怎样写一个 Node.js 命令行工具或项目脚手架?
在 package.json 的 bin 字段里把命令名映射到入口文件,入口文件第一行写
简答
比如团队想要一个
create-admin命令,运行npm create admin my-app就生成一个配好规范的新项目。这就是一个 Node.js 命令行工具,本质上是一个能在终端里直接当命令运行的 JS 文件。让 JS 文件变成命令要两步。一是在
package.json里写bin字段,把命令名映射到入口文件,npm 安装时会把它链接到可执行目录,Windows 上则生成一个.cmd文件。二是入口文件第一行写#!/usr/bin/env node,npm 文档特别提醒,不写这一行脚本就不会用 node 来执行。入口里先解析参数。
process.argv前两项是 node 的路径和脚本路径,后面才是用户输入的参数。手动解析很容易漏情况,Node.js 自带的util.parseArgs从 v20 起是稳定 API,支持长短选项、默认值,遇到未知选项会直接报错。参数没给全时,再用readline在终端里提问。最后把模板目录复制到目标位置,替换里面的项目名等占位符。发布以后,用户不用全局安装,
npx 包名就能运行,npm 会把包下载到缓存里执行。包名叫create-xxx时,npm create xxx会被转成npm exec create-xxx。要注意,传给脚手架的参数要写在--后面,前面的参数会被 npm 自己吃掉。
一个最小的命令长什么样
先看 package.json。bin 是一个对象,键是命令名,值是入口文件:
{
"name": "create-admin",
"version": "1.0.0",
"type": "module",
"bin": {
"create-admin": "./bin/cli.js"
},
"files": ["bin", "templates"]
}
只有一个命令、而且命令名和包名相同时,bin 可以直接写成字符串 "./bin/cli.js",npm 文档说明这两种写法等价。files 决定发布时带上哪些文件,模板目录别忘了写进去。
入口文件第一行是 shebang:
#!/usr/bin/env node
console.log('hello');
为什么要写这一行?在 macOS 和 Linux 上,npm 会给 bin 里的文件建一个符号链接,终端执行这个文件时,看第一行才知道该用哪个程序来跑它。/usr/bin/env node 的意思是在 PATH 里找 node,这样不用写死 node 的安装路径。Windows 不认 shebang,npm 会生成一个 .cmd 包装文件来调用 node,所以同一个包在两个系统上都能用。
开发时不用每次发布再测,在包目录里运行 npm link,就会把命令链接到全局,改了代码直接生效。
怎样解析命令行参数
process.argv 是一个字符串数组。按 Node.js 文档,第一项是 node 可执行文件的路径,第二项是脚本的绝对路径,后面才是用户输入的参数。所以运行 create-admin my-app --template react -f 时,真正的参数是 process.argv.slice(2)。
参数格式很多,--template react、--template=react、-t react、-f 都要支持,自己写很容易漏。util.parseArgs 能处理这些情况:
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
options: {
template: { type: 'string', short: 't', default: 'vanilla' },
force: { type: 'boolean', short: 'f' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
});
// 运行 create-admin my-app --template react -f 时:
// values 是 { template: 'react', force: true },positionals 是 ['my-app']
不传 args 时,parseArgs 默认就解析 process.argv 去掉前两项后的部分。allowPositionals 要显式打开,因为严格模式下它默认是 false,项目名这种不带 -- 的参数会直接报错。
严格模式默认是开的,用户输错选项名,比如 --templete,parseArgs 会抛出 ERR_PARSE_ARGS_UNKNOWN_OPTION 错误。这比悄悄忽略好,用户能马上知道自己写错了。CLI 里可以捕获这个错误,打印帮助信息,再把 process.exitCode 设成非 0,让调用它的脚本知道执行失败了。
parseArgs 在 v18.3.0 加入,v20.0.0 起不再是实验性 API。要兼容更老的 Node.js,或者需要子命令、自动生成帮助信息,再考虑 commander 这类第三方库。
参数不够时怎样交互提问
用户只输入 npm create admin,什么参数都没给,就要在终端里一项项问。Node.js 自带的 readline/promises 可以做到:
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
async function ask(question, fallback) {
// 非终端环境(比如 CI 或管道)不能等用户输入,直接用默认值。
if (!input.isTTY) return fallback;
const rl = readline.createInterface({ input, output });
try {
const answer = await rl.question(`${question}(默认 ${fallback}):`);
return answer.trim() || fallback;
} finally {
rl.close();
}
}
const projectName = positionals[0] ?? (await ask('项目名称', 'my-app'));
为什么要判断 isTTY?在 CI 里或者被别的脚本调用时,没有人能回答问题,程序会一直卡住。Node.js 文档说,标准输入连着终端时 process.stdin 是 tty.ReadStream,isTTY 为 true;不是终端时它就不是 true。所以非终端环境下直接用默认值或者报错退出。需要选择框、多选这类交互时,可以用 @inquirer/prompts、prompts 等库。
怎样根据模板生成项目
模板通常是一个普通目录,放在包里一起发布。生成项目就是两步:把模板目录复制到用户指定的位置,再把文件里的占位符换成真实的值。
复制目录可以用 fs/promises 的 cp,它在 Node.js v22.3.0 起是稳定 API:
import { cp, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const templateDir = fileURLToPath(new URL(`../templates/${values.template}`, import.meta.url));
const targetDir = path.resolve(process.cwd(), projectName);
// force: false 加 errorOnExist: true,目标已存在时报错,而不是覆盖用户的文件。
await cp(templateDir, targetDir, { recursive: true, force: false, errorOnExist: true });
const pkgPath = path.join(targetDir, 'package.json');
const pkg = await readFile(pkgPath, 'utf8');
await writeFile(pkgPath, pkg.replaceAll('{{name}}', projectName));
模板路径为什么用 import.meta.url 算?因为用户在任意目录运行命令,process.cwd() 是用户当前所在的目录,不是脚手架包的安装位置。模板跟着包走,要从入口文件自己的位置去找。目标目录才用 process.cwd()。
占位符替换值得单独写成函数,并先校验项目名。项目名会被写进 package.json,也会作为目录名,所以要限制字符:
function isValidName(name) {
return /^[a-z0-9][a-z0-9._-]*$/.test(name);
}
function render(template, data) {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => (key in data ? data[key] : match));
}
const template = '{"name":"{{name}}","description":"{{desc}}"}';
console.log(String(isValidName('my-app'))); // true
console.log(String(isValidName('My App'))); // false
console.log(render(template, { name: 'my-app' })); // {"name":"my-app","description":"{{desc}}"}
第二行为什么是 false?npm 文档规定新包的名字不能有大写字母,也不能有 URL 里不安全的字符,空格就是其中一种。项目名会直接变成 package.json 的 name,所以生成之前就要挡住。
第三行里没传 desc,占位符原样保留,而不是变成 undefined。这样生成出来的文件一眼能看出哪里没替换,比悄悄写进一个 undefined 好排查。
npx 和 npm create 是怎么找到命令的
用户运行 npx create-admin 时,按 npm 文档,如果当前项目的依赖里没有这个包,npm 会先提示是否安装;它装到 npm 缓存的一个目录里,不会写进当前项目。在 CI 这种非终端环境,npm 会默认同意安装。
一个包可能有多个命令,npm 要决定执行哪一个:bin 里只有一项时就用它;有多项时,用和包名(去掉作用域)同名的那一项;都对不上就报错。所以脚手架通常让主命令和包名一致。
npm create 是 npm init 的别名,它按约定把参数转换成包名:npm init foo 等于 npm exec create-foo,npm init @usr/foo 等于 npm exec @usr/create-foo。所以 create-vite、create-vue 这些包,用户可以写成 npm create vite。
常见追问
为什么 npm create vite my-app --template react 有时候模板参数不生效?
按 npm 文档,
npm init foo -- --hello才会变成npm exec -- create-foo --hello,--前面的选项是给 npm 自己的,后面的才原样传给脚手架。所以要可靠地传选项,写成npm create vite my-app -- --template react。
脚手架用 ESM 还是 CommonJS 写?
新写的工具一般用 ESM,在
package.json里写"type": "module",入口文件里就能用import和import.meta.url。要注意 ESM 里没有__dirname,找模板目录时要用import.meta.url配合fileURLToPath计算。
怎样让命令执行失败时 CI 能发现?
设置非 0 的退出码。Node.js 文档推荐设
process.exitCode = 1让进程自然退出,而不是直接调用process.exit(1),因为强制退出可能把还没写完的输出截断。