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

@@ -1,31 +1,30 @@
/**
* Commander adapter for the `dsh` command-line entry. The default command
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
* overlays. `run` owns one-shot task execution, defaulting to the headless
* profile; `web` is a hardcoded alias for `--profile web` that adds the Web
* flag family; `plugin` manages a profile's plugin dependencies by forwarding
* to pnpm. Commander owns help, version, and parse errors.
* Commander adapter for the `dsh` command line.
*
* The launcher parses only what it owns — which profile to boot, which extra
* patch overlays to apply, and the config dumps — and hands **everything after
* its own flags** to the booted tree verbatim, where the booted app's startup row
* parses its own flag family and prints its own `--help` (see
* `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first
* token this parser does not recognize starts the inner arguments, so
* `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
* and `dsh --profile web -h` prints the web app's help, not this one's.
*
* `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
* plugin dependencies by forwarding to pnpm.
* @module @deepseek-ai/dsh/args
*/
import { Command, CommanderError } from 'commander'
/** Boot a named profile. */
/** Boot a named profile and hand it the invocation's inner arguments. */
interface ProfileInvocation {
mode: 'profile'
profile: string
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
patches: string[]
}
/** Run one task through a profile mounting the headless runner. */
interface RunInvocation {
mode: 'run'
profile: string
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
patches: string[]
/** Non-blank task text joined from the variadic positional arguments. */
task: string
/** Everything after the launcher's own flags, verbatim, for the booted app's startup row. */
args: string[]
}
/** Print a composed profile tree and exit without booting. */
@@ -37,21 +36,6 @@ interface DumpConfigInvocation {
patches: string[]
}
/**
* Browser UI: `dsh web` (alias of `--profile web`). Host and port remain
* unvalidated pass-throughs to the webserver schema; absent values leave the
* shipped web bundle values intact.
*/
interface WebInvocation {
mode: 'web'
patches: string[]
host?: string
port?: number
dev: boolean
/** Extra authorities for the /api browser-trust fence. */
trustedHosts?: string[]
}
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
interface PluginInvocation {
mode: 'plugin'
@@ -61,31 +45,63 @@ interface PluginInvocation {
}
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
/** Launcher flags shared by the default command and the `web` alias. */
interface BootOptions {
patch?: string[]
host?: string
port?: string
dev?: boolean
trustedHost?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}
/** Raw run-subcommand options straight from Commander. */
interface RunOptions {
profile: string
patch?: string[]
}
/**
* Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
* variadic — a variadic `--patch` would swallow a following positional task.
* variadic — a variadic `--patch` would swallow the inner arguments.
*/
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
/** The launcher's own help text; each app prints its own. */
const HELP_EXAMPLES = `
Examples:
dsh --profile web boot the web profile (same as: dsh web)
dsh --profile headless "run the tests" answer one task, print the result, and exit
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
dsh --profile tui --resume <session> arguments after the launcher flags reach the app
dsh --profile web --help the web app's own flags and help
dsh plugin --profile tui add <package> install a plugin into the tui profile
`
/**
* Resolve a boot or dump invocation from the launcher flags and the leftover
* inner arguments.
* @param program - the command whose options were parsed (the root, or the `web` alias).
* @param profile - the profile these flags boot.
* @param options - the launcher flags commander collected.
* @param args - the leftover arguments, in argv order.
* @returns the resolved invocation.
*/
function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
const patches = options.patch ?? []
if (patches.includes('')) program.error('error: --patch needs a path')
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
return { mode: 'profile', profile, patches, args }
}
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
}
// The dump is boot-free: it never runs the app's startup row, so it cannot
// show what that app's flags would decide, and printing a tree that differs
// from the same invocation's boot would mislead.
if (args.length > 0) {
program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`)
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && patches.length > 0) {
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
}
return { mode: 'dump-config', profile, defaultOnly, patches }
}
/**
* Resolve argv into one invocation, or print and exit for help, version, or an
* error.
@@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
let resolved: DshInvocation | undefined
const program = new Command()
// Annotated, not inferred: the actions below call back into `program`, and an
// inferred type would be circular through its own chain.
const program: Command = new Command()
program
.name('dsh')
.version(version, '-V, --version', 'output the version number')
.description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.')
.addHelpText('after', `
Examples:
dsh --profile web boot the web profile (same as: dsh web)
dsh run "run the tests" answer one task, print the result, and exit
dsh run --profile custom "run the tests" run one task through a custom one-shot profile
dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay
dsh plugin --profile tui add <package> install a plugin into the tui profile
dsh web --port 8080 the web alias with its flag family
`)
.addHelpText('after', HELP_EXAMPLES)
.exitOverride()
// The launcher's flags come first and end at the first token it does not
// know; everything from there on belongs to the booted app, including
// its -h. `dsh -h` with no profile still prints this help, below.
.helpOption(false)
.allowUnknownOption()
.passThroughOptions()
.enablePositionalOptions()
.argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
.option('--dump-config', 'print the composed profile tree and exit')
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
.action((options: {
profile?: string
patch?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}) => {
const profile = options.profile ?? program.error('error: --profile <name> is required')
if (profile === '') program.error('error: --profile needs a name')
const patches = options.patch ?? []
if (patches.includes('')) program.error('error: --patch needs a path')
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && patches.length > 0) {
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
}
resolved = { mode: 'dump-config', profile, defaultOnly, patches }
return
.action((args: string[], options: BootOptions & { profile?: string }) => {
// With the app owning -h, the launcher's own help is what a bare
// `dsh -h` (no profile to hand it to) must print.
if (options.profile === undefined) {
if (args.some(argument => argument === '-h' || argument === '--help')) program.help()
program.error('error: --profile <name> is required')
}
resolved = { mode: 'profile', profile, patches }
const profile = options.profile
if (profile === '') program.error('error: --profile needs a name')
resolved = resolveBoot(program, profile, options, args)
})
/** Reject parent options supplied before a subcommand. */
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{
profile?: string
patch?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}>()
const parent = program.opts<BootOptions & { profile?: string }>()
if (parent.profile !== undefined || parent.patch !== undefined
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
}
}
const run = program.command('run').description('run one task through a profile mounting the headless runner')
run
.option('--profile <name>', 'one-shot profile under $DSH_HOME/profiles', 'headless')
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
.argument('<task...>', 'task text')
.action((task: string[], options: RunOptions) => {
rejectParentOptions('run')
const profile = options.profile
if (profile === '') program.error('error: --profile needs a name')
const patches = options.patch ?? []
if (patches.includes('')) program.error('error: --patch needs a path')
const joined = task.join(' ')
if (joined.trim() === '') program.error('error: run needs a non-blank task')
resolved = { mode: 'run', profile, patches, task: joined }
})
const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow')
web
.helpOption(false)
.allowUnknownOption()
.passThroughOptions()
.enablePositionalOptions()
.argument('[args...]', 'arguments for the web app (see: dsh web --help)')
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit')
.option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit')
.action((options: WebOptions) => {
.action((args: string[], options: BootOptions) => {
rejectParentOptions('web')
const patches = options.patch ?? []
if (patches.includes('')) program.error('error: --patch needs a path')
if (options.dumpConfig === true || options.dumpDefaultConfig === true) {
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && patches.length > 0) {
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
}
// The dump is boot-free and does not derive flag patches; silently
// dropping them would print a tree that differs from the same
// invocation's boot.
if (options.host !== undefined || options.port !== undefined || options.dev === true
|| options.trustedHost !== undefined) {
program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)')
}
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
return
}
if (options.port !== undefined && !/^\d+$/.test(options.port)) {
program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`)
}
resolved = {
mode: 'web',
patches,
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
}
resolved = resolveBoot(web, 'web', options, args)
})
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')

View File

@@ -10,7 +10,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
@@ -24,33 +24,19 @@ function readVersion(): string {
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
loadEnv('dsh')
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'profile': {
const { runProfile } = await import('./profile-boot.ts')
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
patchFiles: invocation.patches,
args: invocation.args,
})
break
}
case 'run': {
const { runProfile } = await import('./profile-boot.ts')
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
patchFiles: invocation.patches,
task: invocation.task,
})
break
}
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation, loadLayeredEnv('dsh'))
break
}
case 'plugin': {
const { runPlugin } = await import('./plugin.ts')
process.exit(runPlugin(invocation.profile, invocation.args))

View File

@@ -1,9 +1,13 @@
/**
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own
* `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
* switch), mount the tree over the profile's empty root config, keep the
* profile patch layer live, and wire fail-loud plus bounded shutdown.
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
* tree over the profile's empty root config, keep the profile patch layer
* live, and wire fail-loud plus bounded shutdown.
*
* App flags are not the launcher's business: the invocation's inner arguments
* are provided to the tree through `ctx.cmdlineArgs`, and the booted app's
* startup row parses them and configures its own rows.
* @module @deepseek-ai/dsh/profile-boot
*/
@@ -12,7 +16,7 @@ import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { FiberState, type Context } from '@deepseek-ai/cordis'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import {
boot,
composeEntries,
@@ -25,7 +29,7 @@ import {
watchUserPatches,
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
@@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im
/** Harness-home directory holding locally authored agent presets. */
const USER_PRESET_DIR = '.agent-presets'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { hasCmdlineConsumer, provideCmdline } from '@deepseek-ai/dsh-cmdline'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
@@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The one-shot runner row a `dsh run` task requires and configures. */
/** The one-shot runner row: its presence means this composition exits by itself. */
const HEADLESS_ROW_ID = 'headless-runner'
/** The empty root entry list every profile tree patches over. */
@@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile {
return profile
}
/** Read-only row index of a profile composition before launcher flag patches. */
export type ProfileRows = ReadonlyMap<string, { name?: string; config?: unknown }>
/** One profile's patch layers (application order) and the row index of its pre-flag composition. */
interface ComposedProfile {
profile: Profile
@@ -116,14 +118,13 @@ interface ComposedProfile {
windowsShellPatches: PatchOptions[]
/** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
homePatches: PatchOptions[]
/** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */
overlayAndFlags: PatchOptions[]
/** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
overlays: PatchOptions[]
/**
* id → row of the pre-flag composition (bundles + user layers + overlays),
* for flag merges and row checks. Flag patches must not insert rows the
* launcher consults here (they only override values and insert dev glue).
* id → row of the composed tree (bundles + user layers + overlays), for the
* launcher's own row checks.
*/
rows: ProfileRows
rows: ReadonlyMap<string, EntryOptions>
}
/** The full patch stack of one composed profile, in application order. */
@@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
...composed.windowsShellPatches,
...composed.profile.patches,
...composed.homePatches,
...composed.overlayAndFlags,
...composed.overlays,
]
}
@@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
* is Windows), the profile's user layer, the home-level user layer
* (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to
* every profile, so it outranks the per-profile layer), `--patch` overlays,
* then flag patches derived from the composed rows, then the telemetry
* switch.
* then the telemetry switch.
* @param name - the profile name.
* @param patchFiles - `--patch` overlay paths, in argv order.
* @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
* @returns the profile, its patch layers, and the composed row index.
*/
function composeProfile(
name: string,
patchFiles: readonly string[],
deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [],
): ComposedProfile {
const profile = prepareProfile(name)
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? []
const rows = new Map<string, { name?: string; config?: unknown }>()
const rows = new Map<string, EntryOptions>()
for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
// The agent-preset roots are an assembly fact of every dsh launcher, not a
// patch author's choice: the shipped set sits beside this app's config and
// the user's own under the Harness home. Resolved per boot ($DSH_HOME may
// differ per run) and only patched when the composed tree actually mounts
// the roster — a one-shot `dsh run` composes agents from the same roster
// `dsh web` offers.
const composedOverlays = [...overlays]
// Preset roots belong to every dsh composition that mounts the roster.
if (rows.has('agent-presets')) {
overlayAndFlags.push({
composedOverlays.push({
id: 'agent-presets',
config: {
...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
@@ -184,58 +177,55 @@ function composeProfile(
})
}
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows }
if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows }
}
/** Options for {@link runProfile}. */
export interface RunProfileOptions {
/** This run's frozen environment snapshot, provided before any entry mounts. */
environment: EnvironmentSnapshot
/** The profile name to boot. */
profile: string
/** `--patch` overlay paths, in argv order. */
patchFiles: readonly string[]
/** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */
deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[]
/** `dsh run` task text; requires the composition to mount the headless runner row. */
task?: string
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
/** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
environment: EnvironmentSnapshot
}
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
if (!signal.aborted) throw error
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
args: readonly string[]
/** Host setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context) => Promise<void> | void
}
/**
* Boot one profile invocation end to end and leave process lifetime to the
* mounted plugins (or to the one-shot runner when `task` is present).
* @param options - profile name, overlays, flag patches, and the optional task.
* mounted plugins (or to a one-shot runner the composition mounts).
* @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
* @returns the settled root context and the shutdown controller.
*/
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches)
if (options.task !== undefined) {
if (!composed.rows.has(HEADLESS_ROW_ID)) {
throw new Error(
`dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row `
+ '(the headless profile does)',
)
}
composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } })
} else if (composed.rows.has(HEADLESS_ROW_ID)) {
// The inverse misuse: a one-shot composition booted without its task
// would otherwise die in the runner row's schema with a raw "required"
// error naming no fix.
const composed = composeProfile(options.profile, options.patchFiles)
if (!hasCmdlineConsumer([...composed.rows.values()]) && options.args.length > 0) {
throw new Error(
`dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: `
+ `dsh run --profile ${options.profile} "<task>"`,
`${NAME}: profile ${JSON.stringify(options.profile)} takes no app arguments because no active row injects cmdlineArgs; `
+ `got ${options.args.map(argument => JSON.stringify(argument)).join(' ')}`,
)
}
// A one-shot composition ends by itself, which changes what a signal means
// and makes watching the user's patch layer pointless.
const headlessRow = composed.rows.get(HEADLESS_ROW_ID)
const oneShot = headlessRow !== undefined && headlessRow.disabled !== true
const app: { current?: Context } = {}
// Readiness for rows that publish it (the web URL line): a row can activate
// before concurrently mounted siblings finish or fail.
let bootSettled: () => void = () => {}
let bootFailed: (reason: unknown) => void = () => {}
const ready = new Promise<void>((resolve, reject) => {
bootSettled = resolve
bootFailed = reject
})
// Nothing awaits `ready` on a composition that publishes no readiness, and
// an unobserved rejection must not take the process down on its own.
ready.catch(() => {})
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
const signalShutdown = new AbortController()
const interrupt = (code: number): void => {
@@ -243,9 +233,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
shutdown.interrupt(code)
}
// Signals own teardown throughout the startup window, not only after boot()
// settles: an inserted entry point can publish readiness before sibling rows
// settles: an inserted startup row can publish readiness before sibling rows
// finish mounting.
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) })
process.on('SIGINT', () => { interrupt(130) })
installFailLoud(NAME, process, async () => {
await app.current?.fiber.dispose()
@@ -253,7 +243,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
// Recomposition for the live user layers: bundle layers below, overlays
// and flag patches above, so a user edit can never displace them. BOTH
// above, so a user edit can never displace them. What an app's startup row
// resolved is not in here at all — it lives in that row's own service, which
// survives a recomposition. BOTH
// user files are re-read per generation (the HMR watcher hands us only the
// changed file's patches, which one of the reads duplicates — fresh reads
// keep the two watchers from stitching in each other's stale copy).
@@ -267,19 +259,27 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
...composed.windowsShellPatches,
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
...composed.overlayAndFlags,
...composed.overlays,
])
// One-shot runs exit through the runner; watching would only hold the
// process open after its exit request.
const watchProfilePatch = options.task === undefined
const watchProfilePatch = !oneShot
// Cloned for the same insert-aliasing reason as composeLive: the boot
// application must not mutate the objects later reloads recompose from.
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
app.current = hostCtx
// Before any config-tree entry mounts, so a plugin that resolves a
// user-facing value at construction already sees this run's layers.
// Before any config-tree entry mounts, so plugins resolve all launch-time
// environment values from the same immutable provenance snapshot.
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
if (options.task !== undefined) {
// The command line is a launcher fact every app reads the same way: its
// own arguments, and the bounded exit its startup row requests after
// printing help or rejecting them.
provideCmdline(hostCtx, {
args: options.args,
exit: code => void shutdown.shutdown(code),
ready,
})
if (oneShot) {
const io: HeadlessIo = {
stdout: process.stdout,
stderr: process.stderr,
@@ -287,9 +287,13 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
}
hostCtx.provide('headlessIo', io)
}
await options.prepare?.(hostCtx, composed.rows)
await options.prepare?.(hostCtx)
}).catch((cause: unknown) => {
bootFailed(cause)
throw cause
})
app.current = ctx
bootSettled()
// A surface can dispose the whole tree while startup or this post-boot
// watcher setup is still in flight. Loader presence and fiber state own
// liveness; the local signal fact distinguishes that expected exit race
@@ -323,7 +327,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
compose: composeLive,
})
} catch (error) {
suppressSignalShutdownError(signalShutdown.signal, error)
if (!signalShutdown.signal.aborted) throw error
}
}
return { ctx, shutdown }

View File

@@ -1,144 +0,0 @@
/**
* `dsh web` — the browser-surface alias over the profile boot: `--profile web`
* plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag
* becoming a patch over the composed profile
* tree. All web runtime glue (dist serving, prompt section, URL line) lives
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
* flag patches and the LAN-trust snapshot.
* @module @deepseek-ai/dsh/web
*/
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { runProfile, type ProfileRows } from './profile-boot.ts'
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Non-internal IPv4 interface addresses of this machine — the IP-literal
* authorities an all-interfaces bind is reachable by on the LAN.
* @returns the addresses in interface order (possibly empty).
*/
function lanIPv4Addresses(): string[] {
return Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
}
/**
* One LAN-trust resolution for one invocation, sampled exactly once: the
* machine's LAN IP literals when the effective bind is all-interfaces, and
* the `trustedHosts` value built from them plus the explicit extras. The
* single sample is deliberate — display must advertise only addresses the
* fence was configured with, so the web-app row receives this same snapshot.
* Derived entries are port-less IP literals: DNS rebinding needs an
* attacker-controlled name, so an IP-literal Host is safe on any port, and
* the bound port may be OS-assigned, unknowable pre-boot.
* @param bindHost - the effective webserver bind host (CLI flag, else the composed row value).
* @param extra - `--trusted-host` values, in argv order.
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
*/
export function resolveLanTrust(
bindHost: string | undefined,
extra: readonly string[],
): { lanAddresses: string[]; trustedHosts: string[] } {
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/** The `dsh web` flag family, already parsed by the argument adapter. */
export interface WebFlags {
patches: string[]
host?: string
port?: number
dev: boolean
trustedHosts?: string[]
}
/**
* Derive the web alias's flag patches over an already-composed profile tree.
* Patches replace a row's whole config, so each patched row's composed values
* are re-read and merged under the overrides.
* @param rows - the composed row index from {@link composeProfile}.
* @param flags - the parsed flag family.
* @returns the flag patch list, in application order.
*/
function deriveWebFlagPatches(
rows: ProfileRows,
flags: WebFlags,
): PatchOptions[] {
const overrides = new Map<string, Record<string, unknown>>()
const put = (entryId: string, key: string, value: unknown): void => {
const bag = overrides.get(entryId) ?? {}
bag[key] = value
overrides.set(entryId, bag)
}
if (flags.host !== undefined) put('webserver', 'host', flags.host)
if (flags.port !== undefined) put('webserver', 'port', flags.port)
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
if (trustedHosts.length > 0) {
// Additive over the composed value: a cordis.patch.yml-configured fence
// authority must survive the derived LAN literals and flag extras — a
// silent drop of security-relevant fence configuration.
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
}
// mode and lanAddresses are launcher-derived on every boot (--dev also
// inserts the client-hmr row), never pass-throughs of composed values.
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
put('web-runtime', 'lanAddresses', lanAddresses)
// The agent-preset roots are patched by the shared profile boot: they are
// an assembly fact of every dsh launcher, and `dsh run` composes agents
// from the same roster this alias offers.
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
const composed = rows.get(id)
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } }
})
if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] })
return patches
}
/**
* Whether the composed Web runtime keeps its model- and shell-visible surface
* context. The bundle schema defaults the field to true, so only an explicit
* false suppresses both the bundle contributions and the launcher-owned
* source-checkout section.
* @param rows - the composed Web profile rows before launcher flag patches.
* @returns true unless the web-runtime row explicitly disables surface context.
*/
export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false
}
/**
* Serve the browser UI from the web profile. Host/port flags are passed
* through only when given (absent, the composed profile values
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
* every boot. The URL line is printed by the web-app bundle's runtime row
* after Loader settlement.
* @param flags - the parsed `dsh web` flag family.
* @param environment - this run's frozen environment snapshot.
*/
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
await runProfile({
environment,
profile: 'web',
patchFiles: flags.patches,
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
prepare: (ctx: Context, rows: ProfileRows) => {
if (!webSurfaceContextEnabled(rows)) return
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
})
},
})
}