引言
CLI 是每个工程体系的「第一公民」:脚手架、代码生成器、数据迁移脚本、CI 辅助命令。但 CLI 的坑很集中:process.argv 手写解析难维护、帮助输出不统一、交互卡死无超时、发布后 bin 找不到、测试只能手工敲命令。本文逐一拆掉:参数解析用 commander/yargs 并开严格模式,交互用 readline/prompts,构建用 tsc 保留 shebang,发布管好 bin 与 files,测试用 execa 跑子进程断言。
前置:/typescript-nodejs-backend/(Node.js 运行时与进程)、/typescript-sdk-package-publishing/(npm 发布规范)、/typescript-strict-config/(严格 tsconfig)。
目录
- 1. CLI 工具的骨架:bin、shebang 与入口
- 2. commander 参数解析与子命令
- 3. yargs 的高级解析与严格模式
- 4. 帮助与错误输出的最佳实践
- 5. 标准输入交互:readline 与 prompts
- 6. 配置加载:JSON、YAML 与 env 的优先级
- 7. bin 产物构建:tsc 与 esbuild
- 8. 发布 npm:package.json 的 bin 与 files
- 9. 测试 CLI:集成测试与快照
- 10. 速查表与一句话记忆
- 延伸阅读
1. CLI 工具的骨架:bin、shebang 与入口
可安装可执行的 CLI 需要三件套:package.json 的 bin、脚本首行的 shebang、极薄的入口文件。
{
"name": "plume-cli",
"version": "1.0.0",
"bin": { "plume": "./dist/cli.js" },
"files": ["dist"],
"scripts": { "build": "tsc -p tsconfig.build.json" }
}
bin 的键是全局命令名(plume),值指向发布产物。入口首行必须是 shebang:
#!/usr/bin/env node
import { main } from "./main.js";
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
三个要点:#!/usr/bin/env node 兼容 nvm 等版本管理器;入口只做「调用 + 集中错误处理」,异步异常不静默;用 process.exitCode 而非 process.exit(),让 stdout/stderr 的异步写入自然结束。
2. commander 参数解析与子命令
commander 声明式定义选项、位置参数与子命令:
import { Command } from "commander";
const program = new Command();
program.name("plume").description("Plume 脚手架工具").version("1.0.0");
program
.command("scaffold")
.description("生成项目骨架")
.argument("<name>", "项目名称")
.option("-t, --template <type>", "模板类型", "default")
.option("--force", "覆盖已存在的目录", false)
.action((name: string, opts: { template: string; force: boolean }) => {
console.log(`生成 ${name},模板 ${opts.template}`);
});
program.parse();
关键机制:argument 定义位置参数,<> 必填、[] 可选;option 定义开关,-t, --template <type> 需要值,第三参给默认值;.parse() 放最后,按 process.argv 分流到子命令 action。--template=react 与 --template react 都支持;--no-xxx 自动生成相反语义的布尔选项。
3. yargs 的高级解析与严格模式
yargs 走「解析器为中心」路线,适合参数多、需严格校验的场景:
import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";
const argv = yargs(hideBin(process.argv))
.command(
"scaffold <name>",
"生成项目骨架",
(y) =>
y
.positional("name", { describe: "项目名称", type: "string" })
.option("template", { alias: "t", type: "string", default: "default" })
.option("verbose", { alias: "v", type: "boolean", default: false }),
(args) => runScaffold(args)
)
.strict()
.demandCommand(1, "至少需要一个命令")
.fail((msg, err) => {
console.error(msg ?? err?.message);
process.exitCode = 1;
})
.parseSync();
严格模式三件套:.strict() 让未定义选项直接报错(拼写错误不再静默);.demandCommand 无命令时打印用法退出;.fail 统一接管错误输出。yargs 还自动生成 --help/--version,适合「参数多、帮助要自动维护」的内部工具。
4. 帮助与错误输出的最佳实践
CLI 的门面是帮助与错误:帮助走 stdout、错误走 stderr、退出码语义化。
class CliError extends Error {
constructor(message: string, public readonly exitCode = 1) {
super(message);
}
}
function formatError(err: unknown): string {
if (err instanceof CliError) return `✖ ${err.message}`;
return `✖ 未预期错误:${err instanceof Error ? err.message : String(err)}`;
}
try {
await main(process.argv.slice(2));
} catch (err) {
console.error(formatError(err));
process.exitCode = err instanceof CliError ? err.exitCode : 1;
}
规则:退出码 0 成功、1 一般错误、2 用法错误(commander/yargs 解析失败自动给 2);数据结果走 stdout、诊断错误走 stderr,否则 plume list | grep x 的管道会被错误污染;帮助文本动词开头、≤80 字符、列出默认值。坑:--json 输出模式里严禁混入日志,否则机器解析直接炸。
5. 标准输入交互:readline 与 prompts
交互提问用原生 readline 或 prompts 库。readline 适合单问单答:
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
async function confirm(question: string): Promise<boolean> {
const rl = readline.createInterface({ input, output });
const answer = await rl.question(`${question} (y/N) `);
rl.close();
return answer.toLowerCase() === "y";
}
prompts 提供输入、确认、多选等原语:
import prompts from "prompts";
const res = await prompts([
{ type: "text", name: "name", message: "项目名称?", validate: (v) => v.length > 0 || "不能为空" },
{
type: "select", name: "template", message: "选择模板",
choices: [{ title: "React + Vite", value: "react-vite" }, { title: "Node 服务", value: "node-srv" }],
},
{ type: "confirm", name: "force", message: "覆盖已有目录?", initial: false },
]);
工程要点:非 TTY 时交互库会报错,先检测 process.stdin.isTTY,非 TTY 直接取参数或默认值;rl.question 加超时封装、prompts 监听 onCancel 返回非零退出;密码用 type: "password" 并立即脱敏。
6. 配置加载:JSON、YAML 与 env 的优先级
成熟 CLI 遵守「命令行 > env > 配置文件 > 默认值」的优先级链:
import { cosmiconfig } from "cosmiconfig";
interface Config { registry: string; timeout: number; template: string }
export async function loadConfig(cliArgs: Partial<Config>): Promise<Config> {
const defaults: Config = { registry: "https://npm.example.com", timeout: 30000, template: "default" };
const found = await cosmiconfig("plume").search(process.cwd()); // .plumerc / package.json#plume ...
const fileCfg = (found?.config ?? {}) as Partial<Config>;
const envCfg: Partial<Config> = {
registry: process.env.PLUME_REGISTRY,
timeout: process.env.PLUME_TIMEOUT ? Number(process.env.PLUME_TIMEOUT) : undefined,
};
return { ...defaults, ...fileCfg, ...envCfg, ...cliArgs };
}
要点:env 统一 PLUME_ 前缀防冲突;env 全是字符串,Number()/布尔转换要显式处理("false" 也是真值);配置文件是用户手写,用 zod(见 /typescript-zod-validation/)校验后再合并;文件里相对路径以「配置文件所在目录」为基准,不是 cwd。
7. bin 产物构建:tsc 与 esbuild
CLI 构建要解决 shebang 保留、产物目录干净、ESM/CJS 兼容三件事。tsc 会原样拷贝源文件首行的 shebang;esbuild 需 --banner 注入:
{
"scripts": {
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node'"
}
}
{
"compilerOptions": {
"outDir": "dist", "rootDir": "src", "module": "nodenext", "target": "es2022",
"strict": true, "declaration": true, "sourceMap": true
},
"include": ["src"]
}
| 维度 | tsc | esbuild |
|---|---|---|
| 产物 | 逐文件保留目录结构 | 单文件 bundle |
| shebang | 源文件首行自动保留 | 需 --banner 注入 |
| 速度 | 慢 | 极快 |
tsconfig.build.json 必须 exclude: ["test", "**/*.test.ts"],否则测试文件进产物。发布前跑 node dist/cli.js --help 冒烟验证。
8. 发布 npm:package.json 的 bin 与 files
发布是「bin 生效」的最后一公里:
{
"name": "plume-cli",
"version": "1.0.0",
"bin": { "plume": "./dist/cli.js" },
"files": ["dist", "README.md", "LICENSE"],
"engines": { "node": ">=18" },
"scripts": {
"prepublishOnly": "npm run build && npm test && node dist/cli.js --version"
}
}
要点:files 白名单只发 dist 与文档,src/test/tsconfig 不进包;bin 指向必须存在,否则 npm i -g 后命令静默缺失;prepublishOnly 发布前自动构建 + 测试 + 冒烟;本地用 npm pack --dry-run 看文件清单、npm link 全局链接后直接敲命令验证。坑:bin 目标无 shebang 或权限非可执行,Windows 生成 .cmd 垫片会失败;误发 .js.map 会泄露源码路径。
9. 测试 CLI:集成测试与快照
CLI 的最佳测试是「子进程级集成测试」:用 execa 真实跑编译产物,断言 stdout/stderr/退出码。
import { execa } from "execa";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
const cli = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/cli.js");
it("scaffold 生成目录", async () => {
const { stdout, stderr, exitCode } = await execa("node", [cli, "scaffold", "demo"], { cwd: tmpdir() });
expect(exitCode).toBe(0);
expect(stdout).toContain("生成 demo");
expect(stderr).toBe("");
});
it("未知命令退出码为 2", async () => {
const { exitCode } = await execa("node", [cli, "no-such-cmd"], { reject: false });
expect(exitCode).toBe(2);
});
清单:每个用例 mkdtemp 隔离 cwd;断言退出码用 reject: false;--help 输出 toMatchSnapshot() 防文案漂移;echo y | node cli.js init 验证非 TTY 路径。坑:测试跑「已构建产物」而非 ts-node 源码——否则发现不了 shebang/bundle/路径问题,CI 里先 build 再 test。
10. 速查表与一句话记忆
| 环节 | 关键做法 |
|---|---|
| 入口 | shebang + 极薄 main + 集中错误处理 |
| 参数解析 | commander 子命令 / yargs .strict() |
| 退出码 | 0 成功 / 1 一般 / 2 用法 |
| 交互 | readline/prompts,非 TTY 自动降级 |
| 配置 | cosmiconfig + env 前缀 + 优先级链 |
| 构建 | tsc 保留 shebang / esbuild --banner |
| 发布 | files 白名单 + prepublishOnly 冒烟 |
| 测试 | execa 子进程 + 临时目录 + 快照 |
一句话记忆:CLI = 极薄入口(shebang + 集中错误)+ 严格解析(commander/yargs strict)+ 友好交互(TTY 降级)+ 优先级配置(CLI>env>file>默认)+ 白名单发布(files + 冒烟)+ 子进程测试(execa)。
延伸阅读
- /typescript-nodejs-backend/ — Node.js 运行时、进程与异步
- /typescript-sdk-package-publishing/ — npm 包发布规范与产物管理
- /typescript-strict-config/ — 严格 tsconfig 与编译边界
- /typescript-zod-validation/ — 配置文件的运行时校验
- /typescript-testing-type-safe/ — 类型安全的测试模式
- Node.js 专题 — child_process 与进程管理
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。