@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,548 @@
1
+ /**
2
+ * Interactive prompts (Console / `@poppinss/prompts` surface).
3
+ *
4
+ * Dependency-free, which sets one deliberate boundary: selection prompts are
5
+ * answered by typing a number or a name, not by arrow-key navigation. Doing
6
+ * that properly means raw mode, cursor control and redraw handling — a terminal
7
+ * widget toolkit, which is what enquirer is. Everything else matches Console: the
8
+ * method names, the option bag (`validate` / `default` / `hint` / `name` /
9
+ * `result` / `format`), and the trap API that makes prompts testable.
10
+ */
11
+
12
+ import { stdin, stdout } from 'node:process'
13
+ import { createInterface } from 'node:readline'
14
+ import { ReamError } from '../errors/ReamError.js'
15
+ import { colourise } from './ui.js'
16
+
17
+ /** A selectable option — a bare string, or a value with its display text. */
18
+ export type PromptChoice = string | { name: string; message?: string; hint?: string }
19
+
20
+ /**
21
+ * Options for the selection prompts.
22
+ *
23
+ * `default` is an INDEX here, not a value — that is how Adonis spells it for
24
+ * `choice` / `multiple` / `autocomplete`.
25
+ */
26
+ export interface SelectPromptOptions extends Omit<PromptOptions, 'default'> {
27
+ default?: number | readonly number[]
28
+ }
29
+
30
+ /**
31
+ * Options for `multiple()`.
32
+ *
33
+ * `validate` and `result` receive the whole selection, not each item: the
34
+ * prompt's value IS the array, and per-item calls would make a rule like "pick
35
+ * at least two" impossible to express.
36
+ */
37
+ export interface MultiplePromptOptions
38
+ extends Omit<SelectPromptOptions, 'validate' | 'result' | 'format'> {
39
+ validate?: (values: string[]) => boolean | string | Promise<boolean | string>
40
+ result?: (values: string[]) => unknown
41
+ }
42
+
43
+ export interface PromptOptions<T = string> {
44
+ /** Used when the answer is empty. */
45
+ default?: T
46
+ /** Identifies the prompt for {@link Prompt.trap}. Defaults to the message. */
47
+ name?: string
48
+ /** Shown next to the question. */
49
+ hint?: string
50
+ /** Return `true` to accept, or a string describing what is wrong. */
51
+ validate?: (value: T) => boolean | string | Promise<boolean | string>
52
+ /** Transforms the value that is returned. */
53
+ result?: (value: T) => unknown
54
+ /**
55
+ * Formats what is echoed as the user types — display only.
56
+ *
57
+ * It does NOT change the value the prompt returns: Console documents it as
58
+ * "affecting only the CLI output", and treating it as a transform would make
59
+ * a ported command receive a different value. Use `result` to transform.
60
+ */
61
+ format?: (value: T) => T
62
+ }
63
+
64
+ /**
65
+ * Scripted answer builder — Console's `prompt.trap(name)`.
66
+ *
67
+ * Without it a command that asks anything is untestable: the test would either
68
+ * hang on a prompt or need a fake terminal.
69
+ */
70
+ export class PromptTrap {
71
+ readonly #install: (value: unknown) => void
72
+
73
+ constructor(install: (value: unknown) => void) {
74
+ this.#install = install
75
+ }
76
+
77
+ /** Answer a text/secure prompt with this value. */
78
+ replyWith(value: string): void {
79
+ this.#install(value)
80
+ }
81
+
82
+ /** Answer a confirm/toggle prompt with yes. */
83
+ accept(): void {
84
+ this.#install(true)
85
+ }
86
+
87
+ /** Answer a confirm/toggle prompt with no. */
88
+ reject(): void {
89
+ this.#install(false)
90
+ }
91
+
92
+ /** Pick the option at this index (choice / autocomplete). */
93
+ chooseOption(index = 0): void {
94
+ this.#install({ index })
95
+ }
96
+
97
+ /** Pick these indexes (multiple). */
98
+ chooseOptions(indexes: readonly number[]): void {
99
+ this.#install({ indexes: [...indexes] })
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Options for `confirm` / `toggle`. `result` may map the boolean to something
105
+ * else, which is why the return type follows it (Console documents `result` for
106
+ * every prompt).
107
+ */
108
+ export interface BooleanPromptOptions<R = boolean>
109
+ extends Omit<PromptOptions<boolean>, 'result' | 'format'> {
110
+ result?: (value: boolean) => R
111
+ }
112
+
113
+ /** The slice of the options the validation helpers need. */
114
+ interface ValidatableOptions<T> {
115
+ validate?: (value: T) => boolean | string | Promise<boolean | string>
116
+ }
117
+
118
+ export class Prompt {
119
+ readonly #traps = new Map<string, unknown>()
120
+
121
+ /**
122
+ * Script the next answer for the prompt registered under `name`
123
+ * (`options.name`, defaulting to the message).
124
+ */
125
+ trap(name: string): PromptTrap {
126
+ return new PromptTrap((value) => {
127
+ this.#traps.set(name, value)
128
+ })
129
+ }
130
+
131
+ /** Traps that were installed but never reached — useful in assertions. */
132
+ get pendingTraps(): string[] {
133
+ return [...this.#traps.keys()]
134
+ }
135
+
136
+ async ask(message: string, options: PromptOptions = {}): Promise<unknown> {
137
+ return this.#text(message, options, false)
138
+ }
139
+
140
+ /** Masked input, for passwords and tokens. */
141
+ async secure(message: string, options: PromptOptions = {}): Promise<unknown> {
142
+ return this.#text(message, options, true)
143
+ }
144
+
145
+ async confirm(message: string, options?: BooleanPromptOptions): Promise<boolean>
146
+ async confirm<R>(message: string, options: BooleanPromptOptions<R>): Promise<R>
147
+ async confirm(message: string, options: BooleanPromptOptions<unknown> = {}): Promise<unknown> {
148
+ return this.toggle(message, ['y', 'n'], options)
149
+ }
150
+
151
+ /** Two custom labels instead of yes/no (Console `toggle`). */
152
+ async toggle(
153
+ message: string,
154
+ labels?: readonly [string, string],
155
+ options?: BooleanPromptOptions,
156
+ ): Promise<boolean>
157
+ async toggle<R>(
158
+ message: string,
159
+ labels: readonly [string, string] | undefined,
160
+ options: BooleanPromptOptions<R>,
161
+ ): Promise<R>
162
+ async toggle(
163
+ message: string,
164
+ labels: readonly [string, string] = ['y', 'n'],
165
+ options: BooleanPromptOptions<unknown> = {},
166
+ ): Promise<unknown> {
167
+ const trapped = this.#takeTrap(message, options.name)
168
+ if (trapped !== undefined) {
169
+ const value = Boolean(trapped)
170
+ await this.#assertTrapped(value, options)
171
+ return options.result === undefined ? value : options.result(value)
172
+ }
173
+
174
+ const fallback = options.default ?? false
175
+ const yes = labels[0]
176
+ const no = labels[1]
177
+ const shown = fallback ? `${yes.toUpperCase()}/${no}` : `${yes}/${no.toUpperCase()}`
178
+
179
+ for (;;) {
180
+ const answer = (
181
+ await this.readLine(this.#label(message, options, `(${shown})`))
182
+ ).toLowerCase()
183
+
184
+ if (answer === '') {
185
+ const problem = await this.#check(fallback, options)
186
+ if (problem === undefined) {
187
+ return options.result === undefined ? fallback : options.result(fallback)
188
+ }
189
+ stdout.write(`${colourise(problem, 'yellow')}\n`)
190
+ continue
191
+ }
192
+
193
+ const affirmative = answer === yes.toLowerCase() || answer === 'yes' || answer === 'true'
194
+ const negative = answer === no.toLowerCase() || answer === 'no' || answer === 'false'
195
+ if (!affirmative && !negative) {
196
+ stdout.write(`${colourise(`Answer ${yes} or ${no}.`, 'yellow')}\n`)
197
+ continue
198
+ }
199
+
200
+ const problem = await this.#check(affirmative, options)
201
+ if (problem !== undefined) {
202
+ stdout.write(`${colourise(problem, 'yellow')}\n`)
203
+ continue
204
+ }
205
+ return options.result === undefined ? affirmative : options.result(affirmative)
206
+ }
207
+ }
208
+
209
+ /** Pick one option (Console `choice`). */
210
+ async choice(
211
+ message: string,
212
+ choices: readonly PromptChoice[],
213
+ options: SelectPromptOptions = {},
214
+ ): Promise<unknown> {
215
+ const picked = await this.#select(message, choices, options, false)
216
+ return picked[0]
217
+ }
218
+
219
+ /** Pick several options (Console `multiple`). */
220
+ async multiple(
221
+ message: string,
222
+ choices: readonly PromptChoice[],
223
+ options: MultiplePromptOptions = {},
224
+ ): Promise<unknown> {
225
+ const picked = await this.#select(message, choices, options, true)
226
+ return options.result ? options.result(picked.map(String)) : picked
227
+ }
228
+
229
+ /**
230
+ * Pick one option by typing part of its name (Console `autocomplete`).
231
+ *
232
+ * Substring matching rather than fuzzy scoring — predictable, and enough for
233
+ * the "too many options to list" case it exists for.
234
+ */
235
+ async autocomplete(
236
+ message: string,
237
+ choices: readonly PromptChoice[],
238
+ options: SelectPromptOptions & { limit?: number } = {},
239
+ ): Promise<unknown> {
240
+ const trapped = this.#takeTrap(message, options.name)
241
+ if (trapped !== undefined) {
242
+ const picked = this.#fromTrap(trapped, choices, false)
243
+ // Same rule as every other prompt: a scripted answer is held to the
244
+ // command's own validation, or the test proves nothing.
245
+ await this.#validateSelection(picked, options, false)
246
+ return this.#applyResult(picked[0], options)
247
+ }
248
+
249
+ const limit = options.limit ?? 10
250
+ for (;;) {
251
+ const typed = await this.readLine(this.#label(message, options, '(type to filter)'))
252
+ const matches = choices.filter((choice) =>
253
+ nameOf(choice).toLowerCase().includes(typed.toLowerCase()),
254
+ )
255
+
256
+ if (matches.length === 0) {
257
+ stdout.write(`${colourise('No match.', 'yellow')}\n`)
258
+ continue
259
+ }
260
+
261
+ const first = matches[0]
262
+ if (matches.length === 1 && first !== undefined) {
263
+ return this.#finish(nameOf(first), options)
264
+ }
265
+
266
+ const picked = await this.#select(
267
+ message,
268
+ matches.slice(0, limit),
269
+ { ...options, name: undefined },
270
+ false,
271
+ )
272
+ return picked[0]
273
+ }
274
+ }
275
+
276
+ // ─── internals ──────────────────────────────────────────────
277
+
278
+ async #text(message: string, options: PromptOptions, masked: boolean): Promise<unknown> {
279
+ const trapped = this.#takeTrap(message, options.name)
280
+ if (trapped !== undefined) return this.#finish(String(trapped), options)
281
+
282
+ for (;;) {
283
+ const raw = await this.readLine(this.#label(message, options), masked)
284
+ // `format` is display-only (Console): the returned value is what was typed,
285
+ // with the default filled in when the line was empty.
286
+ const value = raw === '' && options.default !== undefined ? options.default : raw
287
+
288
+ const problem = await this.#check(value, options)
289
+ if (problem !== undefined) {
290
+ stdout.write(`${colourise(problem, 'yellow')}\n`)
291
+ continue
292
+ }
293
+ return options.result ? options.result(value) : value
294
+ }
295
+ }
296
+
297
+ async #select(
298
+ message: string,
299
+ choices: readonly PromptChoice[],
300
+ options: SelectPromptOptions | MultiplePromptOptions,
301
+ many: boolean,
302
+ ): Promise<unknown[]> {
303
+ if (choices.length === 0) {
304
+ throw new ReamError('E_CONSOLE_EMPTY_CHOICES', `"${message}" was asked with no options.`)
305
+ }
306
+
307
+ const trapped = this.#takeTrap(message, options.name)
308
+ if (trapped !== undefined) {
309
+ const picked = this.#fromTrap(trapped, choices, many)
310
+ // Same reasoning as the text prompts: a scripted answer the real prompt
311
+ // would reject must fail the test, not sail through it.
312
+ await this.#validateSelection(picked, options, many)
313
+ return many ? picked : picked.map((value) => this.#applyResult(value, options))
314
+ }
315
+
316
+ const defaultLabel =
317
+ options.default === undefined
318
+ ? undefined
319
+ : [options.default]
320
+ .flat()
321
+ .map((index) => {
322
+ const choice = choices[index]
323
+ return choice === undefined ? String(index) : displayOf(choice)
324
+ })
325
+ .join(', ')
326
+
327
+ for (;;) {
328
+ stdout.write(`${this.#label(message, { ...options, default: defaultLabel }).trimEnd()}\n`)
329
+ choices.forEach((choice, index) => {
330
+ const hint = typeof choice === 'string' ? undefined : choice.hint
331
+ stdout.write(
332
+ ` ${colourise(String(index + 1), 'cyan')}) ${displayOf(choice)}` +
333
+ `${hint === undefined ? '' : colourise(` — ${hint}`, 'dim')}\n`,
334
+ )
335
+ })
336
+
337
+ const answer = await this.readLine(
338
+ many ? `Select (e.g. 1,3) from 1-${choices.length}: ` : `Select (1-${choices.length}): `,
339
+ )
340
+
341
+ // An empty answer takes the default — which for a selection prompt is an
342
+ // index, as Adonis spells it. Without this the default was shown in the
343
+ // label and then rejected as an invalid selection.
344
+ const indexes =
345
+ answer === '' && options.default !== undefined
346
+ ? [options.default]
347
+ .flat()
348
+ .filter((index) => Number.isInteger(index) && index >= 0 && index < choices.length)
349
+ : answer
350
+ .split(',')
351
+ .map((part) => Number(part.trim()) - 1)
352
+ .filter((index) => Number.isInteger(index) && index >= 0 && index < choices.length)
353
+
354
+ if (indexes.length === 0 || (!many && indexes.length !== 1)) {
355
+ stdout.write(`${colourise('Invalid selection.', 'yellow')}\n`)
356
+ continue
357
+ }
358
+
359
+ const picked = indexes.map((index) => nameOf(choices[index] as PromptChoice))
360
+ const problem = await this.#selectionProblem(picked, options, many)
361
+ if (problem !== undefined) {
362
+ stdout.write(`${colourise(problem, 'yellow')}\n`)
363
+ continue
364
+ }
365
+ return many ? picked : picked.map((value) => this.#applyResult(value, options))
366
+ }
367
+ }
368
+
369
+ /** `result` for a single-value selection; `multiple` applies its own. */
370
+ #applyResult(value: unknown, options: { result?: (value: never) => unknown }): unknown {
371
+ return options.result === undefined
372
+ ? value
373
+ : Reflect.apply(options.result, undefined, [String(value)])
374
+ }
375
+
376
+ /**
377
+ * Validation subject for a selection: the whole array for `multiple`, the
378
+ * single value otherwise — so an Console rule written `validate(values: string[])`
379
+ * behaves the same here.
380
+ */
381
+ async #selectionProblem(
382
+ picked: readonly unknown[],
383
+ options: { validate?: (value: never) => boolean | string | Promise<boolean | string> },
384
+ many: boolean,
385
+ ): Promise<string | undefined> {
386
+ if (options.validate === undefined) return undefined
387
+ const subject = many ? picked.map(String) : String(picked[0] ?? '')
388
+ const verdict = await Reflect.apply(options.validate, undefined, [subject])
389
+ if (verdict === true) return undefined
390
+ return typeof verdict === 'string' ? verdict : 'Invalid value.'
391
+ }
392
+
393
+ async #validateSelection(
394
+ picked: readonly unknown[],
395
+ options: { validate?: (value: never) => boolean | string | Promise<boolean | string> },
396
+ many: boolean,
397
+ ): Promise<void> {
398
+ const problem = await this.#selectionProblem(picked, options, many)
399
+ if (problem === undefined) return
400
+ throw new ReamError(
401
+ 'E_CONSOLE_INVALID_TRAP_VALUE',
402
+ `A scripted answer was rejected by its own validation: ${problem}`,
403
+ { hint: 'The trap supplies a value the real prompt would refuse — fix the test.' },
404
+ )
405
+ }
406
+
407
+ /**
408
+ * Apply `format`, `validate` and `result` to a value that came from a trap.
409
+ *
410
+ * Skipping `validate` here would let a test inject a value the real prompt
411
+ * would have rejected, and the command would pass — a test giving confidence
412
+ * in something that cannot happen. A trapped value that fails validation is a
413
+ * broken test, so it throws instead of re-asking: there is nobody to re-ask.
414
+ */
415
+ async #finish(
416
+ value: string,
417
+ options: Pick<PromptOptions, 'validate' | 'result'>,
418
+ ): Promise<unknown> {
419
+ await this.#assertTrapped(value, options)
420
+ return options.result ? options.result(value) : value
421
+ }
422
+
423
+ // Only the validation hook, so `SelectPromptOptions` (whose `default` is an
424
+ // index, not a value) passes without a variance conflict.
425
+ async #assertTrapped<T>(value: T, options: ValidatableOptions<T>): Promise<void> {
426
+ const problem = await this.#check(value, options)
427
+ if (problem === undefined) return
428
+ throw new ReamError(
429
+ 'E_CONSOLE_INVALID_TRAP_VALUE',
430
+ `A scripted answer was rejected by its own validation: ${problem}`,
431
+ { hint: 'The trap supplies a value the real prompt would refuse — fix the test.' },
432
+ )
433
+ }
434
+
435
+ #fromTrap(trapped: unknown, choices: readonly PromptChoice[], many: boolean): unknown[] {
436
+ const read = (index: number): string => {
437
+ const choice = choices[index]
438
+ if (choice === undefined) {
439
+ throw new ReamError(
440
+ 'E_CONSOLE_TRAP_OUT_OF_RANGE',
441
+ `A trap chose option ${index + 1}, but only ${choices.length} were offered.`,
442
+ )
443
+ }
444
+ return nameOf(choice)
445
+ }
446
+
447
+ if (typeof trapped === 'object' && trapped !== null) {
448
+ const indexes = Reflect.get(trapped, 'indexes')
449
+ if (Array.isArray(indexes)) return indexes.map((index) => read(Number(index)))
450
+ const index = Reflect.get(trapped, 'index')
451
+ if (typeof index === 'number') return [read(index)]
452
+ }
453
+ // A trap that replied with a raw value: honour it as-is.
454
+ if (many) return Array.isArray(trapped) ? trapped : [trapped]
455
+ return [trapped]
456
+ }
457
+
458
+ /** Consume a scripted answer, if one was installed for this prompt. */
459
+ #takeTrap(message: string, name: string | undefined): unknown {
460
+ const key = name ?? message
461
+ if (!this.#traps.has(key)) return undefined
462
+ const value = this.#traps.get(key)
463
+ this.#traps.delete(key)
464
+ return value
465
+ }
466
+
467
+ async #check<T>(value: T, options: ValidatableOptions<T>): Promise<string | undefined> {
468
+ if (options.validate === undefined) return undefined
469
+ const verdict = await options.validate(value)
470
+ if (verdict === true) return undefined
471
+ return typeof verdict === 'string' ? verdict : 'Invalid value.'
472
+ }
473
+
474
+ // Only the display fields, so a `PromptOptions<boolean>` can be passed
475
+ // without tripping over the contravariance of `validate`.
476
+ #label(
477
+ message: string,
478
+ options: { hint?: string; default?: unknown; format?: (value: never) => unknown },
479
+ suffix?: string,
480
+ ): string {
481
+ const hint = options.hint === undefined ? '' : colourise(` (${options.hint})`, 'dim')
482
+ // The one place `format` legitimately applies: what is shown, not what is
483
+ // returned.
484
+ const shownDefault =
485
+ options.default !== undefined && options.format !== undefined
486
+ ? Reflect.apply(options.format, undefined, [options.default])
487
+ : options.default
488
+ const shown = shownDefault === undefined ? '' : ` [${String(shownDefault)}]`
489
+ const tail = suffix === undefined ? '' : ` ${suffix}`
490
+ return `${message}${hint}${shown}${tail}: `
491
+ }
492
+
493
+ /**
494
+ * Read one line from the terminal.
495
+ *
496
+ * `protected` on purpose: it is the single seam through which input enters,
497
+ * so a test (or a future non-readline frontend) can substitute it by
498
+ * subclassing instead of faking a TTY.
499
+ */
500
+ protected readLine(query: string, masked = false): Promise<string> {
501
+ if (!stdin.isTTY) {
502
+ return Promise.reject(
503
+ new ReamError(
504
+ 'E_CONSOLE_NOT_INTERACTIVE',
505
+ `Cannot prompt for "${query.trim()}" — stdin is not interactive.`,
506
+ {
507
+ hint: 'Pass the value as a flag, or script the answer in tests with prompt.trap(name).',
508
+ },
509
+ ),
510
+ )
511
+ }
512
+
513
+ const rl = createInterface({ input: stdin, output: stdout, terminal: true })
514
+
515
+ // readline echoes each keystroke through _writeToOutput; replacing it is
516
+ // the dependency-free way to hide what is typed.
517
+ if (masked && isEchoing(rl)) {
518
+ rl._writeToOutput = (text: string): void => {
519
+ stdout.write(text.includes(query) ? query : '')
520
+ }
521
+ }
522
+
523
+ return new Promise((resolve) => {
524
+ rl.question(query, (answer) => {
525
+ if (masked) stdout.write('\n')
526
+ rl.close()
527
+ resolve(answer.trim())
528
+ })
529
+ })
530
+ }
531
+ }
532
+
533
+ /** readline's undocumented echo hook, present on a terminal interface. */
534
+ interface EchoingInterface {
535
+ _writeToOutput(text: string): void
536
+ }
537
+
538
+ function isEchoing(value: object): value is EchoingInterface {
539
+ return '_writeToOutput' in value && typeof Reflect.get(value, '_writeToOutput') === 'function'
540
+ }
541
+
542
+ function nameOf(choice: PromptChoice): string {
543
+ return typeof choice === 'string' ? choice : choice.name
544
+ }
545
+
546
+ function displayOf(choice: PromptChoice): string {
547
+ return typeof choice === 'string' ? choice : (choice.message ?? choice.name)
548
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Diagnostics channels for the console (Console `tracingChannels`).
3
+ *
4
+ * `diagnostics_channel` is Node's built-in tracing hook: an APM agent, or a
5
+ * test, subscribes without the framework knowing about it and without paying
6
+ * anything when nobody listens.
7
+ */
8
+
9
+ import diagnosticsChannel from 'node:diagnostics_channel'
10
+ import type { CommandClass, CommandInstance } from './types.js'
11
+
12
+ export interface CommandExecTracingData {
13
+ command: CommandClass
14
+ commandInstance: CommandInstance
15
+ argv: readonly string[]
16
+ }
17
+
18
+ /** Traces every command run by the kernel — start, end, error and result. */
19
+ export const commandExec = diagnosticsChannel.tracingChannel<
20
+ 'ream.command.exec',
21
+ CommandExecTracingData
22
+ >('ream.command.exec')