| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- /**
- * @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
- }
- /** Successful application-startup signal owned by the launcher. */
- export interface AppReady {
- /**
- * Run a listener once successful startup is committed. A failed or
- * externally terminated startup never calls it.
- * @param listener - work that may begin only after successful startup.
- * @returns a disposer that cancels a pending listener.
- */
- onReady(listener: () => void): () => 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
- /** Successful startup signal; provided by a launcher before the tree mounts. */
- appReady?: AppReady
- }
- }
- /** 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
- /** Successful startup signal for lifecycle work that must not mask boot failure. */
- ready?: AppReady
- }
- /**
- * Provide launcher facts on a host context before any tree entry mounts: the
- * command line, bounded exit request, and optional successful-startup signal.
- * An embedding host with no command line provides an empty argument list; a
- * host that mounts a stdio application also provides readiness.
- * @param ctx - the host context the tree will mount under.
- * @param host - the invocation's arguments, exit request, and optional readiness signal.
- */
- 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)
- if (host.ready !== undefined) ctx.provide('appReady', host.ready)
- }
- /** Process stdin operations used to bind a stdio application's lifetime. */
- export interface AppStdin {
- /** Whether EOF arrived before the application bound its listener. */
- readonly readableEnded: boolean
- /** Subscribe once to stdin EOF. */
- once(event: 'end', listener: () => void): unknown
- /** Remove a previously installed stdin EOF listener. */
- off(event: 'end', listener: () => void): unknown
- }
- /** Process streams used by app command lines and stdio lifetime binding; tests substitute them. */
- export const internals: {
- stdin: AppStdin
- stdout: { write(chunk: string): unknown }
- stderr: { write(chunk: string): unknown }
- } = {
- stdin: process.stdin,
- stdout: process.stdout,
- stderr: process.stderr,
- }
- /**
- * Make stdin EOF request the launcher's bounded successful shutdown after
- * {@link AppReady} commits. A startup rejection therefore remains the process
- * outcome when it races EOF. The caller invokes this only after its command
- * action accepts the invocation, so help and usage failures start no transport
- * lifecycle. This listener does not read or resume stdin: the protocol
- * transport owns input and receives bytes buffered before it mounts. Disposal
- * removes the EOF and readiness listeners.
- * @param ctx - app plugin context carrying the launcher's exit request.
- * @param label - effect label naming the owning application.
- */
- export function exitOnStdinEnd(ctx: Context, label: string): void {
- const exit = ctx.get('appExit')
- const ready = ctx.get('appReady')
- if (exit === undefined || ready === undefined) {
- throw new Error('stdio app: the launcher must provide ctx.appExit and ctx.appReady before the tree mounts')
- }
- const stdin = internals.stdin
- let active = true
- let ended = false
- let cancelReady = (): void => {}
- const onEnd = (): void => {
- if (!active || ended) return
- ended = true
- cancelReady = ready.onReady(() => { exit(0) })
- }
- ctx.effect(() => () => {
- active = false
- cancelReady()
- stdin.off('end', onEnd)
- }, label)
- stdin.once('end', onEnd)
- if (stdin.readableEnded) queueMicrotask(onEnd)
- }
- /**
- * 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'
- }
|