Files
deepseek-harness/packages/boot/cmdline/src/index.ts

175 lines
7.3 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, 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'
// Empty type import carries the Loader Context merge used by enableRow.
import type {} from '@deepseek-ai/cordis-plugin-loader'
/**
* 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,
}
/**
* Resolve parsed arguments into an app-owned value. Call
* `program.error(...)` to reject the invocation with a usage message instead
* of throwing.
* @param program - the parsed commander program.
* @param ctx - the plugin context that received the command line.
* @returns the value an ordinary provider plugin may publish.
*/
export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
/**
* Parse the launcher's immutable argument snapshot with an app's commander
* program. The caller decides whether and how to publish the returned value;
* this helper has no Loader-row or service ownership semantics.
*
* Help, version, and rejected arguments are terminal for the process: commander
* writes the text, the helper requests `ctx.appExit`, and it returns
* `undefined` so the caller publishes nothing.
* @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
* @param program - the app's commander program, with its flags and description already declared.
* @param plan - this invocation's resolved value; omitted returns an empty object.
* @returns the resolved value, or `undefined` when the app asked to exit.
* @throws when the launcher did not provide the command line and exit request.
*/
export function parseCmdline<T>(
ctx: Context,
program: Command,
plan: CmdlinePlan<T> = (() => ({}) as T),
): T | undefined {
// 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`)
}
program
.exitOverride()
.configureOutput({
writeOut: text => void internals.stdout.write(text),
writeErr: text => void internals.stderr.write(text),
})
try {
program.parse(args.get(), { from: 'user' })
return plan(program, ctx)
} catch (error) {
// exitOverride turns help, version, a parse error, and a plan'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)
return undefined
}
}
/**
* Turn on a row this composition ships disabled, because this invocation asked
* for it (`dsh web --dev` and its client-plugin reload chain).
*
* A row cannot be inserted from inside a mounting plugin — the Loader returns a
* prefixed id it then fails to resolve — so a conditional row ships disabled
* and a row mounted beside it enables it after startup resolves the invocation.
* The Loader keeps that activation in memory, separate from serialized options,
* so reapplying the composition cannot restore the invocation's row to disabled.
* @param ctx - plugin context whose Loader tree carries the row.
* @param id - the row id.
* @returns nothing once the row has started or is waiting for its dependencies.
* @throws when the Loader or named row is absent.
*/
export async function enableRow(ctx: Context, id: string): Promise<void> {
const loader = ctx.get('loader')
if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service')
const entry = [...loader.entries()].find(candidate => candidate.options.id === id)
if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
await entry.enableRuntime()
}
/**
* 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'
}