fix(cmdline): reject multiple command-line owners

This commit is contained in:
Turtle
2026-08-09 18:42:54 +08:00
parent d4ccfbd80f
commit 37ee7b0f24
11 changed files with 68 additions and 15 deletions

View File

@@ -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: 948de243abe39c7b4af014f8709e102a53aa9797
2026-08-06-app-owned-command-line.zh.md: 00cce42d123c788f78386a718f7711cad0e0c234
2026-08-06-app-owned-command-line.md: 21433d96d1dbcb26f4104fffb5a78b389d78bca8
2026-08-06-app-owned-command-line.zh.md: 7b123f89c8f844ae396df09136d69215f5ad8d26

View File

@@ -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[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. The rows the app configures inject that service and read it from their own 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[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **startup row**. Both the Loader row and plugin inject `cmdlineArgs`; the plugin calls `runStartup(ctx, service, program, plan)` with its own commander program and provides what it resolved as its own service. The Loader-row injection is also the launcher's discovery declaration; there is no parallel bundle-manifest field. Before boot, the launcher rejects nonempty app arguments with no active declaration and any composition with multiple active declarations. The rows the app configures inject that service and read it from their own 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` provides no startup service, 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.

View File

@@ -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``ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit``ctx.appReady`。应用从自己的**启动行**消费它们。Loader 行与插件都注入 `cmdlineArgs`;插件以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`再把解析结果作为自己的服务提供出去。Loader 行的注入同时也是启动器的发现声明,不再需要一份平行的组合包 manifest 字段。启动器会在 boot 前拒绝没有活跃声明却带有非空应用参数的调用,也会拒绝存在多个活跃声明的组合。应用所配置的行注入该服务,再从各自的配置表达式中读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 不提供启动服务,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。

View File

@@ -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 apps/cli/reference/README.md
README.md: b4a8dfe8a0473e69a0c82e33aba2d1f4210a2477
README.zh.md: 287a215b6abb31c7f0375987210eb9703acf5657
README.md: f28d77ccba7380426df2dd1769e33be0f7256d27
README.zh.md: 3d8fbae31780c00f05384a1e4010fda2b6ce3246

View File

@@ -18,7 +18,7 @@ The launcher's flags come first and end at the first token it does not recognize
A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them.
Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. A composition with multiple active rows injecting `cmdlineArgs` is always rejected because two parsers cannot own the same command line.
The shipped apps own these command lines:

View File

@@ -18,7 +18,7 @@
一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。
启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web``plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。
启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web``plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。若组合中有多个注入 `cmdlineArgs` 的活跃行,启动器总会拒绝该组合,因为两个解析器不能共同持有同一条命令行。
随附的各应用持有这些命令行:

View File

@@ -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: 5a7e267691cd19548a19e812390865f140e3a620
README.zh.md: 6d5892e97708a6bc464dafee3dcba2521129ea93
README.md: dc267080d32d492e132df4592ddf742454a95ad2
README.zh.md: e183156ab4a7907f8ae1e3259b2e09d458cec47d

View File

@@ -35,7 +35,7 @@ The Loader-row injection is also its discovery declaration, so no bundle manifes
inject: [cmdlineArgs]
```
The launcher uses that injection only to reject arguments for a composition with no command-line owner. Loader mounts the composition once and holds each row until its own injections are active.
The launcher uses that injection only to reject arguments for a composition with no command-line owner, and to reject a composition with multiple owners. Loader mounts the composition once and holds each row until its own injections are active.
Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to:

View File

@@ -35,7 +35,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字
inject: [cmdlineArgs]
```
启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。
启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合,以及拒绝存在多个所有者的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。
应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值:

View File

@@ -98,9 +98,28 @@ export function provideCmdline(ctx: Context, host: CmdlineHost): void {
* adding the same injection its startup plugin already requires.
* @param rows - the composed Loader rows.
* @returns whether this composition has a command-line owner.
* @throws when more than one active row claims the command line.
*/
export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean {
return rows.some(row => row.disabled !== true && waitsForAny(row.inject, ['cmdlineArgs']))
const consumers: string[] = []
const visit = (entries: readonly EntryOptions[], ancestorDisabled = false, prefix = ''): void => {
for (const row of entries) {
const id = prefix + row.id
// Loader group containers stay active when disabled, but their children
// inherit that disabled state.
const active = row.group === true || (!ancestorDisabled && row.disabled !== true)
if (active && waitsForAny(row.inject, ['cmdlineArgs'])) consumers.push(id)
if (row.group === true && Array.isArray(row.config)) {
visit(row.config, ancestorDisabled || row.disabled === true, `${id}:`)
}
}
}
visit(rows)
if (consumers.length > 1) {
const ids = consumers.map(id => JSON.stringify(id)).join(', ')
throw new Error(`dsh-cmdline: multiple active rows inject cmdlineArgs (${ids}); disable all but one startup row`)
}
return consumers.length === 1
}
/** The process streams commander output is written to; production writes to the process. */

View File

@@ -130,6 +130,40 @@ describe('hasCmdlineConsumer', () => {
{ id: 'ordinary', name: 'ordinary' },
{ id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true },
])).toBe(false)
expect(() => hasCmdlineConsumer([
{ id: 'web-startup', name: 'web-startup', inject: ['cmdlineArgs'] },
{ id: 'tui-startup', name: 'tui-startup', inject: ['cmdlineArgs'] },
])).toThrow('multiple active rows inject cmdlineArgs ("web-startup", "tui-startup")')
})
it('walks nested groups and ignores consumers disabled by an ancestor', () => {
expect(hasCmdlineConsumer([{
id: 'app',
name: 'cordis:group',
group: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
}])).toBe(true)
expect(hasCmdlineConsumer([{
id: 'app',
name: 'cordis:group',
group: true,
disabled: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
}])).toBe(false)
expect(() => hasCmdlineConsumer([
{
id: 'first',
name: 'cordis:group',
group: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
},
{
id: 'second',
name: 'cordis:group',
group: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
},
])).toThrow('multiple active rows inject cmdlineArgs ("first:startup", "second:startup")')
})
})
@@ -187,9 +221,9 @@ describe('runStartup', () => {
.toThrow('absentStartup: no row injects this startup service')
})
it('provides an empty value when the app declares no plan', async () => {
it('accepts a service-name list when the app declares no plan', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
runStartup(ctx, 'demoStartup', demoCommand())
runStartup(ctx, ['demoStartup'], demoCommand())
expect(ctx.get('demoStartup')).toEqual({})
})
})