dsh --profile <name> replaces the fixed entry modes: --config and -p are removed, --patch adds overlays over the composed profile, a positional task selects one-shot mode (requires the headless-runner row), and dsh web stays as the alias for --profile web carrying the Web flag family as patches. dsh plugin --profile <name> forwards verbatim to pnpm in the profile directory, initializes on first use, and reconciles the dsh.plugins layer list after add/remove (patch-less packages warn and stay plain dependencies). Config dumps and the keyless web e2e scaffold compose the same bundle layers over the same empty root as the boot.
206 lines
9.0 KiB
TypeScript
206 lines
9.0 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')
|
|
}
|
|
resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches }
|
|
return
|
|
}
|
|
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
|
|
}
|