index.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. /**
  2. * @deepseek-ai/dsh-cmdline — the command line a dsh launcher hands to the app
  3. * it boots.
  4. *
  5. * The launcher parses only its own flags (`--profile`, `--patch`, the config
  6. * dumps) and hands everything after them to the tree verbatim through the
  7. * {@link CmdlineArgs} service, so an app owns its flag family, its `--help`
  8. * text, and its parse errors instead of the launcher knowing them.
  9. *
  10. * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
  11. * provider may publish the parsed values as its own service from its program's
  12. * commander action, and ordinary rows
  13. * can inject that service and read it from lazily resolved config —
  14. * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written
  15. * beside it. No row has launcher-level command-line status.
  16. * @module @deepseek-ai/dsh-cmdline
  17. */
  18. import type { Command } from 'commander'
  19. import type { Context } from '@deepseek-ai/cordis'
  20. /**
  21. * The invocation's inner arguments: everything after the launcher's own flags,
  22. * verbatim and in argv order. `dsh --profile tui --resume abc` yields
  23. * `['--resume', 'abc']`.
  24. */
  25. export interface CmdlineArgs {
  26. /**
  27. * Read the inner arguments.
  28. * @returns the arguments in argv order; empty when the invocation carried none.
  29. */
  30. get(): readonly string[]
  31. }
  32. /** Request bounded process exit; the launcher wires it to its shutdown controller. */
  33. export interface AppExit {
  34. /**
  35. * Request exit once the tree has been disposed.
  36. * @param code - the process exit code.
  37. */
  38. (code: number): void
  39. }
  40. declare module '@deepseek-ai/cordis' {
  41. interface Context {
  42. /** The invocation's inner arguments; provided by a launcher before the tree mounts. */
  43. cmdlineArgs?: CmdlineArgs
  44. /** Bounded process-exit request; provided by a launcher before the tree mounts. */
  45. appExit?: AppExit
  46. }
  47. }
  48. /** The launcher facts an app needs. */
  49. export interface CmdlineHost {
  50. /** The invocation's inner arguments, in argv order. */
  51. args: readonly string[]
  52. /** Bounded process-exit request. */
  53. exit: AppExit
  54. }
  55. /**
  56. * Provide the command line and the exit request on a host context before any
  57. * tree entry mounts. Both are launcher facts, not config: an embedding host
  58. * with no command line provides an empty argument list.
  59. * @param ctx - the host context the tree will mount under.
  60. * @param host - the invocation's arguments and its exit request.
  61. */
  62. export function provideCmdline(ctx: Context, host: CmdlineHost): void {
  63. const snapshot: readonly string[] = Object.freeze([...host.args])
  64. ctx.provide('cmdlineArgs', { get: () => snapshot })
  65. ctx.provide('appExit', host.exit)
  66. }
  67. /** The process streams commander output is written to; production writes to the process. */
  68. export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = {
  69. stdout: process.stdout,
  70. stderr: process.stderr,
  71. }
  72. /**
  73. * Parse the launcher's immutable argument snapshot with an app's commander
  74. * program. Commander runs the program's own synchronous action handler on a
  75. * successful parse; app code there publishes its service and rejects an
  76. * invalid invocation with `program.error(...)`. This helper has no Loader-row
  77. * or service ownership semantics.
  78. *
  79. * Help, version, and rejected arguments — from the grammar or from an action
  80. * — are terminal for the process: commander writes the text and the helper
  81. * requests `ctx.appExit`. The action never runs on help, version, or a
  82. * grammar rejection; an action must reject before it publishes, because
  83. * statements before its `program.error(...)` have already run.
  84. * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
  85. * @param program - the app's commander program, with its flags, description,
  86. * actions, and any subcommands already declared.
  87. * @throws when the launcher did not provide the command line and exit request,
  88. * or when no command in the program declares an action.
  89. */
  90. export function parseCmdline(ctx: Context, program: Command): void {
  91. // Read through the global service store, not the property proxy: appExit is
  92. // an optional host value and the plugin only needs to inject cmdlineArgs.
  93. const args = ctx.get('cmdlineArgs')
  94. const exit = ctx.get('appExit')
  95. if (args === undefined || exit === undefined) {
  96. throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
  97. }
  98. if (!hasAction(program)) {
  99. 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`)
  100. }
  101. configureExitAndOutput(program)
  102. try {
  103. program.parse(args.get(), { from: 'user' })
  104. } catch (error) {
  105. // exitOverride turns help, version, a parse error, and the action's own
  106. // program.error() into a CommanderError; commander has already written the
  107. // text through the output configured above.
  108. if (!isCommanderError(error)) throw error
  109. exit(error.exitCode)
  110. }
  111. }
  112. /**
  113. * Whether any command in the tree declares an action handler.
  114. *
  115. * The `Command` type cannot express the action precondition, so the handler is
  116. * read structurally (as {@link isCommanderError} reads commander's control-flow
  117. * errors): without this guard, a program that forgot its action would parse
  118. * successfully, publish nothing, and surface only as dependent rows pending on
  119. * the absent service.
  120. * @param command - the command whose tree is inspected.
  121. * @returns true when the command or any registered subcommand has an action.
  122. */
  123. function hasAction(command: Command): boolean {
  124. if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true
  125. return command.commands.some(hasAction)
  126. }
  127. /**
  128. * Route every command's exit and output through the launcher adapter.
  129. *
  130. * Commander copies `exitOverride` and output configuration into a subcommand
  131. * only at registration, so a root-only override would let an
  132. * already-registered subcommand's rejection write to the process streams and
  133. * call `process.exit` directly, bypassing `ctx.appExit`.
  134. * @param command - the root of the command tree to configure.
  135. */
  136. function configureExitAndOutput(command: Command): void {
  137. command
  138. .exitOverride()
  139. .configureOutput({
  140. writeOut: text => void internals.stdout.write(text),
  141. writeErr: text => void internals.stderr.write(text),
  142. })
  143. for (const child of command.commands) configureExitAndOutput(child)
  144. }
  145. /**
  146. * Whether a thrown value is commander's own control-flow error (help, version,
  147. * a parse error, or `program.error`).
  148. *
  149. * Detected structurally, not with `instanceof`: an out-of-tree plugin brings
  150. * its own commander copy, whose `CommanderError` class is a different identity
  151. * from this package's, and an identity check there would rethrow a printed
  152. * help as a fatal load failure.
  153. * @param error - the thrown value.
  154. * @returns true when the value carries commander's error code and exit code.
  155. */
  156. function isCommanderError(error: unknown): error is { code: string; exitCode: number } {
  157. if (typeof error !== 'object' || error === null) return false
  158. const candidate = error as { code?: unknown; exitCode?: unknown }
  159. return typeof candidate.code === 'string' && candidate.code.startsWith('commander.')
  160. && typeof candidate.exitCode === 'number'
  161. }