@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
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Console command contract — the STATIC shape the kernel dispatches against.
3
+ *
4
+ * Console parity: a command is a class carrying its own metadata as statics
5
+ * (`commandName`, `description`, `options`) plus the argument/flag metadata
6
+ * collected by the `@args` / `@flags` decorators. The kernel never requires the
7
+ * class to extend {@link BaseCommand}: ANY class exposing these statics is
8
+ * dispatchable.
9
+ *
10
+ * That is deliberate. Framework-agnostic packages (atlas and friends) must not
11
+ * import `@c9up/ream` — the same constraint that made the old `AtlasCommand`
12
+ * duck-type the `{ name, description, run }` object shape. Keeping the contract
13
+ * on statics lets those packages declare classes with typed args/flags and stay
14
+ * dependency-free.
15
+ */
16
+
17
+ /** Per-command behaviour switches. Mirrors Console's `CommandOptions`. */
18
+ export interface CommandOptions {
19
+ /**
20
+ * Boot the application (providers, DB, container) before `run()`.
21
+ *
22
+ * Defaults to `false`, as in Console: a scaffolding command has no business
23
+ * opening a database connection. Commands that resolve services from the
24
+ * container must opt in.
25
+ */
26
+ startApp?: boolean
27
+
28
+ /**
29
+ * Keep the process alive after `run()` resolves (long-running commands such
30
+ * as a queue worker). Defaults to `false` — the kernel tears the app down and
31
+ * lets the process exit.
32
+ */
33
+ staysAlive?: boolean
34
+
35
+ /**
36
+ * Accept flags the command did not declare instead of failing. Off by
37
+ * default so a typo (`--warm` for `--warn`) is reported, not swallowed.
38
+ */
39
+ allowUnknownFlags?: boolean
40
+ }
41
+
42
+ /** Metadata recorded by an `@args.*` decorator. */
43
+ export interface ArgumentMetaData {
44
+ type: 'string' | 'spread'
45
+ /** Class property the parsed value is assigned to. */
46
+ propertyName: string
47
+ /** Name shown in help output (defaults to the dash-cased property name). */
48
+ argumentName: string
49
+ description?: string
50
+ required: boolean
51
+ default?: string | string[]
52
+ /**
53
+ * Accept an empty value (`ream greet ""`). Rejected by default, as in Console: an
54
+ * empty positional is nearly always a shell variable that did not expand.
55
+ */
56
+ allowEmptyValue?: boolean
57
+ /** Transforms/validates the raw value before it is assigned (Console `parse`). */
58
+ parse?: (value: string | string[]) => unknown
59
+ }
60
+
61
+ /** Metadata recorded by a `@flags.*` decorator. */
62
+ export interface FlagMetaData {
63
+ type: 'string' | 'boolean' | 'number' | 'array'
64
+ /** Class property the parsed value is assigned to. */
65
+ propertyName: string
66
+ /** Name used on the command line (defaults to the dash-cased property name). */
67
+ flagName: string
68
+ description?: string
69
+ /** Single-character shorthands, e.g. `['r']` for `-r`. */
70
+ alias: string[]
71
+ default?: string | string[] | number | boolean
72
+ required: boolean
73
+ /**
74
+ * Accept `--name` with nothing behind it, as an empty value rather than an
75
+ * error (Console `allowEmptyValue`).
76
+ */
77
+ allowEmptyValue?: boolean
78
+ /** Transforms/validates the coerced value before it is assigned (Console `parse`). */
79
+ parse?: (value: string | string[] | number | boolean) => unknown
80
+ /** List `--no-<flag>` alongside the flag in help (booleans only). */
81
+ showNegatedVariantInHelp?: boolean
82
+ }
83
+
84
+ /** What the kernel produces from argv and assigns onto the command instance. */
85
+ /**
86
+ * The parsed CLI input, in the shape Console publishes it.
87
+ *
88
+ * Positionals are a LIST, in declaration order, and flags are keyed by their
89
+ * COMMAND-LINE name — `parsed.flags['user-email']`, not `parsed.flags.userEmail`.
90
+ * The property names are what the values are assigned TO on the command; the
91
+ * parsed bag describes what was typed.
92
+ */
93
+ export interface ParsedInput {
94
+ args: unknown[]
95
+ flags: Record<string, unknown>
96
+ /**
97
+ * Names of the flags that were passed but not declared (Console
98
+ * `this.parsed.unknownFlags`). Their values are in {@link flags}; this is the
99
+ * list a command inspects to know what it was handed but does not know about.
100
+ */
101
+ unknownFlags: string[]
102
+ /**
103
+ * Positionals beyond what the command declared.
104
+ *
105
+ * Normally none: an undeclared positional is an error. A command running with
106
+ * `allowUnknownFlags` is a proxy, so they are kept here instead of being
107
+ * dropped — forwarding them is the whole point of such a command.
108
+ */
109
+ extraArgs: string[]
110
+ /** Console's name for {@link extraArgs} — the same array, not a copy. */
111
+ _: string[]
112
+ /**
113
+ * The arguments node itself was started with (Console `nodeArgs`), filled only
114
+ * for the command invoked from the command line.
115
+ */
116
+ nodeArgs: string[]
117
+ }
118
+
119
+ /**
120
+ * The instance side of a command.
121
+ *
122
+ * Only `run()` is required. The lifecycle hooks are optional: the kernel calls
123
+ * whichever the command defines, in Console's order — prepare, interact, run,
124
+ * completed.
125
+ */
126
+ export interface CommandInstance {
127
+ /**
128
+ * Whatever it returns becomes `result` on the executed command.
129
+ *
130
+ * Parameters are allowed: the container injects them (Console `@inject()` on a
131
+ * lifecycle method), and a no-arg signature would reject those commands.
132
+ */
133
+ run(...args: never[]): unknown
134
+ prepare?(...args: never[]): void | Promise<void>
135
+ interact?(...args: never[]): void | Promise<void>
136
+ /** Returning `true` marks the error handled, stopping its propagation. */
137
+ // biome-ignore lint/suspicious/noConfusingVoidType: Console contract — returning `true` marks the error handled, returning nothing is the normal case
138
+ completed?(...args: never[]): boolean | void | Promise<boolean | void>
139
+ /** Set by the command to force a code; the kernel fills it in otherwise. */
140
+ exitCode?: number
141
+ /** Filled by the kernel before `completed()` runs. */
142
+ error?: unknown
143
+ /** Filled by the kernel with what `run()` returned. */
144
+ result?: unknown
145
+ }
146
+
147
+ /**
148
+ * A command's metadata as plain data (Console `serialize`).
149
+ *
150
+ * JSON-safe on purpose: the `parse` callbacks carried by the live metadata are
151
+ * dropped, so this can be printed, cached or sent over a wire without a
152
+ * function silently disappearing at the boundary.
153
+ */
154
+ /**
155
+ * How one positional argument is parsed (Console `ArgumentsParserOptions`).
156
+ *
157
+ * Derived from the declarations by `BaseCommand.getParserOptions()`. Ream's own
158
+ * parser reads the declarations directly; this shape exists so code ported from
159
+ * Console — which inspects it — keeps working.
160
+ */
161
+ export interface ArgumentsParserOptions {
162
+ type: 'string' | 'spread'
163
+ default?: string | string[]
164
+ parse?: (value: string | string[]) => unknown
165
+ }
166
+
167
+ /** How the flags are parsed, grouped by type (Console `FlagsParserOptions`). */
168
+ export interface FlagsParserOptions {
169
+ all: string[]
170
+ string: string[]
171
+ boolean: string[]
172
+ number: string[]
173
+ array: string[]
174
+ /** Flag name → its single-character shorthands. */
175
+ alias: Record<string, string[]>
176
+ count: string[]
177
+ /** Flag name → its `parse` callback. */
178
+ coerce: Record<string, (value: never) => unknown>
179
+ default: Record<string, unknown>
180
+ }
181
+
182
+ export interface SerializedCommand {
183
+ commandName: string
184
+ /**
185
+ * Where the command was found, relative to the directory it was loaded from.
186
+ *
187
+ * Set by loaders that read files, and by nothing else: it is what lets a
188
+ * generated manifest import a command without scanning anything.
189
+ */
190
+ filePath?: string
191
+ /** The part before `:` — `make` for `make:controller`, null when there is none. */
192
+ namespace: string | null
193
+ description: string
194
+ help?: string | string[]
195
+ aliases: string[]
196
+ options: CommandOptions
197
+ args: Array<Omit<ArgumentMetaData, 'parse'>>
198
+ flags: Array<Omit<FlagMetaData, 'parse'>>
199
+ }
200
+
201
+ /**
202
+ * A snapshot of a command that has run (Console `toJSON`).
203
+ *
204
+ * Unlike {@link SerializedCommand} this is about one execution: the values it
205
+ * received and what it produced. `args` / `flags` are the parsed VALUES, not
206
+ * their declarations.
207
+ */
208
+ export interface CommandSnapshot {
209
+ commandName: string
210
+ options: CommandOptions
211
+ /** Positional VALUES in declaration order — Console exposes a list here. */
212
+ args: unknown[]
213
+ flags: Record<string, unknown>
214
+ error: unknown
215
+ result: unknown
216
+ exitCode: number | undefined
217
+ }
218
+
219
+ /**
220
+ * A command that has run.
221
+ *
222
+ * The three outcome fields are guaranteed here, which is what `exec()` returns:
223
+ * without it the documented `command.exitCode` / `.result` / `.error` does not
224
+ * typecheck for a caller, and the only way to read them is a reflective escape
225
+ * hatch — which is exactly what the tests had ended up doing.
226
+ */
227
+ /**
228
+ * Assertions available on a command that has run.
229
+ *
230
+ * Part of what `exec()` returns, so a caller can use them without the type
231
+ * hiding what exists at runtime. The kernel attaches them to every command it
232
+ * runs, including one that does not extend `BaseCommand`.
233
+ */
234
+ export interface CommandAssertions {
235
+ assertSucceeded(): void
236
+ assertFailed(): void
237
+ assertExitCode(expected: number): void
238
+ assertNotExitCode(unexpected: number): void
239
+ assertLog(message: string, stream?: 'stdout' | 'stderr'): void
240
+ assertLogMatches(pattern: RegExp, stream?: 'stdout' | 'stderr'): void
241
+ assertTableRows(expected: readonly (readonly string[])[]): void
242
+ }
243
+
244
+ export interface ExecutedCommand extends CommandInstance, CommandAssertions {
245
+ exitCode: number
246
+ error: unknown
247
+ result: unknown
248
+ /** A snapshot of this execution (Console `toJSON`). */
249
+ toJSON(): CommandSnapshot
250
+ }
251
+
252
+ /**
253
+ * The static side. `args` / `flags` are optional so a command with neither
254
+ * still satisfies the contract without declaring empty arrays.
255
+ */
256
+ export interface CommandClass {
257
+ // Not `new ()`: a command built through the container receives its injected
258
+ // dependencies, and a zero-arg signature rejects every such class.
259
+ new (...args: never[]): CommandInstance
260
+ commandName: string
261
+ description: string
262
+ /** Alternative names the command also answers to (Console `static aliases`). */
263
+ aliases?: readonly string[]
264
+ options?: CommandOptions
265
+ args?: readonly ArgumentMetaData[]
266
+ flags?: readonly FlagMetaData[]
267
+ /** Longer help text printed under the usage line by `<cmd> --help`. */
268
+ help?: string | string[]
269
+ }
270
+
271
+ /** Narrowing guard used by the kernel when loading modules. */
272
+ export function isCommandClass(value: unknown): value is CommandClass {
273
+ if (typeof value !== 'function') return false
274
+ const candidate = value as Partial<CommandClass>
275
+ return (
276
+ typeof candidate.commandName === 'string' &&
277
+ candidate.commandName.length > 0 &&
278
+ typeof candidate.description === 'string'
279
+ )
280
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Console UI — `this.logger` and `this.prompt` on every command (Console parity).
3
+ *
4
+ * Kept dependency-free: the framework ships no colour or prompt library, and
5
+ * pulling one in for the CLI surface alone is not worth the install weight.
6
+ */
7
+
8
+ import { stdout } from 'node:process'
9
+
10
+ /** Colour is opt-out via NO_COLOR and skipped when piped to a file. */
11
+ function supportsColour(stream: NodeJS.WriteStream): boolean {
12
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
13
+ if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '') return true
14
+ return Boolean(stream.isTTY)
15
+ }
16
+
17
+ const CODES = {
18
+ reset: '\u001B[0m',
19
+ dim: '\u001B[2m',
20
+ red: '\u001B[31m',
21
+ green: '\u001B[32m',
22
+ yellow: '\u001B[33m',
23
+ blue: '\u001B[34m',
24
+ magenta: '\u001B[35m',
25
+ cyan: '\u001B[36m',
26
+ } as const
27
+
28
+ export type Colour = keyof Omit<typeof CODES, 'reset'>
29
+
30
+ export function colourise(
31
+ text: string,
32
+ colour: Colour,
33
+ stream: NodeJS.WriteStream = stdout,
34
+ ): string {
35
+ if (!supportsColour(stream)) return text
36
+ return `${CODES[colour]}${text}${CODES.reset}`
37
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The helpers Console exposes around commands (`@adonisjs/consoleApp` utils).
3
+ *
4
+ * They exist as exports because a custom loader needs them: validating what a
5
+ * module exported, and reporting a name that resolved to nothing.
6
+ */
7
+
8
+ import { ReamError } from '../errors/ReamError.js'
9
+ import type { Logger } from './cliui.js'
10
+ import { type CommandClass, isCommandClass, type SerializedCommand } from './types.js'
11
+
12
+ /** Sort command and namespace names the way the listing does. */
13
+ export function sortAlphabetically(prev: string, curr: string): number {
14
+ return prev.localeCompare(curr)
15
+ }
16
+
17
+ /**
18
+ * Report a name that resolved to nothing, with the closest matches.
19
+ *
20
+ * Shared by the kernel's unknown-command report and the `help` command: two
21
+ * wordings for the same dead end is how they end up disagreeing.
22
+ */
23
+ export function renderErrorWithSuggestions(
24
+ logger: Logger,
25
+ message: string,
26
+ suggestions: readonly string[],
27
+ ): void {
28
+ logger.error(message)
29
+ if (suggestions.length === 0) return
30
+ process.stderr.write(` Did you mean "${suggestions.slice(0, 4).join('", "')}"?\n`)
31
+ }
32
+
33
+ /**
34
+ * Assert that a module's export is a command class.
35
+ *
36
+ * `exportPath` names the file, because the useful part of the message is WHICH
37
+ * module exported the wrong thing.
38
+ */
39
+ export function validateCommand(
40
+ command: unknown,
41
+ exportPath: string,
42
+ ): asserts command is CommandClass {
43
+ if (isCommandClass(command)) return
44
+ throw new ReamError('E_CONSOLE_INVALID_COMMAND', `Invalid command exported from ${exportPath}.`, {
45
+ hint: 'Expected a class with a static commandName, a static description and a run() method.',
46
+ })
47
+ }
48
+
49
+ /** The same, for a metadata object a loader published. */
50
+ export function validateCommandMetaData(
51
+ metadata: unknown,
52
+ exportPath: string,
53
+ ): asserts metadata is SerializedCommand {
54
+ const invalid = (reason: string): never => {
55
+ throw new ReamError(
56
+ 'E_CONSOLE_INVALID_COMMAND_METADATA',
57
+ `Invalid command metadata exported from ${exportPath}: ${reason}.`,
58
+ )
59
+ }
60
+
61
+ if (typeof metadata !== 'object' || metadata === null) invalid('expected an object')
62
+ const record = metadata as Record<string, unknown>
63
+ if (typeof record.commandName !== 'string' || record.commandName === '') {
64
+ invalid('"commandName" is missing')
65
+ }
66
+ if (typeof record.description !== 'string') invalid('"description" is missing')
67
+ if (!Array.isArray(record.args)) invalid('"args" is missing')
68
+ if (!Array.isArray(record.flags)) invalid('"flags" is missing')
69
+ }
@@ -10,7 +10,13 @@
10
10
  */
11
11
 
12
12
  import 'reflect-metadata'
13
- import { getInjectTokens, getServiceMetadata, getServiceRegistry } from '../decorators/Service.js'
13
+ import { AsyncLocalStorage } from 'node:async_hooks'
14
+ import {
15
+ getInjectTokens,
16
+ getMethodInjectTokens,
17
+ getServiceMetadata,
18
+ getServiceRegistry,
19
+ } from '../decorators/Service.js'
14
20
  import { didYouMean } from '../errors/FuzzyMatcher.js'
15
21
  import { ReamError } from '../errors/ReamError.js'
16
22
  import type { Binding, ServiceFactory, ServiceToken } from './types.js'
@@ -22,6 +28,12 @@ import type { Binding, ServiceFactory, ServiceToken } from './types.js'
22
28
  */
23
29
  const RESERVED_TOKEN_NAMES = new Set(['__proto__', 'constructor', 'prototype'])
24
30
 
31
+ /** One resolution chain: the tokens on it, and a set for O(1) membership. */
32
+ interface ResolutionChain {
33
+ stack: string[]
34
+ set: Set<string>
35
+ }
36
+
25
37
  export class Container {
26
38
  #bindings: Map<string, Binding> = new Map()
27
39
  #singletons: Map<string, unknown> = new Map()
@@ -37,8 +49,16 @@ export class Container {
37
49
  #aliases: Map<string, ServiceToken> = new Map()
38
50
  /** token key → post-resolution callbacks (AdonisJS `container.resolving`). */
39
51
  #resolvingHooks: Map<string, Array<(value: unknown) => void | Promise<void>>> = new Map()
40
- #resolutionStack: string[] = []
41
- #resolutionSet: Set<string> = new Set()
52
+ /**
53
+ * The resolution chain currently being walked, for cycle detection.
54
+ *
55
+ * Scoped to the async context, NOT to the container: `resolve()` is async, so
56
+ * two independent chains interleave, and a stack held on the instance would
57
+ * splice them together and report a cycle neither of them has. Per-container
58
+ * storage keeps a chain that crosses two containers from colliding on a token
59
+ * name they happen to share.
60
+ */
61
+ readonly #chain = new AsyncLocalStorage<ResolutionChain>()
42
62
 
43
63
  // ─── Explicit bindings ────────────────────────────────────
44
64
 
@@ -162,21 +182,30 @@ export class Container {
162
182
 
163
183
  const key = this.#tokenToKey(token)
164
184
 
165
- if (this.#resolutionSet.has(key)) {
166
- const cycle = [...this.#resolutionStack, key].join(' → ')
185
+ // Top of a chain: open one, so everything this resolution triggers shares
186
+ // it and nothing outside it does.
187
+ const chain = this.#chain.getStore()
188
+ if (chain === undefined) {
189
+ return this.#chain.run({ stack: [], set: new Set() }, () =>
190
+ this.resolve<T>(token, runtimeValues),
191
+ )
192
+ }
193
+
194
+ if (chain.set.has(key)) {
195
+ const cycle = [...chain.stack, key].join(' → ')
167
196
  throw new ReamError('CIRCULAR_DEPENDENCY', `Circular dependency detected: ${cycle}`, {
168
197
  hint: 'Break the cycle by resolving one dependency lazily inside a method (`await container.make(Dep)`) instead of injecting it in the constructor.',
169
198
  context: { chain: cycle },
170
199
  })
171
200
  }
172
- this.#resolutionStack.push(key)
173
- this.#resolutionSet.add(key)
201
+ chain.stack.push(key)
202
+ chain.set.add(key)
174
203
 
175
204
  try {
176
205
  return await this.#resolveInner<T>(key, token, runtimeValues)
177
206
  } finally {
178
- this.#resolutionStack.pop()
179
- this.#resolutionSet.delete(key)
207
+ chain.stack.pop()
208
+ chain.set.delete(key)
180
209
  }
181
210
  }
182
211
 
@@ -196,8 +225,16 @@ export class Container {
196
225
  const target: Ctor = Object.getPrototypeOf(instance).constructor
197
226
  const paramTypes: unknown[] =
198
227
  Reflect.getMetadata('design:paramtypes', target.prototype, method) ?? []
199
-
200
- const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0)
228
+ // `@Inject('token')` on a method parameter — a named binding the reflected
229
+ // type cannot express (an interface, or a class registered under a name).
230
+ const injectTokens = getMethodInjectTokens(target.prototype, method)
231
+
232
+ // `@Inject` counts towards the parameter span too: a method may carry named
233
+ // tokens without `design:paramtypes` being emitted for it, and computing the
234
+ // span from the reflected types alone left the loop at zero iterations —
235
+ // the tokens were read and then never used.
236
+ const injectSpan = injectTokens.size === 0 ? 0 : Math.max(...injectTokens.keys()) + 1
237
+ const len = Math.max(paramTypes.length, runtimeValues?.length ?? 0, injectSpan)
201
238
  // Sequential resolution keeps the shared cycle-detection stack consistent.
202
239
  const args: unknown[] = []
203
240
  for (let index = 0; index < len; index += 1) {
@@ -206,6 +243,11 @@ export class Container {
206
243
  args.push(runtimeValues[index])
207
244
  continue
208
245
  }
246
+ const named = injectTokens.get(index)
247
+ if (named !== undefined) {
248
+ args.push(await this.resolve(named))
249
+ continue
250
+ }
209
251
  const type = paramTypes[index]
210
252
  args.push(isInjectableClass(type) ? await this.resolve(type) : undefined)
211
253
  }
@@ -261,7 +303,7 @@ export class Container {
261
303
  async #resolveInner<T>(key: string, token: ServiceToken, runtimeValues?: unknown[]): Promise<T> {
262
304
  // 1. Check swaps (test overrides) — a swap factory may be async.
263
305
  if (this.#overrides.has(key)) {
264
- const swapped = await this.#overrides.get(key)?.()
306
+ const swapped = await this.#overrides.get(key)?.(this)
265
307
  return swapped as T
266
308
  }
267
309
 
@@ -273,7 +315,7 @@ export class Container {
273
315
  // 3. Check explicit bindings — the factory may be async.
274
316
  const binding = this.#bindings.get(key)
275
317
  if (binding) {
276
- const instance = binding.factory ? await binding.factory() : undefined
318
+ const instance = binding.factory ? await binding.factory(this) : undefined
277
319
  if (binding.scope === 'singleton') {
278
320
  this.#singletons.set(key, instance)
279
321
  }
@@ -17,8 +17,21 @@ export interface ServiceMetadata {
17
17
  // biome-ignore lint/suspicious/noExplicitAny: IoC container tokens must accept any constructor signature
18
18
  export type ServiceToken = (new (...args: any[]) => unknown) | string | symbol
19
19
 
20
- /** Factory function for creating service instances. */
21
- export type ServiceFactory = () => unknown
20
+ /**
21
+ * Resolver handed to a binding factory so it can resolve its own dependencies
22
+ * (AdonisJS parity: `container.singleton(token, async (resolver) =>
23
+ * await resolver.make(Dep))`). The Container itself satisfies this contract.
24
+ */
25
+ export interface ContainerResolver {
26
+ make<T>(token: ServiceToken, runtimeValues?: unknown[]): Promise<T>
27
+ }
28
+
29
+ /**
30
+ * Factory function for creating service instances. Receives a {@link
31
+ * ContainerResolver} (AdonisJS parity) — the async container means a factory
32
+ * that needs another binding must `await resolver.make(Dep)`.
33
+ */
34
+ export type ServiceFactory = (resolver: ContainerResolver) => unknown
22
35
 
23
36
  /** Binding entry in the container. */
24
37
  export interface Binding {
@@ -51,11 +51,23 @@ export function Service(options: { scope?: ServiceScope; as?: string } = {}): Cl
51
51
  * @implements FR16
52
52
  */
53
53
  export function Inject(token: ServiceToken): ParameterDecorator {
54
- return (target, _propertyKey, parameterIndex) => {
54
+ return (target, propertyKey, parameterIndex) => {
55
+ // A constructor parameter reports no property key and lands on the class;
56
+ // a method parameter lands on the prototype and MUST be keyed by method
57
+ // name, or every method of the class would share one map and overwrite
58
+ // each other's tokens.
55
59
  const existingTokens: Map<number, ServiceToken> =
56
- Reflect.getOwnMetadata(INJECT_METADATA_KEY, target) ?? new Map()
60
+ (propertyKey === undefined
61
+ ? Reflect.getOwnMetadata(INJECT_METADATA_KEY, target)
62
+ : Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey)) ?? new Map()
63
+
57
64
  existingTokens.set(parameterIndex, token)
58
- Reflect.defineMetadata(INJECT_METADATA_KEY, existingTokens, target)
65
+
66
+ if (propertyKey === undefined) {
67
+ Reflect.defineMetadata(INJECT_METADATA_KEY, existingTokens, target)
68
+ } else {
69
+ Reflect.defineMetadata(INJECT_METADATA_KEY, existingTokens, target, propertyKey)
70
+ }
59
71
  }
60
72
  }
61
73
 
@@ -66,6 +78,19 @@ export function getInjectTokens(target: AnyConstructor): Map<number, ServiceToke
66
78
  return Reflect.getOwnMetadata(INJECT_METADATA_KEY, target) ?? new Map()
67
79
  }
68
80
 
81
+ /**
82
+ * Get named injection tokens for one method's parameters.
83
+ *
84
+ * `target` is the prototype carrying the method — that is where a parameter
85
+ * decorator on a method writes.
86
+ */
87
+ export function getMethodInjectTokens(
88
+ target: object,
89
+ propertyKey: string | symbol,
90
+ ): Map<number, ServiceToken> {
91
+ return Reflect.getOwnMetadata(INJECT_METADATA_KEY, target, propertyKey) ?? new Map()
92
+ }
93
+
69
94
  /**
70
95
  * @inject() class decorator — marks a controller (or service) for transient IoC resolution.
71
96
  *
@@ -7,6 +7,37 @@
7
7
  * @implements FR71, FR72, FR73, FR77
8
8
  */
9
9
 
10
+ /**
11
+ * Is this a Ream error, and one of these codes?
12
+ *
13
+ * The framework carries ONE error type with a `code`, because an error can be
14
+ * raised in Rust and rebuilt in TypeScript across the NAPI boundary — see
15
+ * {@link ReamError.fromNapi}. A class per code cannot cross that line, so the
16
+ * check that replaces `instanceof MyError` is this one.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * try {
21
+ * await consoleApp.exec('provision')
22
+ * } catch (error) {
23
+ * if (isReamError(error, 'E_CONSOLE_MISSING_FLAG', 'E_CONSOLE_MISSING_ARGUMENT')) {
24
+ * console.error(error.hint) // narrowed: `error` IS a ReamError here
25
+ * return
26
+ * }
27
+ * throw error
28
+ * }
29
+ * ```
30
+ */
31
+ export function isReamError(error: unknown): error is ReamError
32
+ export function isReamError<const Code extends string>(
33
+ error: unknown,
34
+ ...codes: [Code, ...Code[]]
35
+ ): error is ReamError & { code: Code }
36
+ export function isReamError(error: unknown, ...codes: string[]): boolean {
37
+ if (!(error instanceof ReamError)) return false
38
+ return codes.length === 0 || codes.includes(error.code)
39
+ }
40
+
10
41
  export class ReamError extends Error {
11
42
  /** Error code (e.g., "ATLAS_QUERY_ERROR", "CONTAINER_NOT_FOUND") */
12
43
  readonly code: string
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Render an error for a human reading a terminal (AdonisJS `prettyPrintError`).
3
+ *
4
+ * Its own module rather than a helper inside `Ignitor.ts`: the console kernel
5
+ * renders the failures of the command it ran, and reaching into the Ignitor for
6
+ * that would make the two import each other.
7
+ */
8
+
9
+ import { ReamError } from './ReamError.js'
10
+
11
+ export function prettyPrintError(error: unknown): void {
12
+ if (error instanceof ReamError) {
13
+ console.error(error.toDevString())
14
+ } else if (error instanceof Error) {
15
+ console.error(`\n ${error.message}\n`)
16
+ if (error.stack) console.error(error.stack)
17
+ } else {
18
+ console.error(error)
19
+ }
20
+ }