@c9up/ream 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +65 -0
  2. package/dist/Ignitor.d.ts +115 -7
  3. package/dist/Ignitor.d.ts.map +1 -1
  4. package/dist/Ignitor.js +233 -37
  5. package/dist/Ignitor.js.map +1 -1
  6. package/dist/console/BaseCommand.d.ts +217 -0
  7. package/dist/console/BaseCommand.d.ts.map +1 -0
  8. package/dist/console/BaseCommand.js +340 -0
  9. package/dist/console/BaseCommand.js.map +1 -0
  10. package/dist/console/Console.d.ts +86 -0
  11. package/dist/console/Console.d.ts.map +1 -0
  12. package/dist/console/Console.js +134 -0
  13. package/dist/console/Console.js.map +1 -0
  14. package/dist/console/ExceptionHandler.d.ts +28 -0
  15. package/dist/console/ExceptionHandler.d.ts.map +1 -0
  16. package/dist/console/ExceptionHandler.js +76 -0
  17. package/dist/console/ExceptionHandler.js.map +1 -0
  18. package/dist/console/HelpCommand.d.ts +16 -0
  19. package/dist/console/HelpCommand.d.ts.map +1 -0
  20. package/dist/console/HelpCommand.js +40 -0
  21. package/dist/console/HelpCommand.js.map +1 -0
  22. package/dist/console/IndexGenerator.d.ts +24 -0
  23. package/dist/console/IndexGenerator.d.ts.map +1 -0
  24. package/dist/console/IndexGenerator.js +65 -0
  25. package/dist/console/IndexGenerator.js.map +1 -0
  26. package/dist/console/Kernel.d.ts +298 -0
  27. package/dist/console/Kernel.d.ts.map +1 -0
  28. package/dist/console/Kernel.js +1120 -0
  29. package/dist/console/Kernel.js.map +1 -0
  30. package/dist/console/ListCommand.d.ts +18 -0
  31. package/dist/console/ListCommand.d.ts.map +1 -0
  32. package/dist/console/ListCommand.js +60 -0
  33. package/dist/console/ListCommand.js.map +1 -0
  34. package/dist/console/assertions.d.ts +20 -0
  35. package/dist/console/assertions.d.ts.map +1 -0
  36. package/dist/console/assertions.js +84 -0
  37. package/dist/console/assertions.js.map +1 -0
  38. package/dist/console/cliui.d.ts +248 -0
  39. package/dist/console/cliui.d.ts.map +1 -0
  40. package/dist/console/cliui.js +587 -0
  41. package/dist/console/cliui.js.map +1 -0
  42. package/dist/console/decorators.d.ts +90 -0
  43. package/dist/console/decorators.d.ts.map +1 -0
  44. package/dist/console/decorators.js +142 -0
  45. package/dist/console/decorators.js.map +1 -0
  46. package/dist/console/index.d.ts +32 -0
  47. package/dist/console/index.d.ts.map +1 -0
  48. package/dist/console/index.js +24 -0
  49. package/dist/console/index.js.map +1 -0
  50. package/dist/console/loaders.d.ts +52 -0
  51. package/dist/console/loaders.d.ts.map +1 -0
  52. package/dist/console/loaders.js +131 -0
  53. package/dist/console/loaders.js.map +1 -0
  54. package/dist/console/parser.d.ts +86 -0
  55. package/dist/console/parser.d.ts.map +1 -0
  56. package/dist/console/parser.js +440 -0
  57. package/dist/console/parser.js.map +1 -0
  58. package/dist/console/prompts.d.ts +124 -0
  59. package/dist/console/prompts.d.ts.map +1 -0
  60. package/dist/console/prompts.js +364 -0
  61. package/dist/console/prompts.js.map +1 -0
  62. package/dist/console/tracing.d.ts +17 -0
  63. package/dist/console/tracing.d.ts.map +1 -0
  64. package/dist/console/tracing.js +11 -0
  65. package/dist/console/tracing.js.map +1 -0
  66. package/dist/console/types.d.ts +255 -0
  67. package/dist/console/types.d.ts.map +1 -0
  68. package/dist/console/types.js +25 -0
  69. package/dist/console/types.js.map +1 -0
  70. package/dist/console/ui.d.ts +20 -0
  71. package/dist/console/ui.d.ts.map +1 -0
  72. package/dist/console/ui.js +31 -0
  73. package/dist/console/ui.js.map +1 -0
  74. package/dist/console/utils.d.ts +27 -0
  75. package/dist/console/utils.d.ts.map +1 -0
  76. package/dist/console/utils.js +56 -0
  77. package/dist/console/utils.js.map +1 -0
  78. package/dist/container/Container.d.ts.map +1 -1
  79. package/dist/container/Container.js +40 -12
  80. package/dist/container/Container.js.map +1 -1
  81. package/dist/container/types.d.ts +14 -2
  82. package/dist/container/types.d.ts.map +1 -1
  83. package/dist/decorators/Service.d.ts +7 -0
  84. package/dist/decorators/Service.d.ts.map +1 -1
  85. package/dist/decorators/Service.js +23 -3
  86. package/dist/decorators/Service.js.map +1 -1
  87. package/dist/errors/ReamError.d.ts +25 -0
  88. package/dist/errors/ReamError.d.ts.map +1 -1
  89. package/dist/errors/ReamError.js +5 -0
  90. package/dist/errors/ReamError.js.map +1 -1
  91. package/dist/errors/prettyPrintError.d.ts +9 -0
  92. package/dist/errors/prettyPrintError.d.ts.map +1 -0
  93. package/dist/errors/prettyPrintError.js +22 -0
  94. package/dist/errors/prettyPrintError.js.map +1 -0
  95. package/dist/index.d.ts +14 -4
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +16 -2
  98. package/dist/index.js.map +1 -1
  99. package/dist/middleware/Pipeline.d.ts +29 -8
  100. package/dist/middleware/Pipeline.d.ts.map +1 -1
  101. package/dist/middleware/Pipeline.js +39 -1
  102. package/dist/middleware/Pipeline.js.map +1 -1
  103. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  104. package/dist/rpc/RpcRouter.js +13 -2
  105. package/dist/rpc/RpcRouter.js.map +1 -1
  106. package/dist/services/app.d.ts +1 -1
  107. package/dist/services/app.js +1 -1
  108. package/dist/services/console.d.ts +21 -0
  109. package/dist/services/console.d.ts.map +1 -0
  110. package/dist/services/console.js +35 -0
  111. package/dist/services/console.js.map +1 -0
  112. package/dist/testing/ApiResponse.d.ts +218 -0
  113. package/dist/testing/ApiResponse.d.ts.map +1 -0
  114. package/dist/testing/ApiResponse.js +576 -0
  115. package/dist/testing/ApiResponse.js.map +1 -0
  116. package/dist/testing/RequestBuilder.d.ts +132 -72
  117. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  118. package/dist/testing/RequestBuilder.js +389 -222
  119. package/dist/testing/RequestBuilder.js.map +1 -1
  120. package/dist/testing/TestClient.d.ts +34 -39
  121. package/dist/testing/TestClient.d.ts.map +1 -1
  122. package/dist/testing/TestClient.js +48 -56
  123. package/dist/testing/TestClient.js.map +1 -1
  124. package/dist/utils/Macroable.js +3 -3
  125. package/dist/utils/Macroable.js.map +1 -1
  126. package/events.darwin-arm64.node +0 -0
  127. package/events.darwin-x64.node +0 -0
  128. package/events.linux-arm64-gnu.node +0 -0
  129. package/events.linux-x64-gnu.node +0 -0
  130. package/events.win32-x64-msvc.node +0 -0
  131. package/index.darwin-arm64.node +0 -0
  132. package/index.darwin-x64.node +0 -0
  133. package/index.linux-arm64-gnu.node +0 -0
  134. package/index.linux-x64-gnu.node +0 -0
  135. package/index.win32-x64-msvc.node +0 -0
  136. package/package.json +15 -2
  137. package/scheduler.darwin-arm64.node +0 -0
  138. package/scheduler.darwin-x64.node +0 -0
  139. package/scheduler.linux-arm64-gnu.node +0 -0
  140. package/scheduler.linux-x64-gnu.node +0 -0
  141. package/scheduler.win32-x64-msvc.node +0 -0
  142. package/src/Ignitor.ts +340 -42
  143. package/src/console/BaseCommand.ts +467 -0
  144. package/src/console/Console.ts +174 -0
  145. package/src/console/ExceptionHandler.ts +91 -0
  146. package/src/console/HelpCommand.ts +37 -0
  147. package/src/console/IndexGenerator.ts +79 -0
  148. package/src/console/Kernel.ts +1382 -0
  149. package/src/console/ListCommand.ts +55 -0
  150. package/src/console/assertions.ts +114 -0
  151. package/src/console/cliui.ts +756 -0
  152. package/src/console/decorators.ts +199 -0
  153. package/src/console/index.ts +67 -0
  154. package/src/console/loaders.ts +145 -0
  155. package/src/console/parser.ts +598 -0
  156. package/src/console/prompts.ts +556 -0
  157. package/src/console/tracing.ts +22 -0
  158. package/src/console/types.ts +280 -0
  159. package/src/console/ui.ts +37 -0
  160. package/src/console/utils.ts +69 -0
  161. package/src/container/Container.ts +55 -13
  162. package/src/container/types.ts +15 -2
  163. package/src/decorators/Service.ts +28 -3
  164. package/src/errors/ReamError.ts +31 -0
  165. package/src/errors/prettyPrintError.ts +20 -0
  166. package/src/index.ts +30 -4
  167. package/src/middleware/Pipeline.ts +76 -5
  168. package/src/rpc/RpcRouter.ts +16 -2
  169. package/src/services/app.ts +1 -1
  170. package/src/services/console.ts +42 -0
  171. package/src/testing/ApiResponse.ts +691 -0
  172. package/src/testing/RequestBuilder.ts +498 -275
  173. package/src/testing/TestClient.ts +63 -86
  174. package/src/utils/Macroable.ts +3 -3
  175. package/dist/console/CommandRunner.d.ts +0 -18
  176. package/dist/console/CommandRunner.d.ts.map +0 -1
  177. package/dist/console/CommandRunner.js +0 -65
  178. package/dist/console/CommandRunner.js.map +0 -1
  179. package/src/console/CommandRunner.ts +0 -82
@@ -0,0 +1,1382 @@
1
+ /**
2
+ * Console kernel — registers commands and dispatches argv against them.
3
+ *
4
+ * Replaces the previous `CommandRunner`, which parsed argv without knowing the
5
+ * commands' declared inputs and therefore could not support `--flag value`,
6
+ * aliases, negation, typed flags, per-command help or required-input checks.
7
+ *
8
+ * The kernel accepts any class exposing the static contract in `types.ts` —
9
+ * extending `BaseCommand` is optional, which is what keeps agnostic packages
10
+ * free of a `@c9up/ream` dependency.
11
+ */
12
+
13
+ import type { Application } from '../Application.js'
14
+ import { isReamError, ReamError } from '../errors/ReamError.js'
15
+ import { createAssertions, createSnapshot } from './assertions.js'
16
+ import { type Logger, Ui } from './cliui.js'
17
+ import { buildFlag } from './decorators.js'
18
+ import { ExceptionHandler } from './ExceptionHandler.js'
19
+ import HelpCommand from './HelpCommand.js'
20
+ import ListCommand from './ListCommand.js'
21
+ import { assignParsedValues, parseArgv, validateParsed } from './parser.js'
22
+ import { Prompt } from './prompts.js'
23
+ import { commandExec } from './tracing.js'
24
+ import {
25
+ type CommandClass,
26
+ type CommandInstance,
27
+ type ExecutedCommand,
28
+ type FlagMetaData,
29
+ isCommandClass,
30
+ type ParsedInput,
31
+ type SerializedCommand,
32
+ } from './types.js'
33
+ import { renderErrorWithSuggestions } from './utils.js'
34
+
35
+ /**
36
+ * Called when a global flag was passed to the command invoked from the command
37
+ * line. Returning `true` stops the dispatch before the command runs.
38
+ */
39
+ /** The kernel's lifecycle stage — Console `getState()`. */
40
+ export type KernelState = 'idle' | 'booted' | 'running' | 'completed'
41
+
42
+ /**
43
+ * A source of commands (Console `LoadersContract`).
44
+ *
45
+ * Two steps, as in Console: the metadata of everything it offers, then the class
46
+ * for one of them — a loader that reads a directory can answer the first from
47
+ * a manifest without importing anything.
48
+ */
49
+ export interface CommandLoader {
50
+ getMetaData(): Promise<SerializedCommand[]>
51
+ getCommand(metadata: SerializedCommand): Promise<CommandClass | null>
52
+ }
53
+
54
+ /**
55
+ * How a command is built and run (Console `ExecutorContract`).
56
+ *
57
+ * `create` returns the instance, ready but untouched; `run` drives its
58
+ * lifecycle and returns what `run()` produced.
59
+ */
60
+ export interface CommandExecutor {
61
+ /**
62
+ * The fourth argument is Ream's: which UI the command writes through, and
63
+ * whether it is the one the command line invoked. An executor ported from
64
+ * Console simply ignores it.
65
+ */
66
+ create(
67
+ Command: CommandClass,
68
+ parsed: ParsedInput,
69
+ kernel: Kernel,
70
+ context: { isMain: boolean; ui: Ui },
71
+ ): CommandInstance | Promise<CommandInstance>
72
+ /** Runs `prepare`, `interact` and `run`, and throws what they throw. */
73
+ run(command: CommandInstance, kernel: Kernel): unknown
74
+ }
75
+
76
+ /** Renders the failure of the command line (Console `ExceptionHandler`). */
77
+ export interface ErrorRenderer {
78
+ render(error: unknown, kernel: Kernel): void | Promise<void>
79
+ }
80
+
81
+ export type GlobalFlagListener = (
82
+ command: CommandClass,
83
+ kernel: Kernel,
84
+ parsed: ParsedInput,
85
+ // biome-ignore lint/suspicious/noConfusingVoidType: Console contract — returning `true` ends the dispatch, returning nothing is the normal case
86
+ ) => boolean | void | Promise<boolean | void>
87
+
88
+ import { colourise } from './ui.js'
89
+
90
+ export interface KernelOptions {
91
+ /**
92
+ * Boots the application. Called only for commands declaring
93
+ * `options.startApp` — a scaffolding command must not open a DB connection.
94
+ */
95
+ startApp?: () => Promise<Application>
96
+ /** Shown in usage lines. */
97
+ binaryName?: string
98
+ /**
99
+ * Shuts the application down. Handed to the command as `this.terminate()`,
100
+ * the only way a `staysAlive` command can end itself.
101
+ */
102
+ onTerminate?: () => void | Promise<void>
103
+ /**
104
+ * The prompt handed to every command as `this.prompt`.
105
+ *
106
+ * Supply one to script its answers with `trap()`: without this the kernel
107
+ * builds its own, and a command that asks anything is untestable through
108
+ * `exec()` — it would reach a terminal that is not there.
109
+ */
110
+ prompt?: Prompt
111
+ }
112
+
113
+ export interface HandleResult {
114
+ /** The command asked to keep the process alive (worker, watcher…). */
115
+ staysAlive: boolean
116
+ }
117
+
118
+ export class Kernel {
119
+ readonly #commands = new Map<string, CommandClass>()
120
+ /**
121
+ * Commands a loader announced but whose class has not been imported yet.
122
+ *
123
+ * Booting reads metadata only, as Console does: listing the commands, answering
124
+ * `hasCommand()` or printing help must not run the module of a command
125
+ * nobody asked for.
126
+ */
127
+ readonly #pending = new Map<string, { metadata: SerializedCommand; loader: CommandLoader }>()
128
+ readonly #aliases = new Map<string, string[]>()
129
+ /**
130
+ * Commands the kernel registered on its own behalf, which an application may
131
+ * replace — see {@link register}.
132
+ */
133
+ readonly #defaults = new Set<string>()
134
+ /** Flags every command accepts — Console's global command. */
135
+ readonly #globalFlags: FlagMetaData[] = []
136
+ readonly #flagListeners = new Map<string, GlobalFlagListener>()
137
+ readonly #loaders: Array<CommandLoader | (() => Promise<CommandLoader>)> = []
138
+ readonly #hooks: {
139
+ finding: Array<(commandName: string) => void | Promise<void>>
140
+ loading: Array<(metadata: SerializedCommand) => void | Promise<void>>
141
+ loaded: Array<(command: CommandClass) => void | Promise<void>>
142
+ executing: Array<(command: CommandInstance, isMain: boolean) => void | Promise<void>>
143
+ executed: Array<(command: CommandInstance, isMain: boolean) => void | Promise<void>>
144
+ } = { finding: [], loading: [], loaded: [], executing: [], executed: [] }
145
+ #state: KernelState = 'idle'
146
+ #mainCommand: CommandInstance | undefined
147
+ readonly #defaultCommand: CommandClass
148
+ readonly #executor: CommandExecutor
149
+ /** The application a command was built with, when it asked for one. */
150
+ readonly #apps = new WeakMap<CommandInstance, Application>()
151
+ readonly #startApp: (() => Promise<Application>) | undefined
152
+ readonly #onTerminate: (() => void | Promise<void>) | undefined
153
+ readonly #prompt: Prompt | undefined
154
+ readonly #binaryName: string
155
+ /** Shared by the kernel and every command it runs. */
156
+ readonly ui = new Ui()
157
+ readonly logger: Logger = this.ui.logger
158
+
159
+ /**
160
+ * The command a kernel runs when none is named. Replace it to ship a
161
+ * different landing command (Console `Kernel.defaultCommand`).
162
+ */
163
+ static defaultCommand: CommandClass = ListCommand
164
+
165
+ /**
166
+ * How a command is built and run (Console `Kernel.commandExecutor`).
167
+ *
168
+ * The seam AdonisJS itself uses to add dependency injection; Ream's default
169
+ * already does it — the container builds the command and invokes its hooks,
170
+ * so `@inject()` works on both. Replace it on a subclass to drive commands
171
+ * some other way.
172
+ */
173
+ static commandExecutor: CommandExecutor = {
174
+ create(Command, parsed, kernel, context) {
175
+ return kernel.buildCommand(Command, parsed, context)
176
+ },
177
+ run(command, kernel) {
178
+ return kernel.runLifecycle(command)
179
+ },
180
+ }
181
+
182
+ /**
183
+ * What the CLI says about itself — binary name, versions. Printed above the
184
+ * command listing when it holds anything (Console `kernel.info`).
185
+ */
186
+ readonly info = new Map<string, string | number | boolean>()
187
+
188
+ /**
189
+ * Renders the failure of the command line (Console `kernel.errorHandler`).
190
+ *
191
+ * Replaceable: an application that wants its own reporting sets it here
192
+ * rather than wrapping `handle()`, which is what owns the process.
193
+ */
194
+ errorHandler: ErrorRenderer = new ExceptionHandler()
195
+
196
+ /**
197
+ * The exit code the command line ended on (Console `kernel.exitCode`). Set once
198
+ * the dispatch is over: 1 for a failure, otherwise the command's own.
199
+ */
200
+ exitCode: number | undefined
201
+
202
+ /**
203
+ * Console's factory — `Kernel.create()` reads better than `new Kernel()`.
204
+ *
205
+ * Built through `this`, so a subclass overriding `defaultCommand` or
206
+ * `commandExecutor` gets its own from the factory as well as from `new`.
207
+ */
208
+ static create<T extends typeof Kernel>(this: T, options: KernelOptions = {}): InstanceType<T> {
209
+ return new this(options) as InstanceType<T>
210
+ }
211
+
212
+ constructor(options: KernelOptions = {}) {
213
+ this.#startApp = options.startApp
214
+ this.#onTerminate = options.onTerminate
215
+ this.#prompt = options.prompt
216
+ this.#binaryName = options.binaryName ?? 'ream'
217
+ // `list` is a command, not a branch in the dispatcher — Console registers it the
218
+ // same way, which is what makes `hasCommand('list')`, `exec('list')` and
219
+ // `help list` true statements instead of special cases.
220
+ // Read off `new.target`, so a subclass overriding `static defaultCommand`
221
+ // gets its own (Console's customisation point).
222
+ this.#defaultCommand = new.target.defaultCommand
223
+ this.#executor = new.target.commandExecutor
224
+ this.register(this.#defaultCommand)
225
+ this.#defaults.add(this.#defaultCommand.commandName)
226
+ // `help` is a command for the same reason `list` is: everything the
227
+ // registry guarantees — introspection, `exec()`, replaceability — is false
228
+ // for a name only the dispatcher knows.
229
+ this.register(HelpCommand)
230
+ this.#defaults.add(HelpCommand.commandName)
231
+
232
+ // Console's `--help`, a global flag whose listener runs the help command.
233
+ this.defineFlag('help', {
234
+ type: 'boolean',
235
+ alias: ['h'],
236
+ description: 'Display help for the given command',
237
+ })
238
+ this.on('help', async (Command, kernel, parsed) => {
239
+ // Only the mention, not `--no-help`.
240
+ if (parsed.flags.help !== true) return false
241
+ // Whatever was resolved: the named command, or the default one for a bare
242
+ // `ream --help` — whose own help documents the listing.
243
+ await kernel.exec('help', [Command.commandName])
244
+ return true
245
+ })
246
+
247
+ // Console's `--ansi` / `--no-ansi`, declared as what they are: a global flag.
248
+ // Declaring them makes them visible in help and rejected nowhere, instead
249
+ // of being intercepted by a special case in the dispatcher.
250
+ this.defineFlag('ansi', {
251
+ type: 'boolean',
252
+ description: 'Force colours on, or off with --no-ansi',
253
+ showNegatedVariantInHelp: true,
254
+ })
255
+ this.on('ansi', (_command, _kernel, parsed) => {
256
+ if (parsed.flags.ansi === true) {
257
+ process.env.FORCE_COLOR = '1'
258
+ delete process.env.NO_COLOR
259
+ return
260
+ }
261
+ process.env.NO_COLOR = '1'
262
+ delete process.env.FORCE_COLOR
263
+ })
264
+ }
265
+
266
+ /** Shown in usage lines — commands read it to print their own. */
267
+ get binaryName(): string {
268
+ return this.#binaryName
269
+ }
270
+
271
+ /**
272
+ * Declare a flag every command accepts (Console `kernel.defineFlag`).
273
+ *
274
+ * Global flags steer the CLI, not the command: they are merged into the
275
+ * parser so no command has to redeclare them, and they are NOT assigned to
276
+ * the command's properties — a command reads them through `this.parsed.flags`
277
+ * or, more usually, does not read them at all.
278
+ */
279
+ defineFlag(name: string, options: Partial<FlagMetaData> = {}): this {
280
+ if (this.#state !== 'idle') {
281
+ throw new ReamError(
282
+ 'E_CONSOLE_LATE_GLOBAL_FLAG',
283
+ `Global flag "--${name}" was declared while the kernel was "${this.#state}".`,
284
+ { hint: 'Declare global flags while wiring the kernel, before booting it.' },
285
+ )
286
+ }
287
+ this.#globalFlags.push(buildFlag(name, options))
288
+ return this
289
+ }
290
+
291
+ /** The global flags — Console `kernel.flags`. */
292
+ get flags(): readonly FlagMetaData[] {
293
+ return [...this.#globalFlags]
294
+ }
295
+
296
+ /**
297
+ * React to a global flag (Console `kernel.on`). One listener per flag, the last
298
+ * one registered winning, and only for the command invoked from the command
299
+ * line. Returning `true` stops there, without running the command — that is
300
+ * how `--help` short-circuits.
301
+ */
302
+ on(flagName: string, listener: GlobalFlagListener): this {
303
+ this.#flagListeners.set(flagName, listener)
304
+ return this
305
+ }
306
+
307
+ register(command: CommandClass): this {
308
+ const existing = this.#commands.get(command.commandName)
309
+ if (existing !== undefined && existing !== command) {
310
+ // The kernel's own defaults are replaceable: one registry, and the
311
+ // application wins — an app shipping its own `list` gets it, the same
312
+ // rule the CLI applies to the built-in commands it shadows. Two
313
+ // APPLICATION commands claiming one name stays an error: there is
314
+ // nothing to arbitrate between them.
315
+ if (!this.#defaults.has(command.commandName)) {
316
+ throw new ReamError(
317
+ 'E_CONSOLE_DUPLICATE_COMMAND',
318
+ `Two commands claim the name "${command.commandName}".`,
319
+ { hint: 'Rename one of them, or namespace it (e.g. "app:provision").' },
320
+ )
321
+ }
322
+ this.#defaults.delete(command.commandName)
323
+ }
324
+ this.#pending.delete(command.commandName)
325
+ this.#commands.set(command.commandName, command)
326
+ // `static aliases` travel with the command; rc-level `commandsAliases` are
327
+ // registered separately and may still override these.
328
+ for (const alias of command.aliases ?? []) {
329
+ this.#aliases.set(alias, [command.commandName])
330
+ }
331
+ return this
332
+ }
333
+
334
+ registerMany(commands: Iterable<CommandClass>): this {
335
+ for (const command of commands) this.register(command)
336
+ return this
337
+ }
338
+
339
+ /**
340
+ * Register the default export of a loaded module when it is a command.
341
+ * Returns whether anything was registered, so the caller can report a module
342
+ * that exports the wrong shape instead of ignoring it.
343
+ */
344
+ registerModule(mod: unknown): boolean {
345
+ if (typeof mod !== 'object' || mod === null) return false
346
+ const value = Reflect.get(mod, 'default')
347
+ if (!isCommandClass(value)) return false
348
+ this.register(value)
349
+ return true
350
+ }
351
+
352
+ /**
353
+ * Register a shorthand expanding to a command and, optionally, its flags —
354
+ * Console's `commandsAliases`. `resource` → `make:controller --resource` means
355
+ * `ream resource users` runs `ream make:controller --resource users`.
356
+ */
357
+ addAlias(alias: string, expansion: string): this {
358
+ const tokens = tokeniseExpansion(expansion)
359
+ if (tokens.length === 0) {
360
+ throw new ReamError('E_CONSOLE_INVALID_ALIAS', `Alias "${alias}" expands to nothing.`, {
361
+ hint: 'Expected a command name, e.g. "make:controller --resource".',
362
+ })
363
+ }
364
+ this.#aliases.set(alias, tokens)
365
+ return this
366
+ }
367
+
368
+ /**
369
+ * Resolve a command by name or alias — Console's `find()`.
370
+ *
371
+ * Async and throwing, as in Console: a name that resolves to nothing is a call
372
+ * error, and returning `undefined` pushes every caller into a check it
373
+ * usually forgets. `await` works even though Ream's registry answers
374
+ * immediately.
375
+ */
376
+ async find(name: string): Promise<CommandClass> {
377
+ const [resolved] = this.#expandAlias([name])
378
+ const commandName = resolved ?? name
379
+ await this.#emit(this.#hooks.finding, (handler) => handler(commandName))
380
+
381
+ const loaded = this.#commands.get(commandName)
382
+ if (loaded !== undefined) {
383
+ await this.#emit(this.#hooks.loading, (handler) => handler(this.#metadataFor(loaded)))
384
+ await this.#emit(this.#hooks.loaded, (handler) => handler(loaded))
385
+ return loaded
386
+ }
387
+
388
+ const pending = this.#pending.get(commandName)
389
+ if (pending === undefined) {
390
+ throw new ReamError('E_CONSOLE_COMMAND_NOT_FOUND', `Unknown command "${name}".`, {
391
+ hint: 'Check the name with hasCommand() first, or list them with getCommands().',
392
+ })
393
+ }
394
+
395
+ await this.#emit(this.#hooks.loading, (handler) => handler(pending.metadata))
396
+
397
+ // Here is where a loader's module is imported — for the command that was
398
+ // actually asked for, and once.
399
+ const command = await pending.loader.getCommand(pending.metadata)
400
+ if (command === null) {
401
+ throw new ReamError(
402
+ 'E_CONSOLE_COMMAND_NOT_FOUND',
403
+ `The loader announced "${commandName}" but did not provide it.`,
404
+ { hint: 'A loader must return the command it listed in its metadata.' },
405
+ )
406
+ }
407
+
408
+ this.register(command)
409
+ await this.#emit(this.#hooks.loaded, (handler) => handler(command))
410
+ return command
411
+ }
412
+
413
+ /**
414
+ * Register a source of commands (Console `kernel.addLoader`).
415
+ *
416
+ * Consumed by {@link boot}, once. Unlike Console, the classes are resolved there
417
+ * and then rather than on first use: Ream's registry holds classes, which is
418
+ * what lets `find()` and `hasCommand()` answer without waiting on a loader.
419
+ */
420
+ addLoader(loader: CommandLoader | (() => Promise<CommandLoader>)): this {
421
+ if (this.#state !== 'idle') {
422
+ throw new ReamError(
423
+ 'E_CONSOLE_LATE_LOADER',
424
+ `A command loader was added while the kernel was "${this.#state}".`,
425
+ { hint: 'Add loaders before booting the kernel.' },
426
+ )
427
+ }
428
+ this.#loaders.push(loader)
429
+ return this
430
+ }
431
+
432
+ /**
433
+ * Load every registered loader's commands. Idempotent, and called by
434
+ * `handle()` — an application driving its own dispatch calls it itself.
435
+ */
436
+ async boot(): Promise<void> {
437
+ if (this.#state !== 'idle') return
438
+ this.#state = 'booted'
439
+
440
+ for (const entry of this.#loaders) {
441
+ const loader = typeof entry === 'function' ? await entry() : entry
442
+ for (const metadata of await loader.getMetaData()) {
443
+ // Metadata only — the class is imported by `find()`, when someone asks
444
+ // for the command. `loading` / `loaded` belong there too.
445
+ if (!this.#commands.has(metadata.commandName)) {
446
+ this.#pending.set(metadata.commandName, { metadata, loader })
447
+ }
448
+ // The metadata is what a manifest-style loader publishes; its aliases
449
+ // may be richer than the class's `static aliases`, and reading only the
450
+ // class would leave `hasCommand(alias)` answering false for a name the
451
+ // loader announced.
452
+ for (const alias of metadata.aliases) this.addAlias(alias, metadata.commandName)
453
+ }
454
+ }
455
+ }
456
+
457
+ /** The kernel's lifecycle stage — Console `getState()`. */
458
+ getState(): KernelState {
459
+ return this.#state
460
+ }
461
+
462
+ /**
463
+ * The command run when none is named — the one this kernel was built with,
464
+ * or the application's replacement when it registered one under that name.
465
+ */
466
+ getDefaultCommand(): CommandClass {
467
+ return this.#commands.get(this.#defaultCommand.commandName) ?? this.#defaultCommand
468
+ }
469
+
470
+ /** The command invoked from the command line, once it has been built. */
471
+ getMainCommand(): CommandInstance | undefined {
472
+ return this.#mainCommand
473
+ }
474
+
475
+ /** Before a command name is resolved (Console hook). */
476
+ finding(callback: (commandName: string) => void | Promise<void>): this {
477
+ this.#hooks.finding.push(callback)
478
+ return this
479
+ }
480
+
481
+ /** Before a command class is read from its loader (Console hook). */
482
+ loading(callback: (metadata: SerializedCommand) => void | Promise<void>): this {
483
+ this.#hooks.loading.push(callback)
484
+ return this
485
+ }
486
+
487
+ /** After a command class has been loaded (Console hook). */
488
+ loaded(callback: (command: CommandClass) => void | Promise<void>): this {
489
+ this.#hooks.loaded.push(callback)
490
+ return this
491
+ }
492
+
493
+ /** Before a command runs. `isMain` distinguishes the CLI dispatch. */
494
+ executing(callback: (command: CommandInstance, isMain: boolean) => void | Promise<void>): this {
495
+ this.#hooks.executing.push(callback)
496
+ return this
497
+ }
498
+
499
+ /** After a command has run — not when it failed, as in Console. */
500
+ executed(callback: (command: CommandInstance, isMain: boolean) => void | Promise<void>): this {
501
+ this.#hooks.executed.push(callback)
502
+ return this
503
+ }
504
+
505
+ /**
506
+ * The metadata of every registered command, as Console exposes it.
507
+ *
508
+ * Metadata rather than the classes themselves: this is the introspection
509
+ * surface (help, completions, a command palette), and handing out the
510
+ * constructors invites callers to instantiate a command outside the kernel,
511
+ * where nothing injects its dependencies or runs its lifecycle. The class is
512
+ * still reachable through {@link find} when a caller genuinely needs it.
513
+ */
514
+ getCommands(): SerializedCommand[] {
515
+ return this.#allMetadata()
516
+ }
517
+
518
+ /** One command's metadata, or null — Console's `getCommand()`. */
519
+ getCommand(commandName: string): SerializedCommand | null {
520
+ const command = this.#commands.get(commandName)
521
+ if (command !== undefined) return this.#metadataFor(command)
522
+ const pending = this.#pending.get(commandName)
523
+ // Answered from the metadata a loader published: describing a command must
524
+ // not require importing it.
525
+ return pending === undefined
526
+ ? null
527
+ : { ...pending.metadata, aliases: this.#aliasesFor(commandName) }
528
+ }
529
+
530
+ /**
531
+ * The commands of one namespace. Called without one — or with an empty
532
+ * string — it answers with the commands that have no namespace at all, which
533
+ * is the group Console's own listing prints first.
534
+ */
535
+ getNamespaceCommands(namespace?: string): SerializedCommand[] {
536
+ const wanted = namespace === undefined || namespace === '' ? null : namespace
537
+ return this.#allMetadata().filter((command) => command.namespace === wanted)
538
+ }
539
+
540
+ /** Every namespace in use, sorted. */
541
+ getNamespaces(): string[] {
542
+ const found = new Set<string>()
543
+ for (const commandName of this.#names()) {
544
+ const namespace = namespaceOf(commandName)
545
+ if (namespace !== null) found.add(namespace)
546
+ }
547
+ return [...found].sort((a, b) => a.localeCompare(b))
548
+ }
549
+
550
+ /** Every registered alias — `static aliases` and the rc file's alike. */
551
+ getAliases(): string[] {
552
+ return [...this.#aliases.keys()].sort((a, b) => a.localeCompare(b))
553
+ }
554
+
555
+ /** The command an alias stands for, or null when the alias is unknown. */
556
+ getAliasCommand(alias: string): SerializedCommand | null {
557
+ const expansion = this.#aliases.get(alias)
558
+ if (expansion === undefined) return null
559
+ return this.getCommand(expansion[0] ?? '')
560
+ }
561
+
562
+ /** Every alias pointing at this command. */
563
+ getCommandAliases(commandName: string): string[] {
564
+ return this.#aliasesFor(commandName)
565
+ }
566
+
567
+ /**
568
+ * Names close enough to this one to be what the user meant — Console's
569
+ * suggestions. Aliases are included: they are names one can legitimately
570
+ * type, so a typo on one deserves the same help.
571
+ */
572
+ getCommandSuggestions(name: string): string[] {
573
+ return suggestFrom(name, [...this.#names(), ...this.#aliases.keys()])
574
+ }
575
+
576
+ getNamespaceSuggestions(name: string): string[] {
577
+ return suggestFrom(name, this.getNamespaces())
578
+ }
579
+
580
+ async handle(
581
+ rawArgv: readonly string[],
582
+ nodeArgs: readonly string[] = [],
583
+ ): Promise<HandleResult> {
584
+ if (this.#state === 'running') {
585
+ throw new ReamError(
586
+ 'E_CONSOLE_KERNEL_RUNNING',
587
+ 'A command is already running through this kernel.',
588
+ { hint: 'One kernel drives one command line. Use exec() to call another command.' },
589
+ )
590
+ }
591
+ if (this.#state === 'completed') {
592
+ throw new ReamError(
593
+ 'E_CONSOLE_KERNEL_COMPLETED',
594
+ 'The kernel has finished; it cannot run another command line.',
595
+ { hint: 'Create a fresh kernel.' },
596
+ )
597
+ }
598
+
599
+ await this.boot()
600
+ this.#state = 'running'
601
+ try {
602
+ return await this.#dispatch(rawArgv, nodeArgs)
603
+ } catch (error) {
604
+ // The command line owns the process, so a failure is REPORTED here rather
605
+ // than thrown at a caller who has nothing better to do with it — Console's
606
+ // rule. `exec()` is the path that rethrows.
607
+ this.exitCode = 1
608
+ process.exitCode = 1
609
+ await this.errorHandler.render(error, this)
610
+ return { staysAlive: false }
611
+ } finally {
612
+ // In `finally`, not after the call: `handle()` returns early on help, on
613
+ // an unknown command and on a short-circuiting flag listener. A state set
614
+ // on the success path alone lies on every other one.
615
+ this.#state = 'completed'
616
+ }
617
+ }
618
+
619
+ async #dispatch(rawArgv: readonly string[], nodeArgs: readonly string[]): Promise<HandleResult> {
620
+ const argv = this.#expandAlias(this.#hoistGlobalFlags(rawArgv))
621
+ const [name, ...rest] = argv
622
+
623
+ // Resolved through `find()`, like Console's own CLI path: it is what runs the
624
+ // finding/loading/loaded hooks, and a tool listening to them must see the
625
+ // command line, not only `exec()`.
626
+ //
627
+ // The default command goes through `find()` too: `ream` bare and
628
+ // `ream <command>` must run the same cycle, hooks included. Resolved by
629
+ // NAME, so an application that replaced it gets its own.
630
+ const target = name ?? this.getDefaultCommand().commandName
631
+ let Command: CommandClass
632
+ try {
633
+ Command = await this.find(target)
634
+ } catch (error) {
635
+ // An unknown name gets the listing's help, not a stack trace: a typo is
636
+ // the most common way to reach this.
637
+ if (isReamError(error, 'E_CONSOLE_COMMAND_NOT_FOUND')) {
638
+ this.#reportUnknown(target)
639
+ return { staysAlive: false }
640
+ }
641
+ throw error
642
+ }
643
+
644
+ const argvForCommand = name === undefined ? [] : rest
645
+
646
+ const parsed = this.#parse(Command, argvForCommand, nodeArgs, true)
647
+ if (await this.#runFlagListeners(Command, parsed)) return { staysAlive: false }
648
+ this.#validate(Command, parsed)
649
+
650
+ const instance = await this.#runCommand(Command, parsed, true)
651
+ this.#mainCommand = instance
652
+
653
+ const failure = Reflect.get(instance, 'error')
654
+ if (failure !== undefined) throw failure
655
+
656
+ const exitCode = Reflect.get(instance, 'exitCode')
657
+ if (typeof exitCode === 'number') this.exitCode = exitCode
658
+ if (typeof exitCode === 'number' && exitCode !== 0) process.exitCode = exitCode
659
+
660
+ return { staysAlive: Command.options?.staysAlive === true }
661
+ }
662
+
663
+ /**
664
+ * Parse, build and run one command through its full lifecycle.
665
+ *
666
+ * Shared by `handle()` (the CLI path, which rethrows) and `exec()` (the
667
+ * programmatic path, which reports through the returned instance). Errors are
668
+ * recorded on the instance rather than thrown, so both callers decide what to
669
+ * do with them instead of the logic being written twice.
670
+ */
671
+ /**
672
+ * Read argv against a command's declarations, global flags included.
673
+ *
674
+ * Separate from {@link #runCommand} because the main command's parsed input
675
+ * is needed BEFORE the command is built: Console's global-flag listeners see it,
676
+ * and one of them may end the dispatch.
677
+ */
678
+ #parse(
679
+ Command: CommandClass,
680
+ argv: readonly string[],
681
+ nodeArgs: readonly string[],
682
+ withGlobalFlags: boolean,
683
+ ): ParsedInput {
684
+ const parsed = parseArgv(argv, {
685
+ args: Command.args,
686
+ // Global flags belong to the command line: they are merged for the
687
+ // command invoked there, and NOT for `exec()`, as in Console. A caller
688
+ // passing `--no-ansi` to exec() is passing a flag the command does not
689
+ // accept, and hearing so is the point.
690
+ flags: withGlobalFlags ? this.#flagsFor(Command) : (Command.flags ?? []),
691
+ allowUnknownFlags: Command.options?.allowUnknownFlags,
692
+ commandName: Command.commandName,
693
+ // Leniently: the required inputs are checked AFTER the global-flag
694
+ // listeners have had their say, which is what makes `--help` work on a
695
+ // command whose flags are required (Console's order).
696
+ validate: false,
697
+ })
698
+ parsed.nodeArgs = [...nodeArgs]
699
+ return parsed
700
+ }
701
+
702
+ /** Hold the parsed input to the command's declarations — after the listeners. */
703
+ #validate(Command: CommandClass, parsed: ParsedInput): void {
704
+ validateParsed(parsed, {
705
+ args: Command.args,
706
+ flags: Command.flags,
707
+ allowUnknownFlags: Command.options?.allowUnknownFlags,
708
+ commandName: Command.commandName,
709
+ })
710
+ }
711
+
712
+ /**
713
+ * Run the listeners of the global flags that were passed. Returns whether one
714
+ * of them ended the dispatch.
715
+ *
716
+ * Console runs them for the main command only, and before it is built: a command
717
+ * that will not run must not boot the application first.
718
+ */
719
+ async #runFlagListeners(Command: CommandClass, parsed: ParsedInput): Promise<boolean> {
720
+ for (const [flagName, listener] of this.#flagListeners) {
721
+ if (parsed.flags[flagName] === undefined) continue
722
+ // A command redeclaring the name owns the flag entirely — the value in
723
+ // `parsed.flags` is ITS value, and handing it to the global listener
724
+ // would act on something that was never the global flag.
725
+ if (Command.flags?.some((flag) => flag.flagName === flagName)) continue
726
+ if ((await listener(Command, this, parsed)) === true) return true
727
+ }
728
+ return false
729
+ }
730
+
731
+ /**
732
+ * Build a command: inject, plug in the plumbing, hydrate. Everything up to
733
+ * the lifecycle, shared by {@link create} and {@link #runCommand}.
734
+ */
735
+ async #build(
736
+ Command: CommandClass,
737
+ parsed: ParsedInput,
738
+ isMain: boolean,
739
+ ui: Ui,
740
+ ): Promise<{ instance: CommandInstance; app: Application | undefined }> {
741
+ // The application is resolved BEFORE the command is built: with one
742
+ // available, the command goes through the container so constructor
743
+ // dependencies are injected (Console parity), and its lifecycle hooks are
744
+ // invoked through `container.call` so `@inject()` works on them too.
745
+ const app = await this.#resolveApp(Command)
746
+ const instance: CommandInstance =
747
+ app === undefined ? new Command() : await app.container.make<CommandInstance>(Command)
748
+
749
+ Object.assign(instance, {
750
+ logger: ui.logger,
751
+ ui,
752
+ kernel: this,
753
+ colors: ui.colors,
754
+ // `handle()` is the command line; `exec()` is another caller.
755
+ isMain,
756
+ prompt: this.#prompt ?? new Prompt(),
757
+ // Console exposes every parsed input, not only the declared ones.
758
+ parsed,
759
+ onTerminate: this.#onTerminate,
760
+ })
761
+ this.#attachApp(instance, Command, app)
762
+
763
+ // Values after the plumbing, as Console does: the command's own `hydrate()`
764
+ // when it has one — a ported command may call it again, it is idempotent —
765
+ // and a plain assignment for a class declared structurally, which has none.
766
+ const hydrate = Reflect.get(instance, 'hydrate')
767
+ if (typeof hydrate === 'function') Reflect.apply(hydrate, instance, [])
768
+ else assignParsedValues(instance, Command.args ?? [], Command.flags ?? [], parsed)
769
+
770
+ // The application is handed back rather than resolved twice: booting it is
771
+ // not free, and `#resolveApp` is what starts it.
772
+ return { instance, app }
773
+ }
774
+
775
+ /**
776
+ * Build a command, ready to run — the default executor's `create`.
777
+ *
778
+ * @internal Public only because {@link commandExecutor} is a static object
779
+ * and cannot reach the kernel's privates.
780
+ */
781
+ async buildCommand(
782
+ Command: CommandClass,
783
+ parsed: ParsedInput,
784
+ context: { isMain: boolean; ui: Ui } = { isMain: false, ui: this.ui },
785
+ ): Promise<CommandInstance> {
786
+ const { instance, app } = await this.#build(Command, parsed, context.isMain, context.ui)
787
+ // Kept off the instance: `this.app` throws by design when the command did
788
+ // not ask for the application, and the lifecycle still needs to know
789
+ // whether there is a container to call through.
790
+ if (app !== undefined) this.#apps.set(instance, app)
791
+ return instance
792
+ }
793
+
794
+ /**
795
+ * Run `prepare`, `interact` and `run` — the default executor's `run`.
796
+ *
797
+ * `completed` is deliberately NOT here: it must run even when this throws,
798
+ * which is the kernel's business, not the executor's.
799
+ *
800
+ * @internal See {@link buildCommand}.
801
+ */
802
+ async runLifecycle(command: CommandInstance): Promise<unknown> {
803
+ await this.#invoke(command, 'prepare')
804
+ await this.#invoke(command, 'interact')
805
+ return this.#invoke(command, 'run')
806
+ }
807
+
808
+ /** Call a lifecycle hook, through the container when there is one. */
809
+ async #invoke(
810
+ command: CommandInstance,
811
+ hook: 'prepare' | 'interact' | 'run' | 'completed',
812
+ ): Promise<unknown> {
813
+ if (typeof command[hook] !== 'function') return undefined
814
+ const app = this.#apps.get(command)
815
+ if (app === undefined) return command[hook]()
816
+ return app.container.call(command, hook)
817
+ }
818
+
819
+ async #runCommand(
820
+ Command: CommandClass,
821
+ parsed: ParsedInput,
822
+ isMain = false,
823
+ ui: Ui = this.ui,
824
+ ): Promise<ExecutedCommand> {
825
+ const instance = await this.#executor.create(Command, parsed, this, { isMain, ui })
826
+ const invoke = (hook: 'completed'): Promise<unknown> => this.#invoke(instance, hook)
827
+
828
+ // Console's order: prepare → interact → run → completed. `completed` runs even
829
+ // when an earlier stage threw, and can mark the error handled.
830
+ let failure: unknown
831
+ let result: unknown
832
+ await this.#emit(this.#hooks.executing, (handler) => handler(instance, isMain))
833
+ try {
834
+ // Traced: an APM agent subscribes to `ream.command.exec` and sees every
835
+ // run without the kernel knowing it is watched. The payload is only built
836
+ // when somebody listens.
837
+ result = await (commandExec.hasSubscribers
838
+ ? commandExec.tracePromise(() => Promise.resolve(this.#executor.run(instance, this)), {
839
+ command: Command,
840
+ commandInstance: instance,
841
+ argv: parsed.args.map(String),
842
+ })
843
+ : this.#executor.run(instance, this))
844
+ } catch (err) {
845
+ failure = err
846
+ }
847
+
848
+ // Assigned before `completed()` so the hook can inspect them.
849
+ const ran = Object.assign(instance, { error: failure, result })
850
+
851
+ if (typeof ran.completed === 'function') {
852
+ try {
853
+ const handled = await invoke('completed')
854
+ if (handled === true) ran.error = undefined
855
+ } catch (err) {
856
+ // `completed` is part of the lifecycle, so a throw from it is an
857
+ // execution failure like any other — it must not escape and turn
858
+ // `exec()` into a rejection. When an earlier stage had already failed,
859
+ // that first error is kept as the `cause` rather than lost.
860
+ ran.error =
861
+ ran.error === undefined || !(err instanceof Error)
862
+ ? err
863
+ : Object.assign(err, { cause: err.cause ?? ran.error })
864
+ }
865
+ }
866
+
867
+ // Console's convention: a command that failed without setting its own code
868
+ // exits 1.
869
+ const scored = Object.assign(ran, {
870
+ exitCode: typeof ran.exitCode === 'number' ? ran.exitCode : ran.error === undefined ? 0 : 1,
871
+ })
872
+
873
+ // Only when it finished: Console runs `executed` after the executor returns, so
874
+ // a throw skips it. A hook counting successes must not see a failure —
875
+ // and `completed()` marking the error handled IS a finish.
876
+ if (scored.error === undefined) {
877
+ await this.#emit(this.#hooks.executed, (handler) => handler(scored, isMain))
878
+ }
879
+
880
+ // Attached rather than inherited: a command declared structurally by an
881
+ // agnostic package never extends BaseCommand, and `exec()` promises these
882
+ // to every caller. A class that defines its own keeps it — `Object.assign`
883
+ // only fills what is missing here because we spread ours first.
884
+ return Object.assign(scored, createAssertions(scored, ui), {
885
+ toJSON: createSnapshot(scored, Command.commandName, Command.options ?? {}),
886
+ })
887
+ }
888
+
889
+ /**
890
+ * Run a command programmatically — Console's `consoleApp.exec()`.
891
+ *
892
+ * It REJECTS when the command fails, as Console does: the error is recorded on
893
+ * the command (`error`, `exitCode` 1) and then rethrown, so a caller cannot
894
+ * mistake a failure for a success by forgetting to look. `process.exitCode`
895
+ * is left alone — only the command line owns that.
896
+ *
897
+ * To inspect a failing command instead of catching, build it with
898
+ * {@link create} and drive it yourself, or run it through `handle()` and read
899
+ * {@link getMainCommand}.
900
+ */
901
+ async exec(
902
+ commandName: string,
903
+ argv: readonly string[] = [],
904
+ options: { ui?: Ui } = {},
905
+ ): Promise<ExecutedCommand> {
906
+ if (this.#state === 'idle') await this.boot()
907
+ if (this.#state === 'completed') {
908
+ throw new ReamError(
909
+ 'E_CONSOLE_KERNEL_COMPLETED',
910
+ `The kernel has finished; "${commandName}" cannot be run through it.`,
911
+ { hint: 'Create a fresh kernel to execute more commands.' },
912
+ )
913
+ }
914
+
915
+ const expanded = this.#expandAlias([commandName, ...argv])
916
+ const [name, ...rest] = expanded
917
+ const Command = await this.find(name ?? commandName)
918
+
919
+ // A caller-supplied UI is what silences a command, or captures its output
920
+ // without touching the kernel's own (Console `exec(..., { ui })`).
921
+ const parsed = this.#parse(Command, rest, [], false)
922
+ this.#validate(Command, parsed)
923
+ const command = await this.#runCommand(Command, parsed, false, options.ui)
924
+ if (command.error !== undefined) throw command.error
925
+ return command
926
+ }
927
+
928
+ /**
929
+ * Build a command instance without running it (Console `kernel.create`).
930
+ *
931
+ * Parsed, injected and hydrated — everything `exec()` does up to the
932
+ * lifecycle. The escape hatch for driving a command by hand: run it with
933
+ * `command.exec()` and inspect it whatever it does.
934
+ */
935
+ async create<T extends CommandClass>(
936
+ Command: T,
937
+ argv: readonly string[] = [],
938
+ ): Promise<InstanceType<T>> {
939
+ if (this.#state === 'idle') await this.boot()
940
+ const { instance } = await this.#build(
941
+ Command,
942
+ this.#parse(Command, argv, [], false),
943
+ false,
944
+ this.ui,
945
+ )
946
+ // The instance IS of the class handed in — built by `new Command()` or by
947
+ // the container from that same class. Typing it as the structural contract
948
+ // would hide the command's own API from the caller who named it.
949
+ return instance as InstanceType<T>
950
+ }
951
+
952
+ /** Is this name — or alias — dispatchable? Console's `consoleApp.hasCommand()`. */
953
+ hasCommand(name: string): boolean {
954
+ const known = (commandName: string): boolean =>
955
+ this.#commands.has(commandName) || this.#pending.has(commandName)
956
+ if (known(name)) return true
957
+ const expansion = this.#aliases.get(name)
958
+ return expansion !== undefined && known(expansion[0] ?? '')
959
+ }
960
+
961
+ /**
962
+ * Give the command its application — booting first when it asked for it.
963
+ *
964
+ * When it did not, `this.app` is a throwing accessor rather than
965
+ * `undefined`: a command that resolves a service without declaring
966
+ * `startApp: true` gets a message naming the fix, instead of a
967
+ * "cannot read properties of undefined" ten frames deep.
968
+ */
969
+ /**
970
+ * Boot the application when the command asked for it, and hand it back so the
971
+ * command can be built through the container.
972
+ */
973
+ async #resolveApp(Command: CommandClass): Promise<Application | undefined> {
974
+ if (Command.options?.startApp !== true) return undefined
975
+
976
+ if (this.#startApp === undefined) {
977
+ throw new ReamError(
978
+ 'E_CONSOLE_NO_APP',
979
+ `"${Command.commandName}" requires a booted application, but this kernel was created without one.`,
980
+ { hint: 'Dispatch through the Ignitor console kernel rather than a bare Kernel instance.' },
981
+ )
982
+ }
983
+
984
+ return this.#startApp()
985
+ }
986
+
987
+ #attachApp(instance: CommandInstance, Command: CommandClass, app: Application | undefined): void {
988
+ if (app === undefined) {
989
+ Object.defineProperty(instance, 'app', {
990
+ configurable: true,
991
+ get: () => {
992
+ throw new ReamError(
993
+ 'E_CONSOLE_APP_NOT_STARTED',
994
+ `"${Command.commandName}" accessed this.app without booting the application.`,
995
+ { hint: `Add: static options: CommandOptions = { startApp: true }` },
996
+ )
997
+ },
998
+ })
999
+ return
1000
+ }
1001
+
1002
+ Object.assign(instance, { app })
1003
+ }
1004
+
1005
+ /**
1006
+ * Swap a leading alias for what it stands for. Only the first token is
1007
+ * considered: an alias is a command shorthand, not a general macro.
1008
+ */
1009
+ #expandAlias(argv: readonly string[]): readonly string[] {
1010
+ const [first, ...rest] = argv
1011
+ if (first === undefined) return argv
1012
+ const expansion = this.#aliases.get(first)
1013
+ return expansion === undefined ? argv : [...expansion, ...rest]
1014
+ }
1015
+
1016
+ /**
1017
+ * Run a hook's handlers in registration order.
1018
+ *
1019
+ * The caller passes the invocation rather than the arguments, which keeps
1020
+ * every hook's payload typed — a single variadic `emit` would need a cast to
1021
+ * correlate the hook name with its parameters.
1022
+ */
1023
+ async #emit<H>(handlers: readonly H[], invoke: (handler: H) => unknown): Promise<void> {
1024
+ for (const handler of handlers) await invoke(handler)
1025
+ }
1026
+
1027
+ /** The command's own flags, plus the global ones it did not redeclare. */
1028
+ #flagsFor(Command: CommandClass): FlagMetaData[] {
1029
+ const own = [...(Command.flags ?? [])]
1030
+ const declared = new Set(own.map((flag) => flag.flagName))
1031
+ return [...own, ...this.#globalFlags.filter((flag) => !declared.has(flag.flagName))]
1032
+ }
1033
+
1034
+ /**
1035
+ * Move a global flag written BEFORE the command name to after it.
1036
+ *
1037
+ * `ream --no-ansi list` has to work — that is where a user naturally puts a
1038
+ * CLI-wide switch — but the command name has to be the first token for the
1039
+ * registry lookup. Moving them keeps ONE parser for global and command flags
1040
+ * alike, instead of a second, hand-rolled scan.
1041
+ */
1042
+ #hoistGlobalFlags(argv: readonly string[]): string[] {
1043
+ const leading: string[] = []
1044
+ const rest: string[] = [...argv]
1045
+
1046
+ while (rest.length > 0) {
1047
+ const token = rest[0] as string
1048
+ if (token === '--' || !token.startsWith('-')) break
1049
+
1050
+ const name = token.replace(/^--?(no-)?/, '').split('=')[0] ?? ''
1051
+ const meta = this.#globalFlags.find(
1052
+ (flag) => flag.flagName === name || flag.alias.includes(name),
1053
+ )
1054
+ if (meta === undefined) break
1055
+
1056
+ leading.push(rest.shift() as string)
1057
+ // A value-taking flag written apart from its value takes the next token
1058
+ // with it, or the value would be read as the command name.
1059
+ if (meta.type !== 'boolean' && !token.includes('=') && rest[1] !== undefined) {
1060
+ leading.push(rest.shift() as string)
1061
+ }
1062
+ }
1063
+
1064
+ if (leading.length === 0) return [...argv]
1065
+ const [command, ...args] = rest
1066
+ return command === undefined ? leading : [command, ...args, ...leading]
1067
+ }
1068
+
1069
+ #reportUnknown(name: string): void {
1070
+ // Aliases are names the user can legitimately type, so a typo on one has to
1071
+ // be suggestible too.
1072
+ renderErrorWithSuggestions(this.logger, `Unknown command "${name}".`, [
1073
+ ...this.getCommandSuggestions(name).slice(0, 1),
1074
+ ])
1075
+ process.stderr.write(` Run "${this.#binaryName} list" to see available commands.\n`)
1076
+ process.exitCode = 1
1077
+ }
1078
+
1079
+ /**
1080
+ * `list` — commands grouped by namespace, as Console does.
1081
+ *
1082
+ * `namespaces` narrows the output to those groups; an empty list shows
1083
+ * everything. `make` selects `make:*`, and the empty string the commands that
1084
+ * have no namespace at all.
1085
+ */
1086
+ printList(asJson = false, namespaces: readonly string[] = []): void {
1087
+ // Off the metadata, not the classes: listing the commands must not import
1088
+ // the ones a loader has only announced.
1089
+ const all = this.#allMetadata()
1090
+ const commands =
1091
+ namespaces.length === 0
1092
+ ? all
1093
+ : all.filter((command) => namespaces.includes(command.namespace ?? ''))
1094
+
1095
+ if (asJson) {
1096
+ // The full metadata contract, not a three-field summary: tooling reads
1097
+ // this to build help, completions or a command palette.
1098
+ this.logger.log(JSON.stringify(commands, null, 2))
1099
+ return
1100
+ }
1101
+
1102
+ this.logger.log(`\n${colourise('Usage:', 'yellow')} ${this.#binaryName} <command> [options]\n`)
1103
+
1104
+ // What the CLI says about itself, when it was told anything (Console
1105
+ // `kernel.info`): binary name, framework and app versions.
1106
+ if (this.info.size > 0) {
1107
+ for (const [label, value] of this.info) {
1108
+ this.logger.log(` ${colourise(label, 'dim')} ${String(value)}`)
1109
+ }
1110
+ this.logger.log('')
1111
+ }
1112
+
1113
+ if (commands.length === 0) {
1114
+ this.logger.log(' No commands registered.\n')
1115
+ return
1116
+ }
1117
+
1118
+ const width = Math.max(...commands.map((command) => command.commandName.length))
1119
+ const groups = new Map<string, SerializedCommand[]>()
1120
+ for (const command of commands) {
1121
+ const group = command.namespace ?? ''
1122
+ const bucket = groups.get(group)
1123
+ if (bucket === undefined) groups.set(group, [command])
1124
+ else bucket.push(command)
1125
+ }
1126
+
1127
+ // Ungrouped commands first, then each namespace.
1128
+ for (const [group, entries] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
1129
+ this.logger.log(
1130
+ group === '' ? colourise('Available commands', 'yellow') : colourise(group, 'yellow'),
1131
+ )
1132
+ for (const command of entries) {
1133
+ const suffix =
1134
+ command.aliases.length === 0 ? '' : colourise(` [${command.aliases.join(', ')}]`, 'dim')
1135
+ this.logger.log(
1136
+ ` ${colourise(command.commandName.padEnd(width), 'green')} ${command.description}${suffix}`,
1137
+ )
1138
+ }
1139
+ this.logger.log('')
1140
+ }
1141
+ }
1142
+
1143
+ /**
1144
+ * Every alias pointing at this command — both `static aliases` and the rc
1145
+ * file's `commandsAliases`, which only differ in where they were declared.
1146
+ */
1147
+ #aliasesFor(commandName: string): string[] {
1148
+ const found: string[] = []
1149
+ for (const [alias, expansion] of this.#aliases) {
1150
+ if (expansion[0] === commandName) found.push(alias)
1151
+ }
1152
+ return found.sort((a, b) => a.localeCompare(b))
1153
+ }
1154
+
1155
+ /** Every command name the kernel knows, loaded or merely announced. */
1156
+ #names(): string[] {
1157
+ return [...new Set([...this.#commands.keys(), ...this.#pending.keys()])]
1158
+ }
1159
+
1160
+ /**
1161
+ * The metadata of every command, sorted by name.
1162
+ *
1163
+ * A loaded class wins over the announcement it came from: once imported, the
1164
+ * class IS the contract, and a stale manifest must not describe it.
1165
+ */
1166
+ #allMetadata(): SerializedCommand[] {
1167
+ const all = new Map<string, SerializedCommand>()
1168
+ for (const [commandName, pending] of this.#pending) {
1169
+ all.set(commandName, { ...pending.metadata, aliases: this.#aliasesFor(commandName) })
1170
+ }
1171
+ for (const command of this.#commands.values()) {
1172
+ all.set(command.commandName, this.#metadataFor(command))
1173
+ }
1174
+ return [...all.values()].sort((a, b) => a.commandName.localeCompare(b.commandName))
1175
+ }
1176
+
1177
+ /**
1178
+ * A command's metadata with the aliases the KERNEL knows about — a command
1179
+ * declares its own, but the rc file can add more, and a consumer reading the
1180
+ * metadata has no way to find those on its own.
1181
+ */
1182
+ #metadataFor(command: CommandClass): SerializedCommand {
1183
+ return { ...serializeCommand(command), aliases: this.#aliasesFor(command.commandName) }
1184
+ }
1185
+
1186
+ /** `<command> --help` — usage, arguments and flags for one command. */
1187
+ printCommandHelp(command: CommandClass | SerializedCommand): void {
1188
+ // Metadata is enough to describe a command, and it is what the `help`
1189
+ // command holds: explaining a command must not import it.
1190
+ const meta = typeof command === 'function' ? serializeCommand(command) : command
1191
+ const args = meta.args
1192
+ const flags = meta.flags
1193
+
1194
+ const usage = [this.#binaryName, meta.commandName]
1195
+ for (const arg of args) {
1196
+ const label = arg.type === 'spread' ? `...${arg.argumentName}` : arg.argumentName
1197
+ usage.push(arg.required ? `<${label}>` : `[${label}]`)
1198
+ }
1199
+ if (flags.length > 0) usage.push('[options]')
1200
+
1201
+ this.logger.log(`\n${colourise('Usage:', 'yellow')} ${usage.join(' ')}\n`)
1202
+ this.logger.log(` ${meta.description}\n`)
1203
+
1204
+ const help = meta.help
1205
+ if (help !== undefined) {
1206
+ // Console substitutes `{{ binaryName }}` so a help block can show a runnable
1207
+ // example without hardcoding the binary's name.
1208
+ for (const line of Array.isArray(help) ? help : [help]) {
1209
+ this.logger.log(` ${line.replace(/\{\{\s*binaryName\s*\}\}/g, this.#binaryName)}`)
1210
+ }
1211
+ this.logger.log('')
1212
+ }
1213
+
1214
+ if (args.length > 0) {
1215
+ this.logger.log(colourise('Arguments', 'yellow'))
1216
+ const width = Math.max(...args.map((arg) => arg.argumentName.length))
1217
+ for (const arg of args) {
1218
+ const notes = [
1219
+ arg.required ? undefined : 'optional',
1220
+ arg.default === undefined ? undefined : `default: ${String(arg.default)}`,
1221
+ ].filter((note) => note !== undefined)
1222
+ const suffix = notes.length > 0 ? colourise(` (${notes.join(', ')})`, 'dim') : ''
1223
+ this.logger.log(
1224
+ ` ${colourise(arg.argumentName.padEnd(width), 'green')} ${arg.description ?? ''}${suffix}`,
1225
+ )
1226
+ }
1227
+ this.logger.log('')
1228
+ }
1229
+
1230
+ // The command's own flags, then the global ones under their own heading —
1231
+ // a flag every command accepts but that no help mentions is a flag nobody
1232
+ // discovers.
1233
+ this.#printFlags('Options', flags)
1234
+ this.#printFlags('Global flags', this.#globalFlags)
1235
+ }
1236
+
1237
+ #printFlags(heading: string, flags: readonly FlagMetaData[]): void {
1238
+ if (flags.length > 0) {
1239
+ this.logger.log(colourise(heading, 'yellow'))
1240
+ const labels = flags.map((flag) => {
1241
+ const aliases = flag.alias.map((alias) => `-${alias}`).join(', ')
1242
+ // Booleans are always negatable; this only decides whether help says so.
1243
+ const negated =
1244
+ flag.type === 'boolean' && flag.showNegatedVariantInHelp === true
1245
+ ? ` | --no-${flag.flagName}`
1246
+ : ''
1247
+ return `${aliases === '' ? '' : `${aliases}, `}--${flag.flagName}${
1248
+ flag.type === 'boolean' ? '' : `=<${flag.type}>`
1249
+ }${negated}`
1250
+ })
1251
+ const width = Math.max(...labels.map((label) => label.length))
1252
+ flags.forEach((flag, index) => {
1253
+ const notes = [
1254
+ flag.required ? 'required' : undefined,
1255
+ flag.default === undefined ? undefined : `default: ${String(flag.default)}`,
1256
+ ].filter((note) => note !== undefined)
1257
+ const suffix = notes.length > 0 ? colourise(` (${notes.join(', ')})`, 'dim') : ''
1258
+ this.logger.log(
1259
+ ` ${colourise((labels[index] ?? '').padEnd(width), 'green')} ${flag.description ?? ''}${suffix}`,
1260
+ )
1261
+ })
1262
+ this.logger.log('')
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ /**
1268
+ * Split an alias expansion into argv tokens, honouring quotes.
1269
+ *
1270
+ * A plain `split(/\s+/)` breaks `make:controller --name "Blog Post"` into four
1271
+ * tokens instead of three, so any alias carrying a value with a space was
1272
+ * unrepresentable. Backslash escapes the next character.
1273
+ */
1274
+ function tokeniseExpansion(expansion: string): string[] {
1275
+ const tokens: string[] = []
1276
+ let current = ''
1277
+ let quote: '"' | "'" | undefined
1278
+ let started = false
1279
+
1280
+ for (let i = 0; i < expansion.length; i++) {
1281
+ const char = expansion[i] as string
1282
+
1283
+ if (char === '\\' && i + 1 < expansion.length) {
1284
+ current += expansion[++i]
1285
+ started = true
1286
+ continue
1287
+ }
1288
+ if (quote !== undefined) {
1289
+ if (char === quote) quote = undefined
1290
+ else current += char
1291
+ continue
1292
+ }
1293
+ if (char === '"' || char === "'") {
1294
+ quote = char
1295
+ started = true
1296
+ continue
1297
+ }
1298
+ if (/\s/.test(char)) {
1299
+ if (started) {
1300
+ tokens.push(current)
1301
+ current = ''
1302
+ started = false
1303
+ }
1304
+ continue
1305
+ }
1306
+ current += char
1307
+ started = true
1308
+ }
1309
+
1310
+ if (started) tokens.push(current)
1311
+ return tokens
1312
+ }
1313
+
1314
+ /**
1315
+ * A command's metadata, whether or not it extends `BaseCommand`.
1316
+ *
1317
+ * A class declared structurally by an agnostic package has no `serialize()`, so
1318
+ * the fields are read off the statics it does carry.
1319
+ */
1320
+ export function serializeCommand(command: CommandClass): SerializedCommand {
1321
+ const own = Reflect.get(command, 'serialize')
1322
+ if (typeof own === 'function') {
1323
+ const serialized: unknown = Reflect.apply(own, command, [])
1324
+ if (isSerialized(serialized)) return serialized
1325
+ }
1326
+
1327
+ return {
1328
+ commandName: command.commandName,
1329
+ namespace: namespaceOf(command.commandName),
1330
+ description: command.description,
1331
+ help: command.help,
1332
+ aliases: [...(command.aliases ?? [])],
1333
+ options: { ...command.options },
1334
+ args: (command.args ?? []).map(({ parse: _p, ...rest }) => rest),
1335
+ flags: (command.flags ?? []).map(({ parse: _p, ...rest }) => rest),
1336
+ }
1337
+ }
1338
+
1339
+ /** `make:controller` → `make`; a name without a colon has no namespace. */
1340
+ function namespaceOf(commandName: string): string | null {
1341
+ const colon = commandName.indexOf(':')
1342
+ return colon === -1 ? null : commandName.slice(0, colon)
1343
+ }
1344
+
1345
+ function isSerialized(value: unknown): value is SerializedCommand {
1346
+ return typeof value === 'object' && value !== null && 'commandName' in value
1347
+ }
1348
+
1349
+ /**
1350
+ * The candidates close enough to `input` to be worth suggesting, nearest first.
1351
+ *
1352
+ * One ranking, used both by the "Did you mean?" line and by the public
1353
+ * suggestion methods — two implementations would eventually disagree about
1354
+ * what counts as close.
1355
+ */
1356
+ function suggestFrom(input: string, candidates: readonly string[]): string[] {
1357
+ const threshold = Math.max(2, Math.floor(input.length / 3))
1358
+ return candidates
1359
+ .map((candidate) => ({ candidate, score: distance(input, candidate) }))
1360
+ .filter(({ score }) => score <= threshold)
1361
+ .sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
1362
+ .map(({ candidate }) => candidate)
1363
+ }
1364
+
1365
+ function distance(a: string, b: string): number {
1366
+ const rows = a.length + 1
1367
+ const cols = b.length + 1
1368
+ let previous = Array.from({ length: cols }, (_, index) => index)
1369
+
1370
+ for (let i = 1; i < rows; i++) {
1371
+ const current = [i]
1372
+ for (let j = 1; j < cols; j++) {
1373
+ const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
1374
+ const insertion = (current[j - 1] ?? 0) + 1
1375
+ const deletion = (previous[j] ?? 0) + 1
1376
+ current[j] = Math.min(substitution, insertion, deletion)
1377
+ }
1378
+ previous = current
1379
+ }
1380
+
1381
+ return previous[cols - 1] ?? 0
1382
+ }