实战中高频使用的CLI工具技巧与避坑指南

西门诗谣 工具 阅读 989
赞 24 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

最近给团队新搭一个组件库的发布流程,又翻出来 CLI 工具这事。不是那种“写个 hello world 就完事”的玩具 CLI,而是真要跑在 CI 里、要支持多环境配置、要能本地调试、还要能自动推 tag 和发 npm 包的生产级 CLI。我试了三套方案:纯 Node.js + commander、oclif、以及 pnpm 的 exec + esbuild 打包脚本组合。没看文档前我以为 commander 最稳妥,结果折腾两天发现它在子命令嵌套、类型提示、错误堆栈和帮助文案自动生成上,真·力不从心。

实战中高频使用的CLI工具技巧与避坑指南

先说结论:我现在默认选 oclif,除非项目极小(比如就一个脚本,50 行以内),否则我基本不碰裸 commander;而 pnpm + esbuild 那套——我把它当“临时快枪手”,debug 时爽,上线后立刻换掉。

commander:亲切但容易翻车

我最早用 commander 是 2018 年,那时候它还是 v2,API 还挺朴素。现在 v11 确实加了不少糖,比如 .addOption()、.hook(),但底层还是靠一堆 if-else 和字符串匹配来分发命令,一不小心就写出“help 显示不出来”“–help 不进 hook”“子命令参数被父命令吞掉”这种问题。

举个真实例子:我们要支持 npx my-cli publish --tag beta --dry-run,同时允许 npx my-cli publish:canary --preid next。commander 要么得手动注册 publish:canary 为独立 command(失去父子语义),要么用 addCommand(new Command().name(‘publish:canary’)),结果 help 里显示的是 publish:canary [options],但 my-cli publish:canary --help 直接报错 —— 因为它没把 : 当作合法命名字符处理,得 hack 去 patch name 属性。

代码也确实“看着清爽”,但实际维护起来,我踩过三次坑:

  • option 的 default 值如果是数组,传空参数时不会触发 default,而是变成 undefined;
  • –no-xxx 这种布尔否定选项,必须显式声明 .boolean(),否则解析失败;
  • help 文案不能自动对齐,长选项名一多就全歪了,还得自己写 formatHelpText。

这是最简 publish 命令的写法(v11):

import { Command } from 'commander';
const program = new Command();

program
  .command('publish')
  .description('发布组件包')
  .option('--tag <name>', 'npm dist-tag 名称')
  .option('--dry-run', '仅打印操作,不执行')
  .action((options) => {
    console.log('publishing with', options);
  });

program.parse();

oclif:重一点,但稳得一批

我第一次用 oclif 是在写一个 AWS 资源清理工具时,当时被它的 plugin 机制和 bin 自动注册惊艳到了。它本质是基于 TypeScript 的 CLI 框架,强制你按命令拆文件,每个命令一个 class,自带生命周期(init、run)、自动 help、自动版本号、自动 update 检查(可关),连 Windows 下的 shebang 都给你处理好了。

关键是我再也不用操心——

  • help 页面格式是否整齐(它用 cli-ux 渲染,对齐、缩进、颜色全包);
  • 参数校验怎么写(@oclif/core 内置了 flag 类型系统,string/number/boolean/array 都有 validator);
  • 错误堆栈要不要截断(默认只显示 error.message,加 –debug 可展开);
  • 用户输错命令,它会自动 fuzzy match 推荐近似命令(比如输 publis,提示 Did you mean publish?)。

而且它生成的 CLI 是单二进制(esbuild 打包后),部署到 CI 只需一个文件,不用装依赖。我们上线后发现,CI 中 node_modules 权限偶尔出问题,commander 版本直接挂,oclif 版本照常跑。

一个 publish 命令长这样(路径:src/commands/publish.ts):

import { Command, Flags } from '@oclif/core';

export default class Publish extends Command {
  static description = '发布组件包';
  static examples = ['<%= config.bin %> <%= command.id %> --tag beta'];

  static flags = {
    tag: Flags.string({ char: 't', description: 'npm dist-tag 名称' }),
    'dry-run': Flags.boolean({ description: '仅打印操作,不执行' }),
  };

  public async run(): Promise<void> {
    const { flags } = await this.parse(Publish);
    this.log(publishing with tag: ${flags.tag});
    if (flags['dry-run']) this.warn('dry run mode enabled');
  }
}

运行 npx my-cli publish --help,出来的帮助页是带颜色、自动换行、对齐的,连 description 换行都给你做了 word-wrap。这东西不是“更好看”,是“少花 3 小时调样式”。

pnpm + esbuild:快枪手,不是主力

这个方案其实不算“CLI 框架”,是我们内部一个快速验证脚本:用 pnpm exec 调用 ts-node,再用 esbuild 打个轻量版 CLI 入口。优势是启动快、改完即跑、不生成 .bin、不走 package.json bin 字段,适合那种“今天要批量改 20 个组件的 package.json 字段”的一次性任务。

比如这个 publish:check 脚本:

pnpm exec ts-node -r tsconfig-paths/register scripts/check-publish.ts

或者打包成单文件:

esbuild scripts/publish.ts --bundle --platform=node --target=node18 --outfile=dist/publish.js

但它的问题也很明显:没有命令发现机制、没有 help 自动生成、没有 flag 解析器、所有校验都得手写。有一次我把 –dry-run 写成 –dryrun,脚本默默跳过校验直接发包了……还好是 internal registry,不然就真翻车了。

所以我的规则很粗暴:超过 3 个 flag 或 2 个子命令,立刻上 oclif;否则就用 pnpm exec 快速跑一遍,不提交,不进 main 分支。

我的选型逻辑

不是看谁文档厚、谁 star 多,而是看三件事:

  • 我愿不愿意给它写单元测试? oclif 的 Command.run() 是普通函数,mock this.log、this.error 很方便;commander 的 action 回调闭包太深,mock 成本高;pnpm+ts-node 根本没法测(入口就是执行)。
  • 用户输错命令时,ta 是看到报错后 Google,还是直接被提示纠正? oclif 做到了后者,commander 默认啥也不干,pnpm 方案得你自己 throw new Error(‘Unknown command’)。
  • 三个月后我回来修 bug,还能 5 分钟看懂逻辑吗? oclif 的文件结构(commands/xxx.ts)一眼定位;commander 常常是 index.ts 里几百行 if-else;pnpm 脚本散在 scripts/ 下,没有统一入口。

顺带提一句:别迷信“零依赖”。oclif 看似依赖多,但 runtime 只依赖 @oclif/core(~200KB),而且它帮你挡掉了 node 版本兼容、Windows line ending、stdin 流控制这些脏活。commander 依赖少,但你要自己补的轮子加起来可能更多。

结语

以上是我过去半年在三个中型项目里反复对比、上线、回滚、再上线后的总结。oclif 不是银弹——它编译慢、TS 类型有时绕、plugin 生态不如 commander 宽泛,但对我目前的使用场景来说,它省下的时间远大于学习成本。

如果你也在搭一个要长期维护的 CLI,别图省事用 commander 写着写着就成意大利面条;也别为了“轻量”硬上 pnpm exec,最后 debug 半天发现是 shell 参数转义问题。

以上是我的对比总结,有不同看法欢迎评论区交流。另外,下篇可能会写 oclif 插件怎么对接 jztheme.com 的 API(比如 my-cli theme:deploy --api https://jztheme.com/api/v1 这种),如果感兴趣可以留言催更。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论