ChipCode CLI 设计:NDJSON 输出与 0/1/2/4/130 退出码
命令行工具只有两个输出通道:stdout 和退出码。前者给人看,后者给脚本看。
大部分 CLI 把这两件事混在一起——错误信息打到 stderr,退出码统一返回 1。这在人手动敲的时候没问题,一旦要接进自动化流程,就立刻不够用了。
ChipCode 的命令行部分(triecode)做了一些不太常见的取舍,这篇讲讲为什么。
为什么不用人类可读的表格
默认输出是 NDJSON:一行一个 JSON 对象,每行独立可解析。
{"event":"start","task":"compile","ts":1793905970}
{"event":"log","level":"info","msg":"Using board: esp32dev"}
{"event":"artifact","path":"build/firmware.bin","size":912384}
{"event":"end","task":"compile","status":"pass","took_ms":8412}
为什么不是一张对齐得漂漂亮亮的表?
因为这张表在下游就是一坨需要正则的文本。而 NDJSON 的每一行都能被 jq、被 Python 的 json.loads、被任何语言的流式解析器直接吃掉,不需要约定列宽、不需要处理中文对齐、不会因为某个字段里出现空格就错位。
流式的另一个好处是能边跑边看。编译一个 ESP-IDF 项目可能要两分钟,如果等它结束才输出,用户只能盯着空白。NDJSON 是一行一行出来的,管道给谁都能实时消费。
代价是人工直接看会有点吵。所以我们留了 --pretty,它的作用只是在末尾把同一批事件汇总成人能读的格式——事件流本身不变。
退出码:把“为什么失败”编码进去
这是这套设计里我们讨论最久的部分。最终的五档:
| 码 | 含义 |
|---|---|
| 0 | 成功,且所有断言都通过 |
| 1 | 执行失败(编译错误、烧录失败、进程异常) |
| 2 | 用法错误(参数不合法、环境缺失、配置读不到) |
| 4 | 执行成功,但验证不通过 |
| 130 | 被用户中断(SIGINT) |
前两档没什么争议。争议全在 4。
一开始我们是 0 和 1 两档:成功返回 0,失败返回 1。用了一段时间之后发现一个问题:“编译过了但验收没过”和“编译都没过”被混成同一件事了。
这在脚本里是要命的。举个真实场景:
triecode run --stage software -q
if [ $? -eq 0 ]; then
triecode run --stage integration
fi
如果编译成功但三条验收条件只过了两条,它返回 1——脚本就停在软件阶段,永远不会往下走。可实际上这时候最该做的是进入联调,让人看着跑一遍,而不是卡在原地重试编译。
拆出 4 之后:
triecode run --stage software -q
code=$?
case $code in
0) triecode run --stage integration ;;
1) echo "编译/执行失败,看日志" >&2; exit 1 ;;
4) echo "执行完成,但有未通过项,转人工" ; triecode report --stage software ;;
esac
4 是个很老的习惯——很多 Unix 工具用它表示“有差异”。我们借了这个语义:动作完成了,但结果不符合预期。
2 和 1 为什么要分开
因为它们的修复动作完全不同。
1 意味着“你写的东西有问题”,去看编译日志。2 意味着“你的调用方式有问题”,去看命令本身。
把这两者混在一起,用户第一反应永远是“我的代码错了”,然后花二十分钟翻日志,最后发现是自己少写了一个 --project。
把用法错误单独拎出来,错误信息可以写得更直接:不打印堆栈,只打印“缺少参数 --project,可用值见 triecode init --help”。这类错误不需要上下文,只需要指路。
130 是个约定
128 + 2,SIGINT。这是 shell 的通行做法,不是我们发明的。
单独处理它的意义在于:中断不是失败。
用户按 Ctrl+C 停下来,脚本不应该把这当成“构建失败”去触发告警、去回滚、去发通知。它应该做的是清理临时文件、保留已有产物、然后安静退出。
我们在 cli driver 里对取消做了三件事:给子进程发终止信号、等它真的退出(带超时强杀)、把已经收到的输出写进日志。这三件事的顺序不能反——先杀再收输出会丢日志。
一个具体的坑:stdout 被谁占了
NDJSON 走 stdout,那子进程的输出怎么办?
如果直接把 make 的输出接到 stdout,事件流就被污染了,下游解析会崩。
我们的做法是:子进程的 stdout/stderr 一律捕获,转成 {"event":"log"} 事件再写出去。 stdout 上永远只有宿主自己产生的 JSON 行。
这带来一个副作用:长时间运行的命令如果输出量很大,内存会涨。所以加了行数上限和滚动丢弃——只保留最近 N 行,更早的落盘。这个 N 我们定在 2000,实测够用。
它不是给所有人用的
说清楚边界:这套东西的目标用户是“要把构建流程接进 CI 或者别的脚本”的人。
如果你只是自己在本地点点按钮,图形界面比命令行舒服得多。
CLI 存在的意义是可组合。而可组合的前提是输出可解析、退出码有语义、错误可区分。这三件事做好了,它就能被任何东西调用;做不好,它就只是一个长得像命令行的按钮。
我们在这上面花的功夫,比写业务逻辑多。