- Clone patch lists per generation (boot + composeLive): the include pushes insert rows by reference and mutates them in place, so a reused object baked user overrides into bundle rows and removal could not revert; the built-bin hot-reload e2e now asserts an override AND its removal reverting. - The headless runner awaits Loader settlement before prompting (its inject gate covers only apiProxy/httpServer) and abandons cleanly when the tree died during the wait. - healProfilesModuleFallback walks the app's full dependency+peer closure: out-of-tree plugins import seam packages (dsh-compact, dsh-subprocess, ...) that only implementations reach, and peers are how seams are declared. - Profile init writes pnpm-workspace.yaml (nodeLinker: hoisted), not .npmrc — pnpm >=10 reads settings from the workspace manifest. - Web dumps reject boot-only flags instead of printing a tree that differs from the same invocation's boot; --port validates at the flag; --dump-default-config no longer parses the (possibly broken) user layer; trustedHosts flag derivation merges over the composed value instead of replacing it; web-runtime gains surfaceContext (headless disables the GUI prompt/bash-vars the old -p never mounted); 'node_modules' is a reserved profile name; plugin-warning names the recovery step; client AGENTS.md registration surfaces point at the web-app bundle. - Ship session-reference/tmux-context/tool-ask-user as app dependencies for terminal front-door patch layers (turtle-ui), same stance as mcp-client.
216 lines
9.7 KiB
TypeScript
216 lines
9.7 KiB
TypeScript
/**
|
|
* Commander adapter for the `dsh` command-line entry. The default command
|
|
* boots a named profile (`--profile <name>`), optionally with extra `--patch`
|
|
* overlays and a positional task (one-shot mode for profiles mounting the
|
|
* headless runner). `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.
|
|
* @module @deepseek-ai/dsh/args
|
|
*/
|
|
|
|
import { Command, CommanderError } from 'commander'
|
|
|
|
/** Boot a named profile. */
|
|
interface ProfileInvocation {
|
|
mode: 'profile'
|
|
profile: string
|
|
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
|
patches: string[]
|
|
/** Positional task text joined by spaces; non-empty only for one-shot runs. */
|
|
task?: string
|
|
}
|
|
|
|
/** Print a composed profile tree and exit without booting. */
|
|
interface DumpConfigInvocation {
|
|
mode: 'dump-config'
|
|
profile: string
|
|
/** Omit the profile's user layer and --patch overlays; print bundle layers only. */
|
|
defaultOnly: boolean
|
|
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
|
|
workspaceRoot?: string
|
|
/** 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'
|
|
profile: string
|
|
/** Raw pnpm arguments, verbatim. */
|
|
args: string[]
|
|
}
|
|
|
|
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
|
|
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation
|
|
|
|
/** Raw web-subcommand options straight from Commander. */
|
|
interface WebOptions {
|
|
patch?: string[]
|
|
host?: string
|
|
port?: string
|
|
dev?: boolean
|
|
workspaceRoot?: string
|
|
trustedHost?: string[]
|
|
dumpConfig?: boolean
|
|
dumpDefaultConfig?: boolean
|
|
}
|
|
|
|
/**
|
|
* Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never
|
|
* variadic — a variadic `--patch` would swallow a following positional task.
|
|
*/
|
|
const collect = (value: string, previous: string[] = []): string[] => [...previous, value]
|
|
|
|
/**
|
|
* Resolve argv into one invocation, or print and exit for help, version, or an
|
|
* error.
|
|
* @param argv - arguments after the Node binary and script.
|
|
* @param version - version string printed by `--version`.
|
|
* @returns the resolved invocation.
|
|
*/
|
|
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
|
let resolved: DshInvocation | undefined
|
|
const program = new Command()
|
|
.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 --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 plugin --profile tui add <package> install a plugin into the tui profile
|
|
dsh web --port 8080 the web alias with its flag family
|
|
`)
|
|
.exitOverride()
|
|
.enablePositionalOptions()
|
|
.argument('[task...]', 'one-shot task text for profiles mounting the headless runner')
|
|
.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((task: string[], 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')
|
|
}
|
|
if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task')
|
|
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
|
|
}
|
|
resolved = {
|
|
mode: 'profile',
|
|
profile,
|
|
patches,
|
|
...task.length > 0 ? { task: task.join(' ') } : {},
|
|
}
|
|
})
|
|
|
|
/** Reject parent options that crossed a subcommand boundary. */
|
|
const rejectParentOptions = (command: string): void => {
|
|
const parent = program.opts<{
|
|
profile?: string
|
|
patch?: string[]
|
|
dumpConfig?: boolean
|
|
dumpDefaultConfig?: boolean
|
|
}>()
|
|
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 web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port')
|
|
web
|
|
.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('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
|
|
.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) => {
|
|
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.workspaceRoot !== undefined || options.trustedHost !== undefined) {
|
|
program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--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.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
|
|
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
|
|
}
|
|
})
|
|
|
|
const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory')
|
|
plugin
|
|
.requiredOption('--profile <name>', 'the profile whose plugins to manage (initialized on first use)')
|
|
.allowUnknownOption()
|
|
.argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
|
|
.action((args: string[], options: { profile: string }) => {
|
|
rejectParentOptions('plugin')
|
|
if (options.profile === '') program.error('error: --profile needs a name')
|
|
if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
|
|
resolved = { mode: 'plugin', profile: options.profile, args }
|
|
})
|
|
|
|
try {
|
|
program.parse(argv, { from: 'user' })
|
|
} catch (error) {
|
|
return process.exit(error instanceof CommanderError ? error.exitCode : 1)
|
|
}
|
|
/* v8 ignore next -- an action resolves or Commander throws */
|
|
if (resolved === undefined) throw new Error('dsh: no invocation resolved')
|
|
return resolved
|
|
}
|