@c9up/ream 0.1.26 → 0.1.27

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 +356 -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 +548 -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
package/src/Ignitor.ts CHANGED
@@ -14,9 +14,13 @@
14
14
  */
15
15
 
16
16
  import { Application } from './Application.js'
17
+ import type { Console } from './console/Console.js'
18
+ import type { CommandLoader, Kernel as ConsoleKernelInstance } from './console/Kernel.js'
19
+ import { type CommandClass, isCommandClass } from './console/types.js'
17
20
  import type { ErrorEvent } from './ErrorBoundary.js'
18
21
  import { ErrorBoundary } from './ErrorBoundary.js'
19
22
  import { loadEnvFiles } from './env/loadEnvFiles.js'
23
+ import { prettyPrintError } from './errors/prettyPrintError.js'
20
24
  import { ReamError } from './errors/ReamError.js'
21
25
  import type { Emitter } from './events/Emitter.js'
22
26
  import type { ShutdownHandle } from './GracefulShutdown.js'
@@ -59,12 +63,84 @@ export interface ReamrcConfig {
59
63
  }
60
64
  >
61
65
  commands?: Array<() => Promise<unknown>>
66
+ /**
67
+ * Command shorthands — Console's `commandsAliases`. The value is the command the
68
+ * alias stands for, flags included:
69
+ * `{ resource: 'make:controller --resource' }`
70
+ */
71
+ commandsAliases?: Record<string, string>
62
72
  modules?: {
63
73
  /** Path to the modules directory (relative to app root). Default: './app/modules' */
64
74
  path?: string
65
75
  /** Auto-loaded files in each module directory. Default: ['routes'] */
66
76
  autoload?: string[]
67
77
  }
78
+ /** Test suites and runner settings — the `tests` block of adonisrc.ts. */
79
+ tests?: TestsConfig
80
+ }
81
+
82
+ /** One test suite, as declared in the rc file (AdonisJS `tests.suites[]`). */
83
+ export interface TestSuiteConfig {
84
+ /** Suite name — what `ream test <name>` selects. */
85
+ name: string
86
+ /**
87
+ * The suite's files: a glob, several, or a callback returning their URLs
88
+ * (Japa `TestFiles`).
89
+ */
90
+ files: string | string[] | (() => URL[] | Promise<URL[]>)
91
+ /** Per-test timeout for this suite, in ms. */
92
+ timeout?: number
93
+ /** Extra attempts on failure for this suite. */
94
+ retries?: number
95
+ /**
96
+ * Configure the suite before it runs (Japa `TestSuite.configure`). Receives
97
+ * the same handle as the bootstrap's `configureSuite`, and runs after it.
98
+ *
99
+ * Costs an import of the rc file in every worker, since a function cannot
100
+ * cross a process boundary — `configureSuite` in `tests/bootstrap.ts` does
101
+ * the same job for free, and is what AdonisJS itself uses.
102
+ */
103
+ configure?: (suite: TestSuiteHandle) => void
104
+ }
105
+
106
+ /**
107
+ * What a suite's `configure` receives — helix's `SuiteHandle`, re-declared here
108
+ * so the rc file types without ream depending on the runner at type level.
109
+ */
110
+ export interface TestSuiteHandle {
111
+ readonly name: string
112
+ setup(fn: () => void | Promise<void>): TestSuiteHandle
113
+ teardown(fn: () => void | Promise<void>): TestSuiteHandle
114
+ onTest(callback: (test: unknown) => void): TestSuiteHandle
115
+ onGroup(callback: (group: unknown) => void): TestSuiteHandle
116
+ bail(toggle?: boolean): TestSuiteHandle
117
+ }
118
+
119
+ /**
120
+ * The `tests` block of the rc file — AdonisJS `adonisrc.ts` `tests`, field for
121
+ * field. The runner that consumes it is helix; ream reads the file, exactly as
122
+ * `@adonisjs/core` reads adonisrc and hands the suites to Japa.
123
+ */
124
+ export interface TestsConfig {
125
+ /** Named suites. `ream test` with no argument runs them all, in order. */
126
+ suites?: TestSuiteConfig[]
127
+ /** Default per-test timeout, in ms. */
128
+ timeout?: number
129
+ /** `process.exit()` once the run ends instead of draining the event loop. */
130
+ forceExit?: boolean
131
+ /**
132
+ * Ream particularity: the bootstrap module's path. AdonisJS hardcodes
133
+ * `tests/bootstrap.ts`; that stays the default here, and this overrides it.
134
+ */
135
+ bootstrap?: string
136
+ /**
137
+ * Point `@japa/runner/core` at helix's shim in every worker, so official Japa
138
+ * plugins (`@japa/assert`, …) instrument the runner that is actually running.
139
+ *
140
+ * Off by default: redirecting a package specifier is not something to do
141
+ * behind a user's back, and a project with no Japa plugin gains nothing.
142
+ */
143
+ japaPlugins?: boolean
68
144
  }
69
145
 
70
146
  /** defineConfig helper — like AdonisJS defineConfig(). */
@@ -134,6 +210,7 @@ export class Ignitor {
134
210
  private phase: 'created' | 'registered' | 'booted' | 'started' | 'ready' | 'shutdown' = 'created'
135
211
  private hotReloadCleanup?: () => void
136
212
  #shutdownHandle?: ShutdownHandle
213
+ #console?: Console
137
214
 
138
215
  // Inline configuration (for simple use or testing)
139
216
  private inlineRoutes?: (router: Router) => void
@@ -311,6 +388,12 @@ export class Ignitor {
311
388
  await this.phaseRegister()
312
389
  await this.phaseBoot()
313
390
  await this.phaseStart()
391
+ // Console documents its `consoleApp` service as available once the application has
392
+ // booted, so the locator is installed here rather than on first use —
393
+ // `import consoleApp from '@c9up/ream/services/console'` must not throw in a running
394
+ // app. Only the façade is built (three small modules); loading the
395
+ // commands themselves stays lazy, inside `consoleApp.boot()`.
396
+ await this.consoleApp()
314
397
  await this.phaseReady()
315
398
  } catch (err) {
316
399
  // A throw mid-boot (e.g. a provider ready() failing AFTER the HTTP port
@@ -329,7 +412,7 @@ export class Ignitor {
329
412
 
330
413
  /**
331
414
  * Load `.env` files into `process.env` before config and providers boot —
332
- * mirroring AdonisJS, which loads env in BOTH the HTTP and the ace (console)
415
+ * mirroring AdonisJS, which loads env in BOTH the HTTP and the consoleApp (console)
333
416
  * flows. Without this, `ream migrate` and other console commands booted in a
334
417
  * clean subprocess never saw the `.env` the web server got from the shell.
335
418
  *
@@ -635,6 +718,20 @@ export class Ignitor {
635
718
  */
636
719
  async stop(): Promise<void> {
637
720
  const errors: unknown[] = []
721
+
722
+ // Release the consoleApp locator first — ownership-guarded, so a second Ignitor
723
+ // having rebound it is left alone.
724
+ if (this.#console !== undefined) {
725
+ const consoleApp = this.#console
726
+ this.#console = undefined
727
+ try {
728
+ const { clearConsole } = await import('./services/console.js')
729
+ clearConsole(consoleApp)
730
+ } catch {
731
+ /* the module never loaded — nothing to clear */
732
+ }
733
+ }
734
+
638
735
  const attempt = async (step: () => void | Promise<void>): Promise<void> => {
639
736
  try {
640
737
  await step()
@@ -683,6 +780,51 @@ export class Ignitor {
683
780
  return this.app
684
781
  }
685
782
 
783
+ /**
784
+ * The application root passed to `new Ignitor(new URL('../', import.meta.url))`.
785
+ * Undefined for inline/test ignitors created without one.
786
+ */
787
+ getAppRoot(): URL | undefined {
788
+ return this.appRoot
789
+ }
790
+
791
+ /** Has the application already been started? */
792
+ isStarted(): boolean {
793
+ return this.phase === 'started' || this.phase === 'ready'
794
+ }
795
+
796
+ /**
797
+ * The `consoleApp` façade — the programmatic console.
798
+ *
799
+ * Built on demand: the console modules are only imported when something
800
+ * actually reaches for them, so an HTTP-only boot does not pay for the CLI.
801
+ */
802
+ async consoleApp(): Promise<Console> {
803
+ if (this.#console !== undefined) return this.#console
804
+
805
+ const [{ Console }, { setConsole }] = await Promise.all([
806
+ import('./console/Console.js'),
807
+ import('./services/console.js'),
808
+ ])
809
+ const { kernel, load } = await new ConsoleKernel(this).build()
810
+ const consoleApp = new Console({ kernel, load })
811
+
812
+ this.#console = consoleApp
813
+ setConsole(consoleApp)
814
+ this.app.container.singleton('console', () => consoleApp)
815
+ return consoleApp
816
+ }
817
+
818
+ /** The loaded rc file, if `useRcFile()` was called. */
819
+ getRcFile(): ReamrcConfig | undefined {
820
+ return this.reamrc
821
+ }
822
+
823
+ /** The custom module importer, when the app provided one. */
824
+ getImporter(): ((filePath: string) => Promise<unknown>) | undefined {
825
+ return this.config.importer
826
+ }
827
+
686
828
  getRouter(): Router {
687
829
  return this.router
688
830
  }
@@ -744,65 +886,221 @@ export class Ignitor {
744
886
  }
745
887
  }
746
888
 
747
- /**
748
- * Pretty-print an error (like AdonisJS prettyPrintError).
749
- */
750
- export function prettyPrintError(error: unknown): void {
751
- if (error instanceof ReamError) {
752
- console.error(error.toDevString())
753
- } else if (error instanceof Error) {
754
- console.error(`\n ${error.message}\n`)
755
- if (error.stack) console.error(error.stack)
756
- } else {
757
- console.error(error)
758
- }
759
- }
889
+ export { prettyPrintError }
760
890
 
761
891
  /**
762
- * ConsoleKernel — handles CLI command dispatching.
763
- * Like AdonisJS: new Ignitor(...).console().handle(process.argv.splice(2))
892
+ * ConsoleKernel — boots the app in console mode and dispatches a command.
893
+ *
894
+ * Console parity: `new Ignitor(...).console().handle(process.argv.slice(2))`.
895
+ *
896
+ * Commands come from two places, as in Console:
897
+ * 1. the app's own `commands/` directory, discovered automatically;
898
+ * 2. `reamrc.commands[]`, which registers commands shipped by packages.
899
+ *
900
+ * The application is booted lazily — only for a command declaring
901
+ * `options.startApp`. A generator has no reason to open a database
902
+ * connection, and paying the boot cost on every `--help` is worse than
903
+ * pointless: it fails on a machine where the DB is simply not running.
764
904
  */
765
905
  export class ConsoleKernel {
766
- private ignitor: Ignitor
906
+ #ignitor: Ignitor
907
+ #started = false
767
908
 
768
909
  constructor(ignitor: Ignitor) {
769
- this.ignitor = ignitor
910
+ this.#ignitor = ignitor
770
911
  }
771
912
 
772
- async handle(argv: string[]): Promise<void> {
773
- await this.ignitor.start()
774
- const { CommandRunner } = await import('./console/CommandRunner.js')
775
- const runner = new CommandRunner()
776
-
777
- // Auto-register commands from reamrc
778
- const reamrc = (this.ignitor as unknown as { reamrc?: ReamrcConfig }).reamrc
779
- if (reamrc?.commands) {
780
- for (const commandImport of reamrc.commands) {
781
- const mod = await commandImport()
782
- const cmd = (
783
- mod as {
784
- default: {
785
- name: string
786
- description: string
787
- run: (args: string[], flags: Record<string, string | boolean>) => Promise<void>
788
- }
789
- }
790
- ).default
791
- if (cmd?.name && typeof cmd?.run === 'function') {
792
- runner.register(cmd)
913
+ /**
914
+ * Build the console kernel and the function that fills it.
915
+ *
916
+ * Shared by `handle()` (the CLI) and the `consoleApp` façade (programmatic use), so
917
+ * both see exactly the same commands — discovery plus `reamrc.commands` —
918
+ * instead of two loaders drifting apart.
919
+ */
920
+ async build(): Promise<{ kernel: ConsoleKernelInstance; load: () => Promise<void> }> {
921
+ const { Kernel } = await import('./console/Kernel.js')
922
+
923
+ const kernel = new Kernel({
924
+ binaryName: 'ream',
925
+ // A `staysAlive` command ends itself through `this.terminate()`; the
926
+ // kernel deliberately does not tear the app down for those.
927
+ onTerminate: async () => {
928
+ if (this.#started) await this.#ignitor.stop()
929
+ },
930
+ startApp: async () => {
931
+ // The façade can be used from an already-running application; starting
932
+ // it a second time would re-register providers.
933
+ if (!this.#ignitor.isStarted()) {
934
+ await this.#ignitor.start()
935
+ this.#started = true
936
+ }
937
+ const app = this.#ignitor.getApp()
938
+ app.container.singleton('consoleKernel', () => kernel)
939
+ return app
940
+ },
941
+ })
942
+
943
+ // Discovery is registered as a LOADER, not run beside the kernel: `boot()`
944
+ // is then the single moment commands come in, whether they were found on
945
+ // disk, declared in the rc file, or added by an application's own loader.
946
+ kernel.addLoader(this.#commandLoader(kernel))
947
+
948
+ const load = async (): Promise<void> => {
949
+ const aliases = this.#ignitor.getRcFile()?.commandsAliases
950
+ if (aliases !== undefined) {
951
+ for (const [alias, expansion] of Object.entries(aliases)) {
952
+ kernel.addAlias(alias, expansion)
793
953
  }
794
954
  }
955
+ await kernel.boot()
795
956
  }
796
957
 
797
- this.ignitor.getApp().container.singleton('console', () => runner)
958
+ return { kernel, load }
959
+ }
960
+
961
+ async handle(argv: string[]): Promise<void> {
962
+ const { kernel, load } = await this.build()
963
+ await load()
964
+
965
+ let staysAlive = false
798
966
  try {
799
- await runner.handle(argv)
967
+ // `process.execArgv` IS what node was started with — Console's `nodeArgs`,
968
+ // which a command reads to know how the process was launched.
969
+ const result = await kernel.handle(argv, process.execArgv)
970
+ staysAlive = result.staysAlive
800
971
  } finally {
801
972
  // A throwing command must still release the Ignitor's resources
802
973
  // (scheduler tickers, watchers) — otherwise the CLI process hangs.
803
- await this.ignitor.stop()
974
+ // A `staysAlive` command keeps them on purpose.
975
+ if (this.#started && !staysAlive) await this.#ignitor.stop()
976
+ }
977
+ }
978
+
979
+ /**
980
+ * The application's commands, as a loader the kernel consumes at boot.
981
+ *
982
+ * Two sources, one collection: the `commands/` directory and the rc file's
983
+ * `commands[]`. Collecting them together is what lets a command listed in
984
+ * both be reported as the duplicate it is.
985
+ */
986
+ #commandLoader(kernel: ConsoleCommandKernel): CommandLoader {
987
+ const found = new Map<string, CommandClass>()
988
+
989
+ return {
990
+ getMetaData: async () => {
991
+ await this.#discoverAppCommands(kernel, found)
992
+ await this.#loadPackageCommands(found)
993
+ // Imported here, not at the top of the file: the console stack is only
994
+ // loaded for a console dispatch, and a static import would pull it into
995
+ // every HTTP boot.
996
+ const { serializeCommand } = await import('./console/Kernel.js')
997
+ return [...found.values()].map(serializeCommand)
998
+ },
999
+ getCommand: async (metadata) => found.get(metadata.commandName) ?? null,
804
1000
  }
805
1001
  }
1002
+
1003
+ /**
1004
+ * Load every command in the app's `commands/` directory.
1005
+ *
1006
+ * Tolerant by design: a module that does not default-export a command is
1007
+ * reported and skipped rather than aborting the whole CLI. Discovery scans a
1008
+ * conventional directory, so a stray helper file there must not make
1009
+ * `ream list` unusable. Explicitly declared entries (`reamrc.commands`) are
1010
+ * held to a stricter standard — see {@link #loadPackageCommands}.
1011
+ */
1012
+ async #discoverAppCommands(
1013
+ kernel: ConsoleCommandKernel,
1014
+ found: Map<string, CommandClass>,
1015
+ ): Promise<void> {
1016
+ const root = this.#ignitor.getAppRoot()
1017
+ if (root === undefined) return
1018
+
1019
+ // The framework's own `FsLoader`, with the application's importer plugged
1020
+ // in: scanning a directory for commands is written once, and an app that
1021
+ // compiles its sources differently only replaces the import.
1022
+ const { FsLoader } = await import('./console/loaders.js')
1023
+ const importModule = (file: URL): Promise<unknown> => this.#importModule(file)
1024
+
1025
+ const loader = new (class extends FsLoader {
1026
+ protected override import(file: URL): Promise<unknown> {
1027
+ return importModule(file)
1028
+ }
1029
+ })(new URL('commands/', root)).onSkipped((fileName) => {
1030
+ // Tolerant by design: discovery scans a conventional directory, so a
1031
+ // stray helper there must not make `ream list` unusable.
1032
+ kernel.logger.warning(`Skipped ${fileName} in commands/ — no default-exported command class.`)
1033
+ })
1034
+
1035
+ for (const metadata of await loader.getMetaData()) {
1036
+ const command = await loader.getCommand(metadata)
1037
+ if (command !== null) found.set(command.commandName, command)
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Register commands declared in `reamrc.commands[]` — the channel for
1043
+ * commands shipped by packages, which discovery cannot see.
1044
+ *
1045
+ * Strict: an entry listed by hand that does not resolve to a command is a
1046
+ * configuration error, and silently ignoring it is how a command "disappears"
1047
+ * with no explanation.
1048
+ */
1049
+ async #loadPackageCommands(found: Map<string, CommandClass>): Promise<void> {
1050
+ const declared = this.#ignitor.getRcFile()?.commands
1051
+ if (!declared) return
1052
+
1053
+ for (const entry of declared) {
1054
+ const command = commandOf(await entry())
1055
+ if (command === undefined) {
1056
+ throw new ReamError(
1057
+ 'E_CONSOLE_INVALID_COMMAND',
1058
+ 'An entry of reamrc.ts `commands` does not default-export a command class.',
1059
+ {
1060
+ hint: 'Expected `export default class X { static commandName = "..."; static description = "..."; run() {} }`.',
1061
+ },
1062
+ )
1063
+ }
1064
+
1065
+ // The most likely duplicate after the move to auto-discovery: a command
1066
+ // living in `commands/` AND still listed by hand. Say so, rather than
1067
+ // leaving the user to guess which two registrations collided.
1068
+ const existing = found.get(command.commandName)
1069
+ if (existing !== undefined && existing !== command) {
1070
+ throw new ReamError(
1071
+ 'E_CONSOLE_DUPLICATE_COMMAND',
1072
+ `Two commands claim the name "${command.commandName}".`,
1073
+ {
1074
+ hint: 'Commands in commands/ are discovered automatically — remove this entry from reamrc.ts `commands`, which is only for commands shipped by packages.',
1075
+ },
1076
+ )
1077
+ }
1078
+ found.set(command.commandName, command)
1079
+ }
1080
+ }
1081
+
1082
+ /** Honour the app's custom importer when it provided one. */
1083
+ async #importModule(file: URL): Promise<unknown> {
1084
+ const importer = this.#ignitor.getImporter()
1085
+ if (importer === undefined) return import(file.href)
1086
+
1087
+ const root = this.#ignitor.getAppRoot()
1088
+ const relative = root === undefined ? file.href : `./${file.href.slice(root.href.length)}`
1089
+ return importer(relative)
1090
+ }
1091
+ }
1092
+
1093
+ /** The slice of the console kernel this class drives. */
1094
+ interface ConsoleCommandKernel {
1095
+ addAlias(alias: string, expansion: string): unknown
1096
+ logger: { warning(message: string): void }
1097
+ }
1098
+
1099
+ /** The command a module default-exports, or undefined when it exports none. */
1100
+ function commandOf(mod: unknown): CommandClass | undefined {
1101
+ if (typeof mod !== 'object' || mod === null) return undefined
1102
+ const value = Reflect.get(mod, 'default')
1103
+ return isCommandClass(value) ? value : undefined
806
1104
  }
807
1105
 
808
1106
  async function findAvailablePort(desired: number): Promise<number> {