前端知库

怎样写一个 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 是一个对象,键是命令名,值是入口文件:

json
{
  "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:

JavaScript
#!/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 能处理这些情况:

JavaScript
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 可以做到:

JavaScript
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:

JavaScript
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,也会作为目录名,所以要限制字符:

JavaScript
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),因为强制退出可能把还没写完的输出截断。