使用 Node.js 进行 Shell 脚本编程
您可以购买本书的离线版本(HTML、PDF、EPUB、MOBI),并支持免费在线版本。
(广告,请不要屏蔽。)

14 创建跨平台 shell 脚本



在本章中,我们将学习如何通过 Node.js ESM 模块实现 shell 脚本。有两种常用的方法

14.1 所需知识

您应该大致熟悉以下两个主题

14.1.1 本章接下来的内容

Windows 并不真正支持用 JavaScript 编写的独立 shell 脚本。因此,我们将首先研究如何为 Unix 编写*带有*文件名扩展名的独立脚本。这些知识将帮助我们创建包含 shell 脚本的包。稍后,我们将学习

通过包安装 shell 脚本是§13 “安装 npm 包和运行 bin 脚本”的主题。

14.2 在 Unix 上将 Node.js ESM 模块作为独立的 shell 脚本

让我们将一个 ESM 模块转换为一个 Unix shell 脚本,我们可以运行它,而无需将其放在包中。原则上,我们可以为 ESM 模块选择两个文件名扩展名

但是,由于我们要创建一个独立的脚本,因此我们不能依赖于 package.json 的存在。因此,我们必须使用文件名扩展名 .mjs(我们将在后面介绍解决方法)。

以下文件名为 hello.mjs

import * as os from 'node:os';
const {username} = os.userInfo();
console.log(`Hello ${username}!`);

我们已经可以运行此文件

node hello.mjs

14.2.1 Unix 上的 Node.js shell 脚本

我们需要做两件事,以便我们可以像这样运行 hello.mjs

./hello.mjs

这些事情是

14.2.2 Unix 上的 Hashbang

在 Unix shell 脚本中,第一行是*hashbang* - 元数据,它告诉 shell 如何执行文件。例如,这是 Node.js 脚本最常见的 hashbang

#!/usr/bin/env node

此行之所以称为“hashbang”,是因为它以井号和感叹号开头。它也经常被称为“shebang”。

如果一行以井号开头,则在大多数 Unix shell(sh、bash、zsh 等)中,它都是注释。因此,这些 shell 会忽略 hashbang。Node.js 也会忽略它,但前提是它是第一行。

为什么我们不使用这个 hashbang?

#!/usr/bin/node

并非所有 Unix 都在该路径下安装 Node.js 二进制文件。那么这条路径呢?

#!node

唉,并非所有 Unix 都允许使用相对路径。这就是为什么我们通过绝对路径引用 env,并使用它为我们运行 node

有关 Unix hashbang 的更多信息,请参阅 Alex Ewerlöf 的“Node.js shebang”

14.2.2.1 将参数传递给 Node.js 二进制文件

如果我们想将命令行选项之类的参数传递给 Node.js 二进制文件怎么办?

一种适用于许多 Unix 的解决方案是为 env 使用选项 -S,这可以防止它将其所有参数解释为二进制文件的单个名称

#!/usr/bin/env -S node --disable-proto=throw

在 macOS 上,即使没有 -S,前面的命令也可以工作;在 Linux 上通常不行。

14.2.2.2 Hashbang 陷阱:在 Windows 上创建 hashbang

如果我们在 Windows 上使用文本编辑器创建一个应该在 Unix 或 Windows 上作为脚本运行的 ESM 模块,则必须添加一个 hashbang。如果我们这样做,第一行将以 Windows 行终止符 \r\n 结尾

#!/usr/bin/env node\r\n

在 Unix 上运行具有此类 hashbang 的文件会产生以下错误

env: node\r: No such file or directory

也就是说,env 认为可执行文件的名称是 node\r。有两种方法可以解决此问题。

首先,某些编辑器会自动检查文件中已经使用了哪些行终止符,并继续使用它们。例如,Visual Studio Code 在右下角的状态栏中显示当前行终止符(它称之为“行尾序列”)

我们可以通过单击该状态信息来选择行终止符。

其次,我们可以创建一个最小的文件 my-script.mjs,它只包含我们从不在 Windows 上编辑的 Unix 行终止符

#!/usr/bin/env node
import './main.mjs';

14.2.3 在 Unix 上使文件可执行

为了成为 shell 脚本,除了具有 hashbang 之外,hello.mjs 还必须是可执行的(文件的权限)

chmod u+x hello.mjs

请注意,我们使文件对创建它的用户(u)可执行(x),而不是对所有人可执行。

14.2.4 直接运行 hello.mjs

hello.mjs 现在是可执行的,如下所示

#!/usr/bin/env node

import * as os from 'node:os';

const {username} = os.userInfo();
console.log(`Hello ${username}!`);

因此,我们可以像这样运行它

./hello.mjs

唉,没有办法告诉 node 将具有任意扩展名的文件解释为 ESM 模块。这就是为什么我们必须使用扩展名 .mjs。解决方法是可能的,但很复杂,我们将在后面看到。

14.3 创建包含 shell 脚本的 npm 包

在本节中,我们将创建一个包含 shell 脚本的 npm 包。然后,我们将研究如何安装这样的包,以便它的脚本在系统的命令行(Unix 或 Windows)中可用。

完成的包可在此处获取

14.3.1 设置包目录

这些命令在 Unix 和 Windows 上都有效

mkdir demo-shell-scripts
cd demo-shell-scripts
npm init --yes

现在有以下文件

demo-shell-scripts/
  package.json
14.3.1.1 未发布包的 package.json

一种选择是创建一个包,但不要将其发布到 npm 注册表。我们仍然可以在我们的系统上安装这样的包(如下所述)。在这种情况下,我们的 package.json 如下所示

{
  "private": true,
  "license": "UNLICENSED"
}

说明

14.3.1.2 已发布包的 package.json

如果我们要将包发布到 npm 注册表,则我们的 package.json 如下所示

{
  "name": "@rauschma/demo-shell-scripts",
  "version": "1.0.0",
  "license": "MIT"
}

对于您自己的包,您需要将 "name" 的值替换为适合您的包名称

14.3.2 添加依赖项

接下来,我们将安装要在我们的一个脚本中使用的依赖项 - 包 lodash-esLodash 的 ESM 版本)

npm install lodash-es

此命令

如果我们仅在开发过程中使用某个包,我们可以将其添加到 "devDependencies" 而不是 "dependencies" 中,并且 npm 只会在我们在包目录中运行 npm install 时安装它,而不会在我们将其作为依赖项安装时安装它。单元测试库是典型的开发依赖项。

我们可以通过以下两种方式安装开发依赖项

第二种方式意味着我们可以轻松地推迟决定包是依赖项还是开发依赖项。

14.3.3 向包添加内容

让我们添加一个自述文件和两个模块 homedir.mjsversions.mjs,它们是 shell 脚本

demo-shell-scripts/
  package.json
  package-lock.json
  README.md
  src/
    homedir.mjs
    versions.mjs

我们必须将两个 shell 脚本告知 npm,以便它可以为我们安装它们。这就是 package.json 中属性 "bin" 的用途

"bin": {
  "homedir": "./src/homedir.mjs",
  "versions": "./src/versions.mjs"
}

如果我们安装此包,则名为 homedirversions 的两个 shell 脚本将可用。

您可能更喜欢 shell 脚本的文件名扩展名 .js。然后,您必须将以下两个属性添加到 package.json,而不是之前的属性

"type": "module",
"bin": {
  "homedir": "./src/homedir.js",
  "versions": "./src/versions.js"
}

第一个属性告诉 Node.js 应该将 .js 文件解释为 ESM 模块(而不是默认的 CommonJS 模块)。

homedir.mjs 看起来像这样

#!/usr/bin/env node
import {homedir} from 'node:os';

console.log('Homedir: ' + homedir());

如果我们想在 Unix 上使用此模块,则必须以前面提到的 hashbang 开头。它从内置模块 node:os 导入函数 homedir(),调用它并将结果记录到控制台(即标准输出)。

请注意,homedir.mjs 不必是可执行的;npm 在安装 "bin" 脚本时会确保其可执行性(我们很快就会看到)。

versions.mjs 具有以下内容

#!/usr/bin/env node

import {pick} from 'lodash-es';

console.log(
  pick(process.versions, ['node', 'v8', 'unicode'])
);

我们从 Lodash 导入函数 pick(),并使用它来显示对象 process.versions 的三个属性。

14.3.4 在不安装的情况下运行 shell 脚本

我们可以像这样运行,例如 homedir.mjs

cd demo-shell-scripts/
node src/homedir.mjs

14.4 npm 如何安装 shell 脚本

14.4.1 在 Unix 上安装

homedir.mjs 这样的脚本在 Unix 上不需要是可执行的,因为 npm 通过可执行符号链接安装它

14.4.2 在 Windows 上安装

要在 Windows 上安装 homedir.mjs,npm 会创建三个文件

npm 将这些文件添加到目录中

14.5 将示例包发布到 npm 注册表

让我们将软件包 @rauschma/demo-shell-scripts(我们之前创建的)发布到 npm。在我们使用 npm publish 上传软件包之前,我们应该检查是否已正确配置所有内容。

14.5.1 发布哪些文件?哪些文件被忽略?

发布时使用以下机制来排除和包含文件

npm 文档有更多详细信息,说明发布时包含和排除的内容。

14.5.2 检查软件包是否已正确配置

在我们上传软件包之前,我们可以检查几件事。

14.5.2.1 检查将上传哪些文件

npm install空运行会在不上传任何内容的情况下运行该命令

npm publish --dry-run

这将显示将上传哪些文件以及有关该软件包的一些统计信息。

我们还可以创建一个软件包的存档,因为它将存在于 npm 注册表中

npm pack

此命令在当前目录中创建文件 rauschma-demo-shell-scripts-1.0.0.tgz

14.5.2.2 在全局安装软件包 - 无需上传

我们可以使用以下两个命令中的任何一个在不将软件包发布到 npm 注册表的情况下全局安装它

npm link
npm install . -g

要查看是否有效,我们可以打开一个新的 shell 并检查这两个命令是否可用。我们还可以列出所有全局安装的软件包

npm ls -g
14.5.2.3 在本地安装软件包(作为依赖项)- 无需上传

要将我们的软件包作为依赖项安装,我们必须执行以下命令(当我们在目录 demo-shell-scripts 中时)

cd ..
mkdir sibling-directory
cd sibling-directory
npm init --yes
npm install ../demo-shell-scripts

我们现在可以使用以下两个命令中的任何一个运行,例如 homedir

npx homedir
./node_modules/.bin/homedir

14.5.3 npm publish:将软件包上传到 npm 注册表

在我们上传软件包之前,我们需要创建一个 npm 用户帐户。npm 文档介绍了如何做到这一点

然后我们终于可以发布我们的软件包了

npm publish --access public

我们必须指定公共访问权限,因为默认值为

选项 --access 仅在我们第一次发布时有效。之后,我们可以省略它,并需要使用npm access 来更改访问级别。

我们可以通过 package.json 中的publishConfig.access 更改初始 npm publish 的默认值

"publishConfig": {
  "access": "public"
}
14.5.3.1 每次上传都需要一个新版本

一旦我们上传了具有特定版本的软件包,我们就不能再次使用该版本,我们必须增加该版本三个组件中的任何一个

major.minor.patch

14.5.4 每次发布前自动执行任务

我们可能希望在每次上传软件包之前执行一些步骤,例如

这可以通过 package.json 属性 `“scripts” 自动完成。该属性可能如下所示

"scripts": {
  "build": "tsc",
  "test": "mocha --ui qunit",
  "dry": "npm publish --dry-run",
  "prepublishOnly": "npm run test && npm run build"
}

mocha 是一个单元测试库。 tsc 是 TypeScript 编译器。

以下软件包脚本在 npm publish 之前运行

有关此主题的更多信息,请参阅§15“通过 npm 软件包脚本运行跨平台任务”

14.6 Unix 上具有任意扩展名的独立 Node.js shell 脚本

14.6.1 Unix:通过自定义可执行文件实现任意文件名扩展名

Node.js 二进制文件 node 使用文件名扩展名来检测文件是哪种模块。目前没有命令行选项可以覆盖它。默认值为 CommonJS,这不是我们想要的。

但是,我们可以创建自己的可执行文件来运行 Node.js,例如将其命名为 node-esm。然后,如果我们将第一行更改为以下内容,则可以将之前的独立脚本 hello.mjs 重命名为 hello(没有任何扩展名)

#!/usr/bin/env node-esm

以前,env 的参数是 node

这是 Andrea Giammarchi 提出的node-esm 的实现

#!/usr/bin/env sh
input_file=$1
shift
exec node --input-type=module - $@ < $input_file

此可执行文件通过标准输入将脚本的内容发送到 node。命令行选项 --input-type=module 告诉 Node.js 它接收到的文本是一个 ESM 模块。

我们还使用以下 Unix shell 功能

在我们使用 node-esm 之前,我们必须确保它是可执行的,并且可以通过 $PATH 找到。稍后将说明如何做到这一点。

14.6.2 Unix:通过 shell 前言实现任意文件名扩展名

我们已经看到,我们不能为文件指定模块类型,只能为标准输入指定模块类型。因此,我们可以编写一个 Unix shell 脚本 hello,它使用 Node.js 将自身作为 ESM 模块运行(基于sambal.org 的工作

#!/bin/sh
':' // ; cat "$0" | node --input-type=module - $@ ; exit $?

import * as os from 'node:os';

const {username} = os.userInfo();
console.log(`Hello ${username}!`);

本章开头介绍了我们在这里使用的大多数 shell 功能。 $? 包含最后执行的 shell 命令的退出代码。这使 hello 能够以与 node 相同的代码退出。

此脚本使用的关键技巧是第二行既是 Unix shell 脚本代码又是 JavaScript 代码

对 JavaScript 隐藏 shell 代码的另一个好处是,JavaScript 编辑器在处理和显示语法时不会感到困惑。

14.7 Windows 上的独立 Node.js shell 脚本

14.7.1 Windows:配置文件名扩展名 .mjs

在 Windows 上创建独立 Node.js shell 脚本的一种选择是使用文件名扩展名 .mjs 并将其配置为通过 node 运行具有该扩展名的文件。唉,这只适用于命令行 shell,不适用于 PowerShell。

另一个缺点是我们不能以这种方式将参数传递给脚本

>more args.mjs
console.log(process.argv);

>.\args.mjs one two
[
  'C:\\Program Files\\nodejs\\node.exe',
  'C:\\Users\\jane\\args.mjs'
]

>node args.mjs one two
[
  'C:\\Program Files\\nodejs\\node.exe',
  'C:\\Users\\jane\\args.mjs',
  'one',
  'two'
]

我们如何配置 Windows 以便命令行 shell 直接运行诸如 args.mjs 之类的文件?

文件关联指定了当我们在 shell 中输入文件名时使用哪个应用程序打开文件。如果我们将文件名扩展名 .mjs 与 Node.js 二进制文件相关联,我们就可以在 shell 中运行 ESM 模块。一种方法是通过“设置”应用程序,如 Tim Fisher 在“如何在 Windows 中更改文件关联” 中所述。

如果我们另外将 .MJS 添加到变量 %PATHEXT% 中,我们甚至可以在引用 ESM 模块时省略文件名扩展名。可以通过“设置”应用程序永久更改此环境变量 - 搜索“变量”。

14.7.2 Windows 命令行 shell:通过 shell 前言实现 Node.js 脚本

在 Windows 上,我们面临着没有像 hashbang 这样的机制的挑战。因此,我们必须使用一种类似于我们在 Unix 上用于无扩展名文件的解决方法:我们创建一个脚本,该脚本通过 Node.js 在自身内部运行 JavaScript 代码。

命令行 shell 脚本的文件名扩展名为 .bat。我们可以通过 script.batscript 运行名为 script.bat 的脚本。

如果我们将 hello.mjs 转换为命令行 shell 脚本 hello.bat,则它看起来像这样

:: /*
@echo off
more +5 %~f0 | node --input-type=module - %*
exit /b %errorlevel%
*/

import * as os from 'node:os';
const {username} = os.userInfo();
console.log(`Hello ${username}!`);

通过 node 将此代码作为文件运行需要两个不存在的功能

因此,我们别无选择,只能将文件的内容通过管道传输到 node。我们还使用以下命令行 shell 功能

14.7.3 Windows PowerShell:通过 shell 前导码运行 Node.js 脚本

我们可以使用与上一节类似的技巧,将 hello.mjs 转换为 PowerShell 脚本 hello.ps1,如下所示

Get-Content $PSCommandPath | Select-Object -Skip 3 | node --input-type=module - $args
exit $LastExitCode
<#
import * as os from 'node:os';
const {username} = os.userInfo();
console.log(`Hello ${username}!`);
// #>

我们可以通过以下任一方式运行此脚本

.\hello.ps1
.\hello

但是,在我们这样做之前,我们需要设置一个执行策略,允许我们运行 PowerShell 脚本(有关执行策略的更多信息

以下命令允许我们运行本地脚本

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

14.8 为 Linux、macOS 和 Windows 创建原生二进制文件

npm 包 pkg 将 Node.js 包转换为原生二进制文件,即使在未安装 Node.js 的系统上也可以运行。它支持以下平台:Linux、macOS 和 Windows。

14.9 Shell 路径:确保 shell 找到脚本

在大多数 shell 中,我们可以输入文件名而不直接引用文件,它们会在多个目录中搜索具有该名称的文件并运行它。这些目录通常列在一个特殊的 shell 变量中

我们需要 PATH 变量用于两个目的

14.9.1 Unix:$PATH

大多数 Unix shell 都有变量 $PATH,它列出了当我们输入命令时 shell 在其中查找可执行文件的所有路径。它的值可能如下所示

$ echo $PATH
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin

以下命令适用于大多数 shell(来源),并在我们离开当前 shell 之前更改 $PATH

export PATH="$PATH:$HOME/bin"

如果两个 shell 变量之一包含空格,则需要使用引号。

14.9.1.1 永久更改 $PATH

在 Unix 上,如何配置 $PATH 取决于 shell。您可以通过以下方式找出您正在运行哪个 shell

echo $0

macOS 使用 Zsh,永久配置 $PATH 的最佳位置是启动脚本 $HOME/.zprofile——像这样

path+=('/Library/TeX/texbin')
export PATH

14.9.2 在 Windows 上更改 PATH 变量(命令行 shell、PowerShell)

在 Windows 上,可以通过“设置”应用配置(永久)命令行 shell 和 PowerShell 的默认环境变量——搜索“变量”。