parseCmdline(ctx, program): void only adapts commander control flow to
the launcher: it parses the immutable cmdlineArgs snapshot and turns
help, version, parse errors, and action rejections into a ctx.appExit
request. App validation and the ctx.provide of the app-owned service
live in the program's own synchronous .action(), which commander runs
inside parse — program.error(...) there shares the exit path with a
grammar rejection. Deletes the CmdlinePlan export, its unread ctx
parameter, the type-unsound (() => ({}) as T) default, and the
T | undefined return with its per-caller publish guard.
173 lines
7.2 KiB
TypeScript
173 lines
7.2 KiB
TypeScript
/**
|
|
* @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'
|
|
}
|