TypeScript 开发 CLI 工具:参数解析、交互与发布

系统覆盖用 TypeScript 打造生产级命令行工具的全流程:bin 入口与 shebang、commander/yargs 的参数解析与严格模式、帮助与错误输出规范、readline/prompts 标准输入交互、配置文件加载与优先级、tsc/esbuild 产物构建、npm 发布与 files 白名单、以及 execa 驱动的 CLI 集成测试,帮助开发者写出参数严格、交互友好、可发布的命令行应用。

引言

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 与入口

可安装可执行的 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"]
}
维度tscesbuild
产物逐文件保留目录结构单文件 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 与进程管理

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. TypeScript 应用安全加固:依赖、注入与敏感信息防护
  2. TypeScript Monorepo 工程化:pnpm、Turborepo 与多包协作
  3. Node.js Worker Threads:TypeScript 并行计算实战