refactor(cli)!: the launcher parses only its own flags

Launcher flags come first and end at the first token dsh does not
recognize; everything after reaches the booted app verbatim, so
dsh --profile tui --resume <id> works with no launcher change and
dsh --profile web --help prints the web app's help. A bare dsh -h, which
has no app to hand the flag to, still prints the launcher's own.

src/web.ts is deleted: the Web flag family, its LAN-trust sampling, and the
one-shot task positional now live in their bundles, and runProfile no
longer knows any row id. What the startup row decides comes back as a
launcher-owned patch layer above every layer a user can edit, so a live
config edit recomposes the tree without resetting a served port.

dsh web and dsh --profile web finally boot through one path, which also
gives --profile web the harness-source prompt section that only the alias
used to add.
This commit is contained in:
Turtle
2026-08-06 20:52:26 +08:00
parent 82728808d4
commit 37cbd155f5
16 changed files with 518 additions and 522 deletions

View File

@@ -21,22 +21,28 @@ function exitCode(argv: string[]): number {
afterEach(() => { vi.restoreAllMocks() })
describe('parseDshArgs', () => {
it('routes profile boots, one-shot runs, and the web alias', () => {
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] })
it('routes profile boots and the web alias, handing the rest to the app', () => {
expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] })
expect(parse(['run', 'run', 'the', 'tests']))
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' })
expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests']))
.toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' })
expect(parse(['run', '--', '--profile', 'is', 'task', 'text']))
.toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' })
expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] })
expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] })
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] })
expect(parse(['web', '--patch', 'web.yml']))
.toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] })
})
it('ends the launcher flags at the first token it does not own', () => {
// App flags, including its -h, and positionals reach the app verbatim.
expect(parse(['--profile', 'tui', '--resume', 'abc']))
.toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] })
expect(parse(['--profile', 'web', '-h']))
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] })
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev']))
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] })
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
.toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
.toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] })
expect(parse(['--profile', 'headless', 'run', 'the', 'tests']))
.toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] })
// Launcher flags placed after that boundary belong to the app too.
expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml']))
.toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] })
})
it('routes the plugin pnpm forwarder', () => {
@@ -64,18 +70,12 @@ describe('parseDshArgs', () => {
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
})
it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => {
it('rejects missing profile, removed flags, and contradictory inputs', () => {
expect(exitCode([])).toBe(1)
expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile
expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar
expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar
expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run`
expect(exitCode(['run'])).toBe(1)
expect(exitCode(['run', ''])).toBe(1)
expect(exitCode(['run', '--profile', '', 'task'])).toBe(1)
expect(exitCode(['run', '--patch=', 'task'])).toBe(1)
expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1)
expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1)
expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
expect(exitCode(['-p', 'task'])).toBe(1) // removed
expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
expect(exitCode(['--profile', ''])).toBe(1)
expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
expect(exitCode(['--dump-config'])).toBe(1)
@@ -87,21 +87,20 @@ describe('parseDshArgs', () => {
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1)
expect(exitCode(['web', '--patch='])).toBe(1)
// Boot-free dumps derive no flag patches; silently dropping the flags
// would print a tree that differs from the same invocation's boot.
// A dump never runs the app's startup row, so it cannot show what that
// app's own flags would decide; printing a tree that differs from the same
// invocation's boot would mislead.
expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1)
expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1)
// A non-numeric port fails at the flag, not deep in the webserver schema.
expect(exitCode(['web', '--port', 'abc'])).toBe(1)
expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1)
expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required
expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward
expect(exitCode(['plugin', '--profile', ''])).toBe(1)
expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
})
it('exits 0 for help and version', () => {
it('keeps its own help for an invocation with no app to hand it to', () => {
expect(exitCode(['--help'])).toBe(0)
expect(exitCode(['run', '--help'])).toBe(0)
expect(exitCode(['-h'])).toBe(0)
expect(exitCode(['--version'])).toBe(0)
})
})

View File

@@ -193,8 +193,105 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
].join('\n'))
}
interface StartupFixture {
home: string
ready: string
echo: string
/** An always-running row's echo, used to observe that a user patch reload landed. */
witness: string
}
/**
* A custom profile whose bundle owns a command line: a startup row whose
* `cmdlineArgs` injection identifies it to the launcher, and a row that reads
* what it resolved through a `!!js` config expression. Both plugin modules resolve
* `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
* fallback, exactly as an installed out-of-tree bundle does.
*/
function createStartupFixture(): StartupFixture {
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
const profileDir = join(home, 'profiles', 'startup')
// Written straight into the installed location: a row module resolves its
// own imports from where it is installed, and only inside the profile does
// Node's parent walk reach the installation fallback these plugins need.
const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
mkdirSync(bundleDir, { recursive: true })
writeFileSync(join(bundleDir, 'startup.mjs'), [
"import { Command } from 'commander'",
"import { runStartup } from '@deepseek-ai/dsh-cmdline'",
"export const name = 'fixture-startup'",
"export const inject = ['cmdlineArgs']",
'export function apply(ctx) {',
" const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
" return runStartup(ctx, 'fixtureStartup', program, parsed => ({ generation: parsed.opts().generation }))",
'}',
'',
].join('\n'))
writeFileSync(join(bundleDir, 'waiting.mjs'), [
"import { writeFileSync } from 'node:fs'",
"import { join } from 'node:path'",
"export const name = 'startup-fixture'",
'export function apply(ctx, config = {}) {',
' const heartbeat = setInterval(() => {}, 1000)',
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
" writeFileSync(process.env.RAW_READY_FILE, 'ready')",
' ctx.effect(() => () => { clearInterval(heartbeat) })',
'}',
'',
].join('\n'))
writeFileSync(join(bundleDir, 'witness.mjs'), [
"import { writeFileSync } from 'node:fs'",
"import { join } from 'node:path'",
"export const name = 'reload-witness'",
'export function apply(ctx, config = {}) {',
" writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
'}',
'',
].join('\n'))
writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
'- insert:',
' - id: startup-fixture',
` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
' inject: [fixtureStartup]',
' config:',
// The flag the startup row resolved wins over the value written beside it.
" generation: !!js ctx.get('fixtureStartup')?.generation ?? 'bundle-default'",
' - id: fixture-startup',
` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
' inject: [cmdlineArgs]',
' - id: reload-witness',
` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
'',
].join('\n'))
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
name: 'dsh-startup-bundle',
version: '0.0.0',
type: 'module',
dsh: { bundle: { patch: './cordis.patch.yml' } },
}, undefined, 2))
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
name: 'dsh-profile-startup',
private: true,
dependencies: {},
dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
}, undefined, 2))
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
return { home, ready: join(home, 'ready'), echo: join(home, 'config-echo'), witness: join(home, 'witness') }
}
function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
cwd: fixture.home,
input: '',
reject: false,
timeout: 25_000,
killSignal: 'SIGKILL',
env: { DSH_HOME: fixture.home, RAW_READY_FILE: fixture.ready },
})
}
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
it('requires --profile and rejects inputs outside the current grammar', async () => {
it('requires --profile and rejects removed commands', async () => {
const bare = await runBuiltBin()
expect(bare.code).toBe(1)
expect(bare.stdout).toBe('')
@@ -202,46 +299,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
const help = await runBuiltBin(['--help'])
expect(help.code).toBe(0)
expect(help.stdout).toContain('dsh --profile web')
expect(help.stdout).toContain('dsh run "run the tests"')
expect(help.stdout).toContain('dsh plugin --profile')
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
const result = await runBuiltBin(outsideGrammar)
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
const result = await runBuiltBin(removed)
expect(result.code).toBe(1)
}
}, 30_000)
it('prints run help without initializing the selected profile', async () => {
const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-'))
const home = join(parent, 'not-created')
it('routes help and usage errors without activating startup-dependent rows', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
try {
const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home })
expect(result.code).toBe(0)
expect(result.stderr).toBe('')
expect(result.stdout).toContain('Usage: dsh run [options] <task...>')
expect(existsSync(home)).toBe(false)
} finally {
rmSync(parent, { recursive: true, force: true })
}
})
const web = await runBuiltBin(['--profile', 'web', '--help'], {
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
})
expect(web.code).toBe(0)
expect(web.stderr).toBe('')
expect(web.stdout).toContain('Usage: dsh --profile web')
expect(web.stdout).toContain('--port <port>')
expect(web.stdout).not.toContain('dsh web: http://')
it('runs the default headless profile through the published run command', async () => {
const apiKey = 'built-dsh-run-key'
const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
})
expect(headlessHelp.code).toBe(0)
expect(headlessHelp.stderr).toBe('')
expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
const missingTask = await runBuiltBin(['--profile', 'headless'], {
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
})
expect(missingTask.code).toBe(1)
expect(missingTask.stderr).toContain('a task is required')
} finally {
rmSync(home, { recursive: true, force: true })
}
}, 30_000)
it('runs the headless profile through its app-owned task positional', async () => {
const apiKey = 'built-dsh-headless-key'
const server = await startMockLlmServer({
sequence: ['success'],
apiKey,
successText: 'published dsh run reached the mock',
successText: 'published headless profile reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-'))
const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
try {
const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], {
const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: apiKey,
DEEPSEEK_BASE_URL: server.baseURL,
})
expect(result.code, result.stderr).toBe(0)
expect(result.stdout).toBe('published dsh run reached the mock')
expect(result.stdout).toBe('published headless profile reached the mock')
expect(result.stderr).toBe('')
expect(server.requests.length).toBeGreaterThan(0)
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
@@ -317,9 +431,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}, 30_000)
it('reports a patch-overlay boot failure without hanging', async () => {
// An HMR main-watcher initial scan that refreshes the include
// mid-initial-apply deadlocks the failing apply's rollback against the
// refresh drain: dsh exits 13 with no diagnostic instead of settling
// The HMR main watcher's initial scan once refreshed the include
// mid-initial-apply, deadlocking the failing apply's rollback against the
// refresh drain: dsh exited 13 with no diagnostic instead of settling
// ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
try {
@@ -336,6 +450,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('rejects arguments when no active row injects the profile command line', async () => {
const fixture = createProfileLifecycleFixture()
try {
const result = await runBuiltBin(['--profile', 'lifecycle', '--help'], { DSH_HOME: fixture.home })
expect(result.code).toBe(1)
expect(result.stderr).toContain('takes no app arguments because no active row injects cmdlineArgs')
expect(existsSync(fixture.ready)).toBe(false)
} finally {
rmSync(fixture.home, { recursive: true, force: true })
}
}, 30_000)
it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
const fixture = createProfileLifecycleFixture()
const child = startProfileLifecycle(fixture)
@@ -404,6 +530,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('hands the app arguments to the profile, which applies them before its rows start', async () => {
const fixture = createStartupFixture()
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
try {
await waitForFile(fixture.ready)
// The waiting row started once, already carrying the flag value: the
// launcher never saw --generation, and the app resolved it first.
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
child.kill('SIGTERM')
expect((await child).exitCode).toBe(0)
} finally {
child.kill('SIGKILL')
rmSync(fixture.home, { recursive: true, force: true })
}
}, 30_000)
it('starts a waiting row on its composed value when the invocation carries no app arguments', async () => {
const fixture = createStartupFixture()
const child = startStartupProfile(fixture, [])
try {
await waitForFile(fixture.ready)
expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
child.kill('SIGTERM')
expect((await child).exitCode).toBe(0)
} finally {
child.kill('SIGKILL')
rmSync(fixture.home, { recursive: true, force: true })
}
}, 30_000)
it('keeps the app arguments across a user patch reload', async () => {
// A live edit recomposes every row while the startup service remains
// active, so each config expression reads the same invocation value (a
// served port does not move back to its composed fallback).
const fixture = createStartupFixture()
const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
const child = startStartupProfile(fixture, ['--generation', 'flagged'])
try {
// Both rows: the waiting one carries the flag value, and the witness is
// what a reload will re-mount. They start independently, so neither
// marker implies the other.
await waitForFile(fixture.ready)
await waitForFile(fixture.witness)
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
// An edit to an unrelated row: the witness re-mounts, which is how this
// test knows the whole tree was recomposed.
rmSync(fixture.witness)
writeFileSync(profilePatch, [
'- id: reload-witness',
' config:',
' generation: reloaded',
'',
].join('\n'))
await waitForFile(fixture.witness)
expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
child.kill('SIGTERM')
expect((await child).exitCode).toBe(0)
} finally {
child.kill('SIGKILL')
rmSync(fixture.home, { recursive: true, force: true })
}
}, 30_000)
it("prints the app's own help, starts none of its rows, and exits", async () => {
const fixture = createStartupFixture()
try {
const result = await startStartupProfile(fixture, ['--help'])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('Usage: fixture')
expect(result.stdout).toContain('--generation')
expect(existsSync(fixture.ready)).toBe(false)
} finally {
rmSync(fixture.home, { recursive: true, force: true })
}
}, 30_000)
it('anchors a relative add spec to the invoking directory, not the profile', async () => {
// `dsh plugin --profile x add .` from a plugin checkout must install THAT
// checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
@@ -490,20 +693,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
}, 30_000)
it('prints a headless profile with no Host, HTTP, or browser rows', async () => {
const { stdout, code, stderr } = await runBuiltBin(
['--profile', 'headless', '--dump-default-config'],
{ DSH_HOME: home },
)
expect(code).toBe(0)
expect(stderr).toBe('')
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'")
expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-")
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
}, 30_000)
it('composes the profile user layer and a --patch overlay in order', async () => {
// Auto-init the web profile first, then write its user layer.
const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })

View File

@@ -1,45 +0,0 @@
/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */
import { describe, expect, it, vi } from 'vitest'
import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts'
vi.mock('node:os', () => ({
networkInterfaces: () => ({
lo0: [
{ family: 'IPv4', internal: true, address: '127.0.0.1' },
],
en0: [
{ family: 'IPv6', internal: false, address: 'fe80::1' },
{ family: 'IPv4', internal: false, address: '192.168.1.5' },
],
en1: [
{ family: 'IPv4', internal: false, address: '10.0.0.7' },
],
utun0: undefined,
}),
}))
describe('resolveLanTrust', () => {
it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => {
const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080'])
expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7'])
expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080'])
})
it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => {
expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] })
expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] })
})
})
describe('webSurfaceContextEnabled', () => {
it('defaults to enabled and honors an explicit complete-prompt disable', () => {
expect(webSurfaceContextEnabled(new Map())).toBe(true)
expect(webSurfaceContextEnabled(new Map([
['web-runtime', { config: { mode: 'production' } }],
]))).toBe(true)
expect(webSurfaceContextEnabled(new Map([
['web-runtime', { config: { surfaceContext: false } }],
]))).toBe(false)
})
})