/** * @deepseek-ai/dsh-cmdline — the command line a dsh launcher hands to the app * it boots. * * The launcher parses only its own flags (`--profile`, `--patch`, the config * dumps) and hands everything after them to the tree verbatim through the * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` * text, and its parse errors instead of the launcher knowing them. * * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A * provider may publish the parsed values as its own service from its program's * commander action, and ordinary rows * can inject that service and read it from lazily resolved config — * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written * beside it. No row has launcher-level command-line status. * @module @deepseek-ai/dsh-cmdline */ import type { Command } from 'commander' import type { Context } from '@deepseek-ai/cordis' /** * The invocation's inner arguments: everything after the launcher's own flags, * verbatim and in argv order. `dsh --profile tui --resume abc` yields * `['--resume', 'abc']`. */ export interface CmdlineArgs { /** * Read the inner arguments. * @returns the arguments in argv order; empty when the invocation carried none. */ get(): readonly string[] } /** Request bounded process exit; the launcher wires it to its shutdown controller. */ export interface AppExit { /** * Request exit once the tree has been disposed. * @param code - the process exit code. */ (code: number): void } declare module '@deepseek-ai/cordis' { interface Context { /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ cmdlineArgs?: CmdlineArgs /** Bounded process-exit request; provided by a launcher before the tree mounts. */ appExit?: AppExit } } /** The launcher facts an app needs. */ export interface CmdlineHost { /** The invocation's inner arguments, in argv order. */ args: readonly string[] /** Bounded process-exit request. */ exit: AppExit } /** * Provide the command line and the exit request on a host context before any * tree entry mounts. Both are launcher facts, not config: an embedding host * with no command line provides an empty argument list. * @param ctx - the host context the tree will mount under. * @param host - the invocation's arguments and its exit request. */ export function provideCmdline(ctx: Context, host: CmdlineHost): void { const snapshot: readonly string[] = Object.freeze([...host.args]) ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) } /** The process streams commander output is written to; production writes to the process. */ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { stdout: process.stdout, stderr: process.stderr, } /** * Parse the launcher's immutable argument snapshot with an app's commander * program. Commander runs the program's own synchronous action handler on a * successful parse; app code there publishes its service and rejects an * invalid invocation with `program.error(...)`. This helper has no Loader-row * or service ownership semantics. * * Help, version, and rejected arguments — from the grammar or from an action * — are terminal for the process: commander writes the text and the helper * requests `ctx.appExit`. The action never runs on help, version, or a * grammar rejection; an action must reject before it publishes, because * statements before its `program.error(...)` have already run. * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. * @param program - the app's commander program, with its flags, description, * actions, and any subcommands already declared. * @throws when the launcher did not provide the command line and exit request, * or when no command in the program declares an action. */ export function parseCmdline(ctx: Context, program: Command): void { // Read through the global service store, not the property proxy: appExit is // an optional host value and the plugin only needs to inject cmdlineArgs. const args = ctx.get('cmdlineArgs') const exit = ctx.get('appExit') if (args === undefined || exit === undefined) { throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) } if (!hasAction(program)) { throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`) } configureExitAndOutput(program) try { program.parse(args.get(), { from: 'user' }) } catch (error) { // exitOverride turns help, version, a parse error, and the action's own // program.error() into a CommanderError; commander has already written the // text through the output configured above. if (!isCommanderError(error)) throw error exit(error.exitCode) } } /** * Whether any command in the tree declares an action handler. * * The `Command` type cannot express the action precondition, so the handler is * read structurally (as {@link isCommanderError} reads commander's control-flow * errors): without this guard, a program that forgot its action would parse * successfully, publish nothing, and surface only as dependent rows pending on * the absent service. * @param command - the command whose tree is inspected. * @returns true when the command or any registered subcommand has an action. */ function hasAction(command: Command): boolean { if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true return command.commands.some(hasAction) } /** * Route every command's exit and output through the launcher adapter. * * Commander copies `exitOverride` and output configuration into a subcommand * only at registration, so a root-only override would let an * already-registered subcommand's rejection write to the process streams and * call `process.exit` directly, bypassing `ctx.appExit`. * @param command - the root of the command tree to configure. */ function configureExitAndOutput(command: Command): void { command .exitOverride() .configureOutput({ writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) for (const child of command.commands) configureExitAndOutput(child) } /** * Whether a thrown value is commander's own control-flow error (help, version, * a parse error, or `program.error`). * * Detected structurally, not with `instanceof`: an out-of-tree plugin brings * its own commander copy, whose `CommanderError` class is a different identity * from this package's, and an identity check there would rethrow a printed * help as a fatal load failure. * @param error - the thrown value. * @returns true when the value carries commander's error code and exit code. */ function isCommanderError(error: unknown): error is { code: string; exitCode: number } { if (typeof error !== 'object' || error === null) return false const candidate = error as { code?: unknown; exitCode?: unknown } return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') && typeof candidate.exitCode === 'number' }