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 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({})
})
})