diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 5ee9d06358..37ab609908 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920 -2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b +2026-08-06-app-owned-command-line.md: 6d84ba457564ef250e1acfbcc71fcc91b1d49aee +2026-08-06-app-owned-command-line.zh.md: f964f7a7de7aae7e97b52fbc572443352dc5ae26 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 2480775f65..6d84ba4575 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program)` with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index d754c125d5..f964f7a7de 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program)`,再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml new file mode 100644 index 0000000000..97163e3a85 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md +2026-08-11-cmdline-program-action.md: 40c4dae1d3461f25ac7f34dee7c166434e6cd24d +2026-08-11-cmdline-program-action.zh.md: 91036f1c52b60d28055935813d6698205f045422 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md new file mode 100644 index 0000000000..40c4dae1d3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline runs the program's own commander action + +Status: implemented + +English | [中文](2026-08-11-cmdline-program-action.zh.md) + +## Problem + +`dsh-cmdline`'s ([app-owned command line](../architecture/2026-08-06-app-owned-command-line.md)) `parseCmdline` carried a bespoke callback: `CmdlinePlan = (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 `Command` for the caller to read**: a post-parse `program.error(...)` in the caller escapes the helper's catch as an uncaught `CommanderError`, 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**: `InvalidArgumentError` covers 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 `CmdlineArgs` accessor with a bare frozen `readonly string[]` service**: the maintainer keeps the accessor object as the service's named interface. + +## Consequences + +- `parseCmdline` loses its generic, callback parameter, and `undefined` sentinel; callers lose the `if (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`, not `parseAsync`, so a returned promise would escape the catch unobserved. diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md new file mode 100644 index 0000000000..91036f1c52 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline 运行 program 自己的 commander action + +Status: implemented + +[English](2026-08-11-cmdline-program-action.md) | 中文 + +## Problem + +`dsh-cmdline`([应用自有命令行](../architecture/2026-08-06-app-owned-command-line.md))的 `parseCmdline` 曾带着一个自造的回调:`CmdlinePlan = (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。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c7834c3658..c989d9ce3e 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -227,8 +227,8 @@ function createStartupFixture(): StartupFixture { "export const inject = ['cmdlineArgs']", 'export function apply(ctx) {', " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", - ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', - ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', + " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))", + ' parseCmdline(ctx, program)', '}', '', ].join('\n')) diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 91dba947bb..a7b9b1d39a 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 -publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a +publish.md: 588531a28020ebe620643cd1aaaa43de000e658a +publish.zh.md: 938e4b0aa2ea09f80fce897413e9c57f90d3209a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 8437c7ea5c..588531a280 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -117,7 +117,7 @@ A bundle that defines a runnable app mounts an ordinary provider plugin: name: 'dsh-hello-plugin/startup' ``` -The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides its app-owned service from the program's action. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 4409dbfda0..938e4b0aa2 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -117,7 +117,7 @@ dsh --profile demo name: 'dsh-hello-plugin/startup' ``` -该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再在 program 自己的 action 中把应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9d30c65bb8..22a80a7e13 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: 2e8e58b23785fa78bd2663a459817669309a81be -README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd +README.md: 33125014539e801dbd2952a3b4513cafc80bdcee +README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 2e8e58b237..3312501453 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones ## Ordinary providers and injected config -Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ Every row configured from those values uses ordinary service injection and direc port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. +`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate. ### How injection orders config diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index c04d76905e..7ef49a1027 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 ## 普通提供方与注入配置 -任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ export function apply(ctx: Context): void { port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 +`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。 ### 注入如何排列配置求值 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index ebe8d95aee..c053dcb95f 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -8,7 +8,8 @@ * text, and its parse errors instead of the launcher knowing them. * * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A - * provider may publish the parsed values as its own service, and ordinary rows + * provider may publish the parsed values as its own service from its program's + * commander action, and ordinary rows * can inject that service and read it from lazily resolved config — * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written * beside it. No row has launcher-level command-line status. @@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w stderr: process.stderr, } -/** - * Resolve parsed arguments into an app-owned value. Call - * `program.error(...)` to reject the invocation with a usage message instead - * of throwing. - * @param program - the parsed commander program. - * @param ctx - the plugin context that received the command line. - * @returns the value an ordinary provider plugin may publish. - */ -export type CmdlinePlan = (program: Command, ctx: Context) => T - /** * Parse the launcher's immutable argument snapshot with an app's commander - * program. The caller decides whether and how to publish the returned value; - * this helper has no Loader-row or service ownership semantics. + * program. Commander runs the program's own synchronous action handler on a + * successful parse; app code there publishes its service and rejects an + * invalid invocation with `program.error(...)`. This helper has no Loader-row + * or service ownership semantics. * - * Help, version, and rejected arguments are terminal for the process: commander - * writes the text, the helper requests `ctx.appExit`, and it returns - * `undefined` so the caller publishes nothing. + * Help, version, and rejected arguments — from the grammar or from an action + * — are terminal for the process: commander writes the text and the helper + * requests `ctx.appExit`. The action never runs on help, version, or a + * grammar rejection; an action must reject before it publishes, because + * statements before its `program.error(...)` have already run. * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. - * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's resolved value; omitted returns an empty object. - * @returns the resolved value, or `undefined` when the app asked to exit. - * @throws when the launcher did not provide the command line and exit request. + * @param program - the app's commander program, with its flags, description, + * actions, and any subcommands already declared. + * @throws when the launcher did not provide the command line and exit request, + * or when no command in the program declares an action. */ -export function parseCmdline( - ctx: Context, - program: Command, - plan: CmdlinePlan = (() => ({}) as T), -): T | undefined { +export function parseCmdline(ctx: Context, program: Command): void { // Read through the global service store, not the property proxy: appExit is // an optional host value and the plugin only needs to inject cmdlineArgs. const args = ctx.get('cmdlineArgs') @@ -112,23 +103,54 @@ export function parseCmdline( if (args === undefined || exit === undefined) { throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) } - program + if (!hasAction(program)) { + throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`) + } + configureExitAndOutput(program) + try { + program.parse(args.get(), { from: 'user' }) + } catch (error) { + // exitOverride turns help, version, a parse error, and the action's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + exit(error.exitCode) + } +} + +/** + * Whether any command in the tree declares an action handler. + * + * The `Command` type cannot express the action precondition, so the handler is + * read structurally (as {@link isCommanderError} reads commander's control-flow + * errors): without this guard, a program that forgot its action would parse + * successfully, publish nothing, and surface only as dependent rows pending on + * the absent service. + * @param command - the command whose tree is inspected. + * @returns true when the command or any registered subcommand has an action. + */ +function hasAction(command: Command): boolean { + if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true + return command.commands.some(hasAction) +} + +/** + * Route every command's exit and output through the launcher adapter. + * + * Commander copies `exitOverride` and output configuration into a subcommand + * only at registration, so a root-only override would let an + * already-registered subcommand's rejection write to the process streams and + * call `process.exit` directly, bypassing `ctx.appExit`. + * @param command - the root of the command tree to configure. + */ +function configureExitAndOutput(command: Command): void { + command .exitOverride() .configureOutput({ writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - try { - program.parse(args.get(), { from: 'user' }) - return plan(program, ctx) - } catch (error) { - // exitOverride turns help, version, a parse error, and a plan's own - // program.error() into a CommanderError; commander has already written the - // text through the output configured above. - if (!isCommanderError(error)) throw error - exit(error.exitCode) - return undefined - } + for (const child of command.commands) configureExitAndOutput(child) } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 941bfe727e..d05126a29f 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -14,7 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts' +import { internals, parseCmdline, provideCmdline } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -43,8 +43,8 @@ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port ', 'listen port') } -/** The fixture app's plan: the resolved values its rows read. */ -const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { +/** The fixture app's action body: the resolved values its rows read. */ +const resolveDemo = (program: Command): { port?: number } => { const port = program.opts<{ port?: string }>().port if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) @@ -58,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) * Mount a two-row composition the way a profile boot does: both rows at once, * with Loader ordering config resolution from their injections. * @param args - the invocation's inner arguments. - * @param plan - the app's plan; defaults to the fixture's own. + * @param resolve - the app's action body; defaults to the fixture's own. * @returns the booted fixture. */ async function bootFixture( args: string[], - plan: CmdlinePlan = demoPlan, + resolve: (program: Command) => unknown = resolveDemo, options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) @@ -88,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } globals.__observed = observed globals.__provideDemoArgs = (ctx: Context) => { - const values = parseCmdline(ctx, demoCommand(), plan) - if (values !== undefined) ctx.provide('demoStartup', values) + const program = demoCommand() + program.action(() => { ctx.provide('demoStartup', resolve(program)) }) + parseCmdline(ctx, program) } // The composition, exactly as a profile delivers one: include patches whose @@ -133,7 +134,7 @@ describe('parseCmdline', () => { }) it('recognizes the Loader object form of a provider-service injection', async () => { - const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true }) expect(observed.started).toEqual({ port: 8080 }) }) @@ -144,31 +145,35 @@ describe('parseCmdline', () => { expect(observed.exits).toEqual([0]) }) - it('rejects the invocation from the plan without starting the app', async () => { + it('rejects the invocation from the action without starting the app', async () => { const { observed } = await bootFixture(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([1]) }) - it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { throw new Error('plan exploded') } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') + it('rethrows an action failure that is not commander asking to exit', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { throw new Error('action exploded') }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { - const thrown: unknown = 'plan threw a string' + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { + const thrown: unknown = 'action threw a string' throw thrown - } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') + }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string') }) - it('returns values without inspecting Loader rows or owning a service', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - expect(parseCmdline(ctx, demoCommand())).toEqual({}) + it('runs the action without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + expect(values).toEqual({}) expect(ctx.get('demoStartup')).toBeUndefined() }) }) @@ -182,6 +187,28 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) + it('refuses at load a program in which no command declares an action', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + expect(() => { parseCmdline(ctx, demoCommand()) }) + .toThrow('no command in the program declares an action') + }) + + it('routes a pre-registered subcommand rejection through the launcher exit request', () => { + const ctx = new Context() + const exits: number[] = [] + let err = '' + internals.stderr = { write: (chunk: string) => { err += chunk; return true } } + provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) }) + // The root declares no action of its own: the tree-wide guard accepts the + // subcommand's, and the subcommand inherits the exit and output routing. + const program = new Command().name('demo') + const child = program.command('serve') + child.action(() => { child.error('error: serve rejected') }) + parseCmdline(ctx, program) + expect(err).toContain('serve rejected') + expect(exits).toEqual([1]) + }) + it('fails loud when a parser runs without the launcher values', () => { const ctx = new Context() expect(() => { parseCmdline(ctx, demoCommand()) }) @@ -191,8 +218,15 @@ describe('provideCmdline', () => { it('lets multiple parsers read the same immutable snapshot', () => { const ctx = new Context() provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + const parseOnce = (): unknown => { + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + return values + } + expect(parseOnce()).toEqual({ port: 8080 }) + expect(parseOnce()).toEqual({ port: 8080 }) expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) }) }) diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index bfb4d44e51..cb56b5ae9a 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -41,22 +41,17 @@ Examples: } /** - * Turn the parsed command line into the runner's task. - * @param program - the parsed headless command. - * @returns the runner's service value. - */ -function planHeadlessStartup(program: Command): HeadlessStartupValues { - const task = program.args.join(' ') - if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - return { task } -} - -/** - * Parse and provide the one-shot task as an ordinary Cordis service. + * Parse and provide the one-shot task as an ordinary Cordis service. The + * command's action publishes the task; a missing or whitespace-only task is a + * usage error, so on rejection (and on `--help`) nothing is provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once the task is provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) - if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) + const program = headlessCommand() + program.action(() => { + const task = program.args.join(' ') + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues) + }) + parseCmdline(ctx, program) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 90de34b01d..2aaf89a742 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -57,28 +57,24 @@ Examples: } /** - * Turn the parsed flags into the value injected rows read. - * @param program - the parsed web command. - * @returns this invocation's immutable Web options. - */ -function planWebStartup(program: Command): WebStartupValues { - const options = program.opts() - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - return { - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - trustedHosts: options.trustedHost ?? [], - } -} - -/** - * Parse and provide the Web invocation as an ordinary Cordis service. + * Parse and provide the Web invocation as an ordinary Cordis service. The + * command's action publishes the flags this invocation named; a non-numeric + * `--port` is a usage error, so on rejection (and on `--help`) nothing is + * provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once values are provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) + const program = webCommand() + program.action(() => { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + ctx.provide(WEB_STARTUP_SERVICE, { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + trustedHosts: options.trustedHost ?? [], + } satisfies WebStartupValues) + }) + parseCmdline(ctx, program) }