parseCmdline runs the program's own commander action
parseCmdline 运行 program 自己的 commander action
`dsh-cmdline`'s ([app-owned command line](../architecture/2026-08-06-app-owned-command-line.md)) `parseCmdline` carried a bespoke callback: `CmdlinePlan<T> = (program, ctx) => T`, invoked after a successful parse inside the helper's catch so a plan's `program.error(...)` shared the help/parse-error exit path, with a type-unsound `(() => ({}) as T)` default only tests used and a `ctx` argument no plan read. The whole
English
Problem
dsh-cmdline's (app-owned command line) parseCmdline carried a bespoke callback: CmdlinePlan<T> = (program, ctx) => T, invoked after a successful parse inside the helper's catch so a plan's program.error(...) shared the help/parse-error exit path, with a type-unsound (() => ({}) as T) default only tests used and a ctx argument no plan read. The whole seam duplicated a slot commander already defines: a command's action handler runs inside parse, and program.error(...) thrown from it obeys exitOverride exactly like a grammar rejection.
Decision
parseCmdline(ctx, program): void only adapts commander control flow to the launcher: it parses the immutable cmdlineArgs snapshot and turns help, version, parse errors, and action rejections into a ctx.appExit request. App code — validation commander's grammar cannot express and the ctx.provide of the app-owned service — lives in the program's own synchronous .action(), which commander runs on a successful parse and never runs on help or rejection. The CmdlinePlan export, its ctx parameter, the default plan, and the T | undefined return are deleted; both bundle providers publish from their action. Because the Command type cannot express the action precondition, parseCmdline reads the handler structurally (as isCommanderError reads commander's control-flow errors) and refuses at load a program in which no command declares an action — without the guard, a provider that forgot its action (or a stale caller still passing the deleted third argument) parses successfully, publishes nothing, and surfaces only as dependent rows pending on the absent service at settlement. The helper configures exitOverride and output on the whole command tree, not the root alone: commander copies those settings into a subcommand only at registration, so a root-only override would let a pre-registered subcommand's rejection call process.exit past ctx.appExit. An action must reject before it publishes; statements before its program.error(...) have already run.
Verified on commander 15 before shipping: an action runs inside parse and its program.error(...) throws a CommanderError through exitOverride; help and version short-circuit before the action; excess-argument handling is identical with and without an action.
Alternatives considered
- Keeping a bespoke
resolve/plan callback: it existed only so app rejection could share the helper's catch, which commander's action slot already provides; a second callback seam for the same moment in the parse lifecycle is duplication. - Returning the parsed
Commandfor the caller to read: a post-parseprogram.error(...)in the caller escapes the helper's catch as an uncaughtCommanderError, turning a usage rejection into a plugin load failure; every app with validation would rebuild the try/catch the helper owns. - Moving all validation into commander option/argument parsers:
InvalidArgumentErrorcovers per-value checks, but the headless bundle rejects a joined variadic ("task must be non-blank") with its own usage message, which per-argument parsers cannot express. - Accepting an action-less program and relying on the settlement diagnostic: the assembled launcher does fail loud (
pending (waiting for service: …)), but that error names the consumers, not the misconfigured provider, and an embedding host without the settlement assertion would hang silently; the load-time guard reports the culprit program directly. - Replacing the
CmdlineArgsaccessor with a bare frozenreadonly string[]service: the maintainer keeps the accessor object as the service's named interface.
Consequences
parseCmdlineloses its generic, callback parameter, andundefinedsentinel; callers lose theif (values !== undefined)publish guard.- An app's command is self-contained — flags, help text, validation, and the publishing effect travel together on the
Command. - Actions must be synchronous: the helper calls
parse, notparseAsync, so a returned promise would escape the catch unobserved.
中文
Problem
dsh-cmdline(应用自有命令行)的 parseCmdline 曾带着一个自造的回调:CmdlinePlan<T> = (program, ctx) => T,在解析成功后于该适配器的 catch 之内调用,使 plan 的 program.error(...) 与 help/解析错误共用同一条退出路径;它还带有只被测试使用、类型不健全的默认值 (() => ({}) as T),以及没有任何 plan 读取的 ctx 参数。这整条接缝复制了 commander 本就定义的席位:命令的 action 处理器在 parse 内部运行,从中抛出的 program.error(...) 与语法拒绝一样遵循 exitOverride。
Decision
parseCmdline(ctx, program): void 只把 commander 的控制流适配到启动器:它解析不可变的 cmdlineArgs 快照,并把 help、version、解析错误与 action 的拒绝转换为一次 ctx.appExit 请求。应用代码——commander 语法表达不了的校验,以及应用自有服务的 ctx.provide——放在 program 自己的同步 .action() 里,commander 在解析成功时运行它,在 help 或拒绝时绝不运行。CmdlinePlan 导出、其 ctx 参数、默认 plan 与 T | undefined 返回值全部删除;两个组合包提供方都在各自的 action 中发布。由于 Command 类型无法表达 action 前置条件,parseCmdline 按结构读取处理器(如同 isCommanderError 按结构识别 commander 的控制流错误),在加载时拒绝整棵命令树中没有任何命令声明 action 的 program 并点名它——若无此守卫,漏写 action 的提供方(或仍在传已删除第三参数的陈旧调用方)会解析成功、什么也不发布,只在 settlement 时以依赖行 pending 等待缺席服务的形式浮现。该适配器在整棵命令树而非仅根命令上配置 exitOverride 与输出:commander 只在注册时把这些设置复制进子命令,只配置根命令会让已注册子命令的拒绝绕过 ctx.appExit 直接调用 process.exit。action 必须先拒绝后发布;写在 program.error(...) 之前的语句已经执行。
交付前已在 commander 15 上验证:action 在 parse 内部运行,其 program.error(...) 经 exitOverride 抛出 CommanderError;help 与 version 在 action 之前短路;有无 action 时的多余参数处理完全一致。
Alternatives considered
- 保留自造的
resolve/plan 回调:它存在的唯一理由是让应用侧的拒绝共用适配器的 catch,而 commander 的 action 席位本就提供这一点;为解析生命周期的同一时刻再造第二条回调接缝属于重复。 - 返回解析后的
Command交调用方读取:调用方在解析之后调用program.error(...)会以未捕获的CommanderError逃出适配器的 catch,把一次用法拒绝变成插件加载失败;每个带校验的应用都得重建适配器持有的那套 try/catch。 - 把全部校验移进 commander 的 option/argument 解析器:
InvalidArgumentError覆盖逐值检查,但 headless 组合包用自己的用法信息拒绝拼接后的可变参数("任务不得为空白"),逐参数解析器表达不了。 - 接受没有 action 的 program,依赖 settlement 诊断:组装好的启动器确实会大声失败(
pending (waiting for service: …)),但那个错误点名的是消费者而非配置错误的提供方,且没有 settlement 断言的嵌入宿主会静默挂起;加载时守卫直接报出肇事的 program。 - 用裸的冻结
readonly string[]服务替换CmdlineArgs访问器:维护者保留该访问器对象作为服务的具名接口。
Consequences
parseCmdline失去泛型、回调参数与undefined哨兵值;调用方不再需要if (values !== undefined)的发布守卫。- 应用的命令是自包含的——flag、help 文本、校验与发布效果一起挂在
Command上。 - action 必须是同步的:适配器调用的是
parse而非parseAsync,返回的 promise 会在无人观察的情况下逃出 catch。