CLI 工具是工程效率的放大器:脚手架、代码生成、CI 脚本、运维小助手……Node.js 是写 CLI 最顺手的语言之一。本文从 shebang 与参数解析讲起,覆盖交互提示、输出规范、退出码、打包与 npm 发布,最后带你搭一个实战脚手架工具。
1. CLI 程序原理与 shebang
1.1 可执行入口
CLI 的本质是:一个由 shell 调用的可执行脚本。Node CLI 用 shebang 声明解释器:
#!/usr/bin/env node
// 上面的 shebang 告诉系统用 node 运行本文件
console.log('hello cli');
chmod +x bin/cli.js # 赋予执行权限
./bin/cli.js # 直接运行
1.2 package.json 的 bin 字段
{
"name": "@org/my-cli",
"bin": {
"my-cli": "./bin/cli.js" // 安装后全局生成 my-cli 命令
}
}
一句话:CLI = shebang + bin 字段两步走——shebang 让它能被直接执行,
bin字段让 npm 安装后自动生成全局命令。
2. 参数解析:commander vs yargs
2.1 commander:声明式、子命令友好
#!/usr/bin/env node
import { Command } from 'commander';
const program = new Command();
program
.name('my-cli')
.description('示例 CLI')
.version('1.0.0');
program
.command('create <name>')
.description('创建项目')
.option('-t, --template <t>', '模板名', 'default')
.action((name, opts) => {
console.log(`create ${name} with template=${opts.template}`);
});
program.parse();
my-cli create my-app -t node-ts
# → create my-app with template=node-ts
2.2 yargs:极简、少仪式感
import yargs from 'yargs/yargs';
const argv = await yargs(process.argv.slice(2))
.option('verbose', { type: 'boolean', alias: 'v' })
.command('create <name>', '创建项目', () => {}, (args) => {
console.log(args);
})
.help().argv;
2.3 选型对比
| 维度 | commander | yargs |
|---|---|---|
| 子命令 | 声明式好读 | 支持 |
| 帮助/版本 | 自动生成 | 自动生成 |
| TypeScript | 类型友好 | 一般 |
| 学习曲线 | 平缓 | 平缓 |
一句话:新工具默认 commander——声明式、自动帮助、子命令清晰;yargs 适合想要"开箱即跑"的最简场景。
3. 交互式 CLI:提示与进度
3.1 inquirer 交互提示
import inquirer from 'inquirer';
const answers = await inquirer.prompt([
{ type: 'input', name: 'projectName', message: '项目名', default: 'my-app' },
{ type: 'list', name: 'template', message: '选择模板', choices: ['node-ts', 'express', 'nestjs'] },
{ type: 'confirm', name: 'initGit', message: '初始化 git?', default: true },
{ type: 'checkbox', name: 'features', message: '额外功能', choices: ['eslint', 'prettier', 'ci'] },
]);
3.2 进度条:cli-progress
import cliProgress from 'cli-progress';
const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
bar.start(100, 0);
for (let i = 1; i <= 100; i++) {
await doStep(i);
bar.update(i);
}
bar.stop();
3.3 交互原则
| 原则 | 说明 |
|---|---|
| 有默认值 | 回车即可继续,不强制输入 |
| 可跳过的步骤 | --yes 跳过所有确认 |
| 非交互模式 | CI=true 时不做交互提示 |
| 错误可恢复 | 输入错误给提示,不直接退出 |
// CI 检测:非交互模式直接用默认值
if (process.env.CI || process.env.NODE_ENV !== 'development') {
// 跳过 inquirer,直接取命令行参数
}
一句话:交互式 CLI = inquirer 提示 + 进度条反馈 + 默认值兜底;同时必须支持
--yes和 CI 环境下的非交互模式,否则无法进流水线。
4. 输出规范与 ANSI 颜色
4.1 颜色与样式
// 轻量:直接用 ANSI 码封装
const C = {
red: (s) => `\x1b[31m${s}\x1b[0m`,
green: (s) => `\x1b[32m${s}\x1b[0m`,
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
};
// 或直接装 picocolors / chalk
import pc from 'picocolors';
console.log(pc.green('✔ 构建成功'));
4.2 输出分级
正常信息 → stdout(info)
错误信息 → stderr(error)
进度/状态 → 同 stdout,但禁用颜色时用符号前缀
console.log('info: 开始构建'); // stdout
console.error('error: 文件不存在'); // stderr
4.3 颜色开关
// 非 TTY 或 NO_COLOR 环境关闭颜色
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
一句话:CLI 输出 = stdout 信息 / stderr 错误分流 + ANSI 颜色 + 非 TTY 自动降级——颜色是给终端看的,进了日志文件必须是干净文本。
5. 退出码与错误处理
5.1 退出码语义
0 成功
1 通用错误
2 CLI 用法错误(参数错、命令错)
3+ 业务自定义错误(按错误类型分配)
program.exitOverride(); // 捕获 commander 的用法错误,自定义退出
// 业务错误显式退出
if (!fileExists) {
console.error('error: 配置文件不存在');
process.exit(1);
}
5.2 错误处理规范
async function main() {
try {
await run();
} catch (err) {
// 简洁给用户看
console.error(`${C.red('error')}: ${err.message}`);
if (process.env.DEBUG) console.error(err.stack); // 调试才打堆栈
process.exit(1);
}
}
main();
一句话:退出码是 CLI 与脚本协作的协议——
0 成功 / 1 错误 / 2 用法错,错误信息给用户一条简洁的、堆栈留给DEBUG环境,保证能进 CI 判断成败。
6. 打包与全局安装
6.1 本地开发调试
npm link # 把当前包链接到全局,my-cli 命令即时可用
npm unlink # 解除链接
6.2 打包与发布
npm pack # 生成 tarball 检查内容
npm publish # 发布到 registry
npm i -g @org/my-cli # 用户全局安装
6.3 内容与体积控制
{
"files": ["bin", "dist"], // 只发布需要的目录
"bin": { "my-cli": "./bin/cli.js" },
"engines": { "node": ">=18" } // 声明运行时下限
}
6.4 可选:单文件打包
体积敏感或要分发给非 Node 环境时,用 esbuild 把 CLI 打成单文件自包含可执行文件:
esbuild bin/cli.js --bundle --platform=node --format=cjs --outfile=dist/cli.js
一句话:发布链路 =
npm link本地调试 →files白名单控制内容 →npm publish全局安装;体积敏感用 esbuild 打单文件。
7. 实战:脚手架工具设计
7.1 架构
bin/cli.js —— 入口,只做参数解析与调度
src/commands/ —— 子命令:create、list、upgrade
src/helpers/ —— 通用:模板渲染、git init、依赖安装
src/constants.js —— 模板清单、版本、默认值
7.2 核心流程
async function createProject(name, opts) {
// 1. 校验输入
assertNameValid(name);
// 2. 拷贝模板(或远程拉取)
await copyTemplate(opts.template, name);
// 3. 渲染占位(包名、作者、版本)
await renderTemplates(name);
// 4. 安装依赖
if (!opts.noInstall) await runNpmInstall(name);
// 5. git init 与首提
if (opts.initGit) await gitInit(name);
// 6. 输出下一步指引
printNextSteps(name);
}
7.3 模板占位渲染
// 模板文件里用 {{ projectName }},渲染时替换
const out = template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => vars[key]);
一句话:脚手架 = 参数解析 → 模板拷贝/渲染 → 依赖安装 → git 初始化 → 收尾输出一条流水线;模板占位符统一
{{ key }},逻辑与模板分离,后续加命令只加一个文件。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 忘写 shebang | command not found | 首行 #!/usr/bin/env node |
| 忘了 chmod | Permission denied | chmod +x 或 npm 自动处理 |
| 同步阻塞主线程 | 大文件处理卡死 | 用 fs/promises |
| 交互在 CI 卡死 | 流水线挂起 | CI 检测 + --yes 跳过 |
| 颜色进日志文件 | 日志满是 \x1b | 非 TTY 降级无色 |
| 错误只 console.log | stderr 无错误流 | 错误走 console.error |
| 退出码不区分 | 脚本无法判断失败类型 | 0/1/2/自定义 分层 |
| 发布了大文件 | 全局安装体积大 | files 白名单 + esbuild |
9. 总结
| 环节 | 要点 |
|---|---|
| 入口 | shebang + bin 字段,npm 自动生成命令 |
| 参数 | commander 声明式,子命令 + option |
| 交互 | inquirer 提示 + 进度条 + 默认值兜底 |
| 输出 | stdout/stderr 分流 + ANSI 颜色 + 降级 |
| 退出码 | 0 成功 / 1 错误 / 2 用法错 |
| 发布 | npm link 调试 → files 白名单 → publish |
| 脚手架 | 模板渲染 + 依赖安装 + git init 流水线 |
一句话记住:CLI 工具是"把重复动作封装成一次回车"的工程——参数清晰、交互可跳过、输出可读、退出码规范、发布可控,这样的工具才配得上进入团队工具箱。写完后 npm link 试一遍真实场景,体验和纸上不同。
延伸阅读
- Node.js 核心架构与运行时 — 进程与模块机制
- Node.js TypeScript 工程化实践 — TS 版 CLI 的类型工程
- Node.js 文件系统与路径工程 — CLI 大量文件操作的正确姿势
- Node.js 测试策略 — CLI 命令的自动化测试
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。