在这篇博文中,我们学习如何通过Node.js ESM模块实现shell脚本。有两种常见的方法:
- 我们可以写一个独立的脚本,然后自己安装它。
- 我们可以把我们的脚本放在一个npm包里,并使用包管理器来安装它。这也让我们可以选择将包发布到npm注册表上,这样别人也可以安装它。
目录
- 所需知识
- 在Unix上将Node.js ESM模块作为独立的shell脚本使用
- 用shell脚本创建一个npm包
- npm如何安装shell脚本
- 将示例包发布到npm注册中心
- 在Unix上使用任意扩展的独立Node.js shell脚本
- 在Windows上独立的Node.js shell脚本
- 为Linux、macOS和Windows创建本地二进制文件
- 外壳路径:确保外壳找到脚本
- 进一步阅读
必要的知识
你应该对以下两个主题有一定的了解:
- ECMAScript模块,在 "JavaScript for impatient programmers "中的"modules "一章中解释。
- npm包,在博文 "通过包发布和消费ECMAScript模块--大背景 "中解释。
Node.js ESM模块作为Unix上的独立shell脚本
我们将首先探索为Unix创建简单的独立shell脚本,因为这将教会我们用shell脚本创建包所需要的知识。稍后我们会得到更多关于Unix和Windows的独立脚本的信息。
让我们把ESM模块变成一个Unix的shell脚本,我们可以在不在软件包内运行它。原则上,我们可以为ESM模块选择两种文件名扩展名:
-
.mjs
文件总是被解释为ESM模块。 -
.js
文件只有在最接近的 有以下条目时才会被解释为ESM模块。package.json
"type": "module"
然而,由于我们想创建一个独立的脚本,我们不能依靠package.json
。因此,我们必须使用文件名扩展名.mjs
(我们将在后面讲到变通方法)。
下面这个文件的名字是hello.mjs
。
import * as os from 'node:os';
const {username} = os.userInfo();
console.log(`Hello ${username}!`);
我们已经可以运行这个文件了。
node hello.mjs
Unix上的Node.js shell脚本
我们需要做两件事,以便我们可以像这样运行hello.mjs
。
./hello.mjs
这些事情是
- 在文件的开头添加一个hashbang行:
hello.mjs
- 使
hello.mjs
可执行
Unix上的哈希班(Hashbangs
在Unix的shell脚本中,第一行是一个hashbang--告诉shell如何执行文件的元数据。例如,这是Node.js脚本最常见的hashbang。
#!/usr/bin/env node
这一行的名字是 "hashbang",因为它以一个哈希符号和一个感叹号开头。它也经常被称为 "shebang"。
如果一行以哈希符号开头,在大多数Unix shells(sh、bash、zsh等)中就是一个注释。因此,hashbang被这些shells所忽略。Node.js也会忽略它,但只有当它是第一行的时候。
为什么我们不使用这个hashbang呢?
#!/usr/bin/node
不是所有的Unix都在这个路径上安装Node.js二进制文件。那么这个路径如何呢?
#!node
唉,不是所有的Unix系统都允许相对路径。这就是为什么我们通过绝对路径引用env
,并使用它来为我们运行node
。
关于Unix hashbangs的更多信息,见Alex Ewerlöf的"Node.js shebang"。
向Node.js二进制文件传递参数
如果我们想向Node.js二进制文件传递参数,如命令行选项,该怎么办?
一个在许多Unix上都有效的解决方案是使用选项-S
,用于env
,这可以防止它将所有的参数解释为二进制的单一名称。
#!/usr/bin/env -S node --disable-proto=throw
在macOS上,即使没有-S
,前面的命令也能工作;在Linux上,通常不能。
哈希邦陷阱:在Windows上创建哈希邦
如果我们在Windows上使用文本编辑器来创建一个ESM模块,该模块应该作为一个脚本在Unix或Windows上运行,我们必须添加一个哈希邦。如果我们这样做,第一行将以Windows的行结束符\r\n
。
#!/usr/bin/env node\r\n
在Unix上运行带有这种哈希邦的文件会产生以下错误:
env: node\r: No such file or directory
也就是说,env
认为可执行文件的名称是node\r
。有两种方法可以解决这个问题。
首先,一些编辑器会自动检查文件中已经使用了哪些行结束符,并继续使用它们。例如,Visual Studio Code,在右下方的状态栏中显示当前的行结束符(它称之为 "行结束序列"):
LF
(换行)代表Unix的行结束符\n
CRLF
(回车、换行)为Windows的行结束符\r\n
我们可以通过点击该状态信息来切换挑选行结束符。
其次,我们可以创建一个最小的文件my-script.mjs
,其中只有Unix的行结束符,我们在Windows上从不编辑。
#!/usr/bin/env node
import './main.mjs';
使文件在Unix上可执行
为了成为一个shell脚本,hello.mjs
,除了有hashbang外,还必须是可执行的(文件的权限)。
chmod u+x hello.mjs
请注意,我们为创建该文件的用户(u
)制作了可执行文件(x
),而不是为所有人制作。
直接运行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
。变通方法是可能的,但很复杂,我们以后会看到。
用shell脚本创建一个npm包
在本节中,我们用shell脚本创建一个npm包。然后我们研究如何安装这样一个包,以便它的脚本可以在你的系统(Unix或Windows)的命令行中使用。
完成的软件包可以在这里找到:
- 在GitHub上为
rauschma/demo-shell-scripts
- 在npm上为
@rauschma/demo-shell-scripts
设置软件包的目录
下面的命令在Unix和Windows上都适用。
mkdir demo-shell-scripts
cd demo-shell-scripts
npm init --yes
现在有了以下文件:
demo-shell-scripts/
package.json
package.json
用于未发布的软件包
一种选择是创建一个包,但不把它发布到npm注册表上。我们仍然可以在我们的系统上安装这样一个包(后面会解释)。在这种情况下,我们的package.json
,看起来如下:
{
"private": true,
"license": "UNLICENSED"
}
解释一下:
- 把包变成私有的意味着不需要名称和版本,也不可能被意外地发布。
"UNLICENSED"
拒绝其他人在任何条件下使用该软件包的权利。
package.json
用于发布的包
如果我们想把我们的包发布到npm注册中心,我们的package.json
,看起来像这样:
{
"name": "@rauschma/demo-shell-scripts",
"version": "1.0.0",
"license": "MIT"
}
对于你自己的包,你需要用一个适合你的包名来替换"name"
的值:
-
要么是一个全球唯一的名字。这样的名字应该只用于重要的包,因为我们不想阻止其他人使用这个名字。
-
或者一个范围内的名字:要发布一个包,你需要一个npm账户(如何获得一个账户将在后面解释)。你的账户名称可以作为软件包名称的范围。例如,如果你的账户名是
jane
,你可以使用下面的包名。"name": "@jane/demo-shell-scripts"
添加依赖性
接下来,我们安装一个我们想在某个脚本中使用的依赖项--包lodash-es
(Lodash的ESM版本)。
npm install lodash-es
这个命令:
-
创建目录
node_modules
。 -
将软件包
lodash-es
安装到其中。 -
在
package.json
中添加了以下属性。"dependencies": { "lodash-es": "^4.17.21" }
-
创建文件
package-lock.json
。
如果我们只在开发过程中使用一个包,我们可以把它添加到"devDependencies"
,而不是添加到"dependencies"
,如果我们在我们包的目录内运行npm install
,npm才会安装它,但如果我们把它作为依赖关系来安装,则不会。一个单元测试库是一个典型的开发依赖项。
这就是我们安装开发依赖的两种方式:
- 通过
npm install some-package
。 - 我们可以使用
npm install some-package --save-dev
,然后手动将some-package
的条目从"dependencies"
移到"devDependencies"
。
第二种方式意味着我们可以轻松地推迟决定一个包是依赖关系还是开发依赖关系。
向包中添加内容
让我们添加一个readme文件和两个模块homedir.mjs
和versions.mjs
,它们是shell脚本:
demo-shell-scripts/
package.json
package-lock.json
README.md
src/
homedir.mjs
versions.mjs
我们必须告诉npm这两个shell脚本,以便它能为我们安装它们。这就是package.json
中的属性"bin"
的作用。
"bin": {
"homedir": "./src/homedir.mjs",
"versions": "./src/versions.mjs"
}
如果我们安装了这个包,两个名字为homedir
和versions
的 shell 脚本将变得可用。
你可能更喜欢文件名后缀为.js
的 shell 脚本。然后,你必须为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());
这个模块以前面提到的hashbang开始,如果我们想在Unix上使用它,这是必须的。它从内置模块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
。
运行shell脚本而不安装它们
我们可以这样运行,例如:homedir.mjs
。
cd demo-shell-scripts/
node src/homedir.mjs
npm如何安装shell脚本
在Unix上的安装
像homedir.mjs
这样的脚本在Unix上不需要可执行,因为npm通过可执行的符号链接来安装它。
- 如果我们在全局范围内安装该软件包,该链接会被添加到一个列在
$PATH
的目录中。 - 如果我们在本地安装该软件包(作为一个依赖关系),该链接会被添加到
node_modules/.bin/
在Windows上的安装
为了在Windows上安装homedir.mjs
,npm创建了三个文件:
homedir.bat
是一个Command shell脚本,使用 来执行 。node
homedir.mjs
homedir.ps1
对PowerShell也是如此。homedir
对Cygwin、MinGW和MSYS也是如此。
npm将这些文件添加到一个目录中:
- 如果我们在全局范围内安装软件包,这些文件会被添加到一个在
%Path%
中列出的目录。 - 如果我们在本地安装该包(作为依赖关系),这些文件会被添加到
node_modules/.bin/
发布示例包到npm注册中心
让我们把软件包@rauschma/demo-shell-scripts
(我们之前已经创建了)发布到npm。在我们使用npm publish
来上传软件包之前,我们应该检查所有的配置是否正确。
哪些文件被发布了?哪些文件会被忽略?
在发布时,以下机制被用来排除和包含文件:
-
在顶层文件
.gitignore
中列出的文件被排除。- 我们可以用文件
.npmignore
覆盖.gitignore
,它具有相同的格式。
- 我们可以用文件
-
package.json
属性"files"
包含一个数组,其中有被包括的文件名称。这意味着我们可以选择列出我们想排除的文件(在.npmignore
)或我们想包括的文件。 -
有些文件和目录是默认排除的--例如:
node_modules
.*.swp
._*
.DS_Store
.git
.gitignore
.npmignore
.npmrc
npm-debug.log
除了这些默认情况外,点状文件(名字以点开头的文件)被包括在内。
-
以下文件永远不会被排除:
package.json
README.md
和它的变体CHANGELOG
和它的变体LICENSE
,LICENCE
检查软件包的配置是否正确
在上传软件包之前,我们可以检查几件事。
检查哪些文件将被上传
npm install
,在不上传任何东西的情况下运行该命令。
npm publish --dry-run
这将显示哪些文件将被上传,以及关于软件包的一些统计数据。
我们也可以在npm注册表上创建一个软件包的档案。
npm pack
这个命令在当前目录下创建文件rauschma-demo-shell-scripts-1.0.0.tgz
。
全局安装软件包--无需上传
我们可以使用下面两个命令中的任何一个来全局安装我们的包,而不把它发布到npm注册表上。
npm link
npm install . -g
为了看看是否成功,我们可以打开一个新的shell,检查这两个命令是否可用。我们还可以列出所有全局安装的软件包。
npm ls -g
在本地安装软件包(作为依赖关系)--不上传它
要把我们的软件包作为依赖关系安装,我们必须执行以下命令(当我们在目录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
npm publish
: 将软件包上传到npm注册中心
在我们上传我们的包之前,我们需要创建一个npm用户账户。npm文档描述了如何做到这一点。
然后我们就可以最终发布我们的包了。
npm publish --access public
我们必须指定公共访问,因为默认情况是:
-
public
对于无范围的包 -
restricted
为范围内的包。这个设置使包 私有- 这是个付费的npm功能,主要由公司使用,与package.json
中的"private":true
不同。引用npm的话:"通过npm私有包,你可以使用npm注册表来托管只对你和选定的合作者可见的代码,允许你在项目中与公共代码一起管理和使用私有代码。"
选项--access
,只在我们第一次发布时有影响。之后,我们可以省略它,需要用 npm access
来改变访问级别。
我们可以通过以下方式改变初始npm publish
的默认值 publishConfig.access
在package.json
。
"publishConfig": {
"access": "public"
}
每次上传都需要一个新的版本
一旦我们上传了一个特定版本的包,我们就不能再使用这个版本了,我们必须增加版本的三个组成部分中的任何一个。
major.minor.patch
- 如果我们做了破坏性的修改,我们就增加
major
。 - 如果我们做了向后兼容的修改,我们就增加
minor
。 - 如果我们做了没有真正改变API的小修正,我们就增加
patch
。
每次发布前自动执行任务
在上传软件包之前,我们可能每次都要执行一些步骤--例如:
- 运行单元测试
- 将TypeScript代码编译为JavaScript代码
这可以通过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
:
"prepare"
是运行:- 之前
npm pack
- 之前
npm publish
- 在一个没有参数的本地
npm install
之后
- 之前
"prepublishOnly"
只在npm publish
之前运行。
在Unix上具有任意扩展名的独立Node.js shell脚本
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特性:
$1
包含传递给 的第一个参数--脚本的路径。node-esm
- 我们通过
shift
删除参数$0
(node-esm
的路径),并通过$@
将剩余的参数传给node
。 exec
将当前进程替换为运行 的进程。这样可以保证脚本退出时的代码与 相同。node
node
- 连字符 (
-
) 将 Node 的参数与脚本的参数分开。
在我们使用node-esm
之前,我们必须确保它是可执行的,并且可以通过$PATH
。如何做到这一点将在后面解释。
Unix:通过shell prolog的任意文件名扩展
我们已经看到,我们不能为一个文件指定模块类型,只能为标准输入指定。因此,我们可以写一个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代码。
-
作为shell脚本代码,它运行了带引号的命令
':'
,除了扩展参数和执行重定向外,没有任何其他动作。它的唯一参数是路径//
。然后它将当前文件的内容输送到node
二进制文件。 -
作为JavaScript代码,它是字符串
':'
(它被解释为一个表达式语句,什么也不做),后面是一个注释。
从JavaScript中隐藏shell代码的另一个好处是,JavaScript编辑器在处理和显示语法时不会感到困惑。
Windows上独立的Node.js shell脚本
Windows:配置文件名扩展名.mjs
在Windows上创建独立的Node.js shell脚本的一个选择是文件名扩展名.mjs
,并配置它,使有它的文件通过node
。但这只适用于Command 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,使Command shell直接运行文件,如args.mjs
?
当我们在shell中输入一个文件的名字时,文件关联会指定用哪个应用程序打开。如果我们将文件名扩展名.mjs
与Node.js二进制文件相关联,我们就可以在shell中运行ESM模块。一种方法是通过设置应用程序,如Tim Fisher的"如何在Windows中改变文件关联 "中所解释的。
%PATHEXT%
如果我们另外在变量.MJS
,我们甚至可以在提及ESM模块时省略文件名扩展名。这个环境变量可以通过 "设置 "应用程序永久改变--搜索 "变量"。
Windows命令壳:通过shell prolog的Node.js脚本
在Windows上,我们面临的挑战是没有类似hashbangs的机制。因此,我们必须使用一个类似于我们在Unix上用于无扩展文件的变通方法。我们创建一个脚本,通过Node.js在其内部运行JavaScript代码。
命令壳脚本的文件名扩展名是.bat
。我们可以通过script.bat
或script
来运行一个名为script.bat
的脚本。
这是hello.mjs
的样子,如果我们把它变成Command 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
以文件形式运行这段代码,需要两个不存在的功能:
- 使用一个命令行选项来覆盖无扩展名的文件被默认解释为ESM模块。
- 跳过文件开头的行。
因此,我们别无选择,只能将文件的内容输送到node
。我们还使用了以下命令的外壳特征:
%~f0
包含当前脚本的完整路径,包括其文件名扩展名。与此相反, 包含用来调用脚本的命令。因此, 前一个shell变量使我们可以通过 或 来调用脚本.%0
hello
hello.bat
%*
包含命令的参数--我们把这些参数传递给 。node
%errorlevel%
包含最后执行的命令的退出代码。我们使用这个值来退出。
Windows PowerShell。通过shell prolog编写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脚本的执行策略(关于执行策略的更多信息):
- Windows客户端上的默认策略是
Restricted
,不允许我们运行任何脚本。 - 政策
RemoteSigned
,允许我们运行没有签名的本地脚本。下载的脚本必须经过签名。这也是Windows服务器上的默认策略。
下面的命令可以让我们运行本地脚本。
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
为Linux、macOS和Windows创建本地二进制文件
npm软件包pkg
,将一个Node.js包变成一个本地二进制文件,甚至可以在没有安装Node.js的系统上运行。它支持以下平台。Linux、macOS和Windows。
Shell路径:确保shell找到脚本
在大多数shell中,我们可以键入一个文件名,而不直接引用一个文件,它们会在几个目录中搜索具有该名称的文件并运行它。这些目录通常被列在一个特殊的shell变量中:
- 在大多数Unix shells中,我们通过
$PATH
来访问它。 - 在Windows命令壳中,我们通过
%Path%
来访问它。 - 在PowerShell中,我们通过
$Env:PATH
来访问它。
我们需要PATH变量有两个目的:
- 如果我们想安装我们的自定义Node.js可执行文件
node-esm
。 - 如果我们想运行一个独立的shell脚本而不直接引用其文件。
Unix:$PATH
大多数Unix shell有一个变量$PATH
,它列出了当我们输入命令时,shell寻找可执行文件的所有路径。它的值可能看起来像这样:
$ echo $PATH
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
下面的命令在大多数shells(源码)上工作,并改变$PATH
,直到我们离开当前的shell。
export PATH="$PATH:$HOME/bin"
引号是需要的,因为两个shell变量中的一个包含空格。
永久地改变$PATH
在Unix中,如何配置$PATH
,取决于shell。你可以通过以下方式找到你正在运行的shell:
echo $0
MacOS使用Zsh,其中永久配置$PATH
的最佳位置是启动脚本$HOME/.zprofile
-像这样。
path+=('/Library/TeX/texbin')
export PATH
在Windows上改变PATH变量(命令壳,PowerShell)
在Windows上,Command shell和PowerShell的默认环境变量可以通过设置应用程序进行配置(永久)--搜索 "变量"。