index.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  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. /** Successful application-startup signal owned by the launcher. */
  41. export interface AppReady {
  42. /**
  43. * Run a listener once successful startup is committed. A failed or
  44. * externally terminated startup never calls it.
  45. * @param listener - work that may begin only after successful startup.
  46. * @returns a disposer that cancels a pending listener.
  47. */
  48. onReady(listener: () => void): () => void
  49. }
  50. declare module '@deepseek-ai/cordis' {
  51. interface Context {
  52. /** The invocation's inner arguments; provided by a launcher before the tree mounts. */
  53. cmdlineArgs?: CmdlineArgs
  54. /** Bounded process-exit request; provided by a launcher before the tree mounts. */
  55. appExit?: AppExit
  56. /** Successful startup signal; provided by a launcher before the tree mounts. */
  57. appReady?: AppReady
  58. }
  59. }
  60. /** The launcher facts an app needs. */
  61. export interface CmdlineHost {
  62. /** The invocation's inner arguments, in argv order. */
  63. args: readonly string[]
  64. /** Bounded process-exit request. */
  65. exit: AppExit
  66. /** Successful startup signal for lifecycle work that must not mask boot failure. */
  67. ready?: AppReady
  68. }
  69. /**
  70. * Provide launcher facts on a host context before any tree entry mounts: the
  71. * command line, bounded exit request, and optional successful-startup signal.
  72. * An embedding host with no command line provides an empty argument list; a
  73. * host that mounts a stdio application also provides readiness.
  74. * @param ctx - the host context the tree will mount under.
  75. * @param host - the invocation's arguments, exit request, and optional readiness signal.
  76. */
  77. export function provideCmdline(ctx: Context, host: CmdlineHost): void {
  78. const snapshot: readonly string[] = Object.freeze([...host.args])
  79. ctx.provide('cmdlineArgs', { get: () => snapshot })
  80. ctx.provide('appExit', host.exit)
  81. if (host.ready !== undefined) ctx.provide('appReady', host.ready)
  82. }
  83. /** Process stdin operations used to bind a stdio application's lifetime. */
  84. export interface AppStdin {
  85. /** Whether EOF arrived before the application bound its listener. */
  86. readonly readableEnded: boolean
  87. /** Subscribe once to stdin EOF. */
  88. once(event: 'end', listener: () => void): unknown
  89. /** Remove a previously installed stdin EOF listener. */
  90. off(event: 'end', listener: () => void): unknown
  91. }
  92. /** Process streams used by app command lines and stdio lifetime binding; tests substitute them. */
  93. export const internals: {
  94. stdin: AppStdin
  95. stdout: { write(chunk: string): unknown }
  96. stderr: { write(chunk: string): unknown }
  97. } = {
  98. stdin: process.stdin,
  99. stdout: process.stdout,
  100. stderr: process.stderr,
  101. }
  102. /**
  103. * Make stdin EOF request the launcher's bounded successful shutdown after
  104. * {@link AppReady} commits. A startup rejection therefore remains the process
  105. * outcome when it races EOF. The caller invokes this only after its command
  106. * action accepts the invocation, so help and usage failures start no transport
  107. * lifecycle. This listener does not read or resume stdin: the protocol
  108. * transport owns input and receives bytes buffered before it mounts. Disposal
  109. * removes the EOF and readiness listeners.
  110. * @param ctx - app plugin context carrying the launcher's exit request.
  111. * @param label - effect label naming the owning application.
  112. */
  113. export function exitOnStdinEnd(ctx: Context, label: string): void {
  114. const exit = ctx.get('appExit')
  115. const ready = ctx.get('appReady')
  116. if (exit === undefined || ready === undefined) {
  117. throw new Error('stdio app: the launcher must provide ctx.appExit and ctx.appReady before the tree mounts')
  118. }
  119. const stdin = internals.stdin
  120. let active = true
  121. let ended = false
  122. let cancelReady = (): void => {}
  123. const onEnd = (): void => {
  124. if (!active || ended) return
  125. ended = true
  126. cancelReady = ready.onReady(() => { exit(0) })
  127. }
  128. ctx.effect(() => () => {
  129. active = false
  130. cancelReady()
  131. stdin.off('end', onEnd)
  132. }, label)
  133. stdin.once('end', onEnd)
  134. if (stdin.readableEnded) queueMicrotask(onEnd)
  135. }
  136. /**
  137. * Parse the launcher's immutable argument snapshot with an app's commander
  138. * program. Commander runs the program's own synchronous action handler on a
  139. * successful parse; app code there publishes its service and rejects an
  140. * invalid invocation with `program.error(...)`. This helper has no Loader-row
  141. * or service ownership semantics.
  142. *
  143. * Help, version, and rejected arguments — from the grammar or from an action
  144. * — are terminal for the process: commander writes the text and the helper
  145. * requests `ctx.appExit`. The action never runs on help, version, or a
  146. * grammar rejection; an action must reject before it publishes, because
  147. * statements before its `program.error(...)` have already run.
  148. * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
  149. * @param program - the app's commander program, with its flags, description,
  150. * actions, and any subcommands already declared.
  151. * @throws when the launcher did not provide the command line and exit request,
  152. * or when no command in the program declares an action.
  153. */
  154. export function parseCmdline(ctx: Context, program: Command): void {
  155. // Read through the global service store, not the property proxy: appExit is
  156. // an optional host value and the plugin only needs to inject cmdlineArgs.
  157. const args = ctx.get('cmdlineArgs')
  158. const exit = ctx.get('appExit')
  159. if (args === undefined || exit === undefined) {
  160. throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
  161. }
  162. if (!hasAction(program)) {
  163. 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`)
  164. }
  165. configureExitAndOutput(program)
  166. try {
  167. program.parse(args.get(), { from: 'user' })
  168. } catch (error) {
  169. // exitOverride turns help, version, a parse error, and the action's own
  170. // program.error() into a CommanderError; commander has already written the
  171. // text through the output configured above.
  172. if (!isCommanderError(error)) throw error
  173. exit(error.exitCode)
  174. }
  175. }
  176. /**
  177. * Whether any command in the tree declares an action handler.
  178. *
  179. * The `Command` type cannot express the action precondition, so the handler is
  180. * read structurally (as {@link isCommanderError} reads commander's control-flow
  181. * errors): without this guard, a program that forgot its action would parse
  182. * successfully, publish nothing, and surface only as dependent rows pending on
  183. * the absent service.
  184. * @param command - the command whose tree is inspected.
  185. * @returns true when the command or any registered subcommand has an action.
  186. */
  187. function hasAction(command: Command): boolean {
  188. if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true
  189. return command.commands.some(hasAction)
  190. }
  191. /**
  192. * Route every command's exit and output through the launcher adapter.
  193. *
  194. * Commander copies `exitOverride` and output configuration into a subcommand
  195. * only at registration, so a root-only override would let an
  196. * already-registered subcommand's rejection write to the process streams and
  197. * call `process.exit` directly, bypassing `ctx.appExit`.
  198. * @param command - the root of the command tree to configure.
  199. */
  200. function configureExitAndOutput(command: Command): void {
  201. command
  202. .exitOverride()
  203. .configureOutput({
  204. writeOut: text => void internals.stdout.write(text),
  205. writeErr: text => void internals.stderr.write(text),
  206. })
  207. for (const child of command.commands) configureExitAndOutput(child)
  208. }
  209. /**
  210. * Whether a thrown value is commander's own control-flow error (help, version,
  211. * a parse error, or `program.error`).
  212. *
  213. * Detected structurally, not with `instanceof`: an out-of-tree plugin brings
  214. * its own commander copy, whose `CommanderError` class is a different identity
  215. * from this package's, and an identity check there would rethrow a printed
  216. * help as a fatal load failure.
  217. * @param error - the thrown value.
  218. * @returns true when the value carries commander's error code and exit code.
  219. */
  220. function isCommanderError(error: unknown): error is { code: string; exitCode: number } {
  221. if (typeof error !== 'object' || error === null) return false
  222. const candidate = error as { code?: unknown; exitCode?: unknown }
  223. return typeof candidate.code === 'string' && candidate.code.startsWith('commander.')
  224. && typeof candidate.exitCode === 'number'
  225. }