@c9up/ream 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +65 -0
  2. package/dist/Ignitor.d.ts +115 -7
  3. package/dist/Ignitor.d.ts.map +1 -1
  4. package/dist/Ignitor.js +233 -37
  5. package/dist/Ignitor.js.map +1 -1
  6. package/dist/console/BaseCommand.d.ts +217 -0
  7. package/dist/console/BaseCommand.d.ts.map +1 -0
  8. package/dist/console/BaseCommand.js +340 -0
  9. package/dist/console/BaseCommand.js.map +1 -0
  10. package/dist/console/Console.d.ts +86 -0
  11. package/dist/console/Console.d.ts.map +1 -0
  12. package/dist/console/Console.js +134 -0
  13. package/dist/console/Console.js.map +1 -0
  14. package/dist/console/ExceptionHandler.d.ts +28 -0
  15. package/dist/console/ExceptionHandler.d.ts.map +1 -0
  16. package/dist/console/ExceptionHandler.js +76 -0
  17. package/dist/console/ExceptionHandler.js.map +1 -0
  18. package/dist/console/HelpCommand.d.ts +16 -0
  19. package/dist/console/HelpCommand.d.ts.map +1 -0
  20. package/dist/console/HelpCommand.js +40 -0
  21. package/dist/console/HelpCommand.js.map +1 -0
  22. package/dist/console/IndexGenerator.d.ts +24 -0
  23. package/dist/console/IndexGenerator.d.ts.map +1 -0
  24. package/dist/console/IndexGenerator.js +65 -0
  25. package/dist/console/IndexGenerator.js.map +1 -0
  26. package/dist/console/Kernel.d.ts +298 -0
  27. package/dist/console/Kernel.d.ts.map +1 -0
  28. package/dist/console/Kernel.js +1120 -0
  29. package/dist/console/Kernel.js.map +1 -0
  30. package/dist/console/ListCommand.d.ts +18 -0
  31. package/dist/console/ListCommand.d.ts.map +1 -0
  32. package/dist/console/ListCommand.js +60 -0
  33. package/dist/console/ListCommand.js.map +1 -0
  34. package/dist/console/assertions.d.ts +20 -0
  35. package/dist/console/assertions.d.ts.map +1 -0
  36. package/dist/console/assertions.js +84 -0
  37. package/dist/console/assertions.js.map +1 -0
  38. package/dist/console/cliui.d.ts +248 -0
  39. package/dist/console/cliui.d.ts.map +1 -0
  40. package/dist/console/cliui.js +587 -0
  41. package/dist/console/cliui.js.map +1 -0
  42. package/dist/console/decorators.d.ts +90 -0
  43. package/dist/console/decorators.d.ts.map +1 -0
  44. package/dist/console/decorators.js +142 -0
  45. package/dist/console/decorators.js.map +1 -0
  46. package/dist/console/index.d.ts +32 -0
  47. package/dist/console/index.d.ts.map +1 -0
  48. package/dist/console/index.js +24 -0
  49. package/dist/console/index.js.map +1 -0
  50. package/dist/console/loaders.d.ts +52 -0
  51. package/dist/console/loaders.d.ts.map +1 -0
  52. package/dist/console/loaders.js +131 -0
  53. package/dist/console/loaders.js.map +1 -0
  54. package/dist/console/parser.d.ts +86 -0
  55. package/dist/console/parser.d.ts.map +1 -0
  56. package/dist/console/parser.js +440 -0
  57. package/dist/console/parser.js.map +1 -0
  58. package/dist/console/prompts.d.ts +124 -0
  59. package/dist/console/prompts.d.ts.map +1 -0
  60. package/dist/console/prompts.js +364 -0
  61. package/dist/console/prompts.js.map +1 -0
  62. package/dist/console/tracing.d.ts +17 -0
  63. package/dist/console/tracing.d.ts.map +1 -0
  64. package/dist/console/tracing.js +11 -0
  65. package/dist/console/tracing.js.map +1 -0
  66. package/dist/console/types.d.ts +255 -0
  67. package/dist/console/types.d.ts.map +1 -0
  68. package/dist/console/types.js +25 -0
  69. package/dist/console/types.js.map +1 -0
  70. package/dist/console/ui.d.ts +20 -0
  71. package/dist/console/ui.d.ts.map +1 -0
  72. package/dist/console/ui.js +31 -0
  73. package/dist/console/ui.js.map +1 -0
  74. package/dist/console/utils.d.ts +27 -0
  75. package/dist/console/utils.d.ts.map +1 -0
  76. package/dist/console/utils.js +56 -0
  77. package/dist/console/utils.js.map +1 -0
  78. package/dist/container/Container.d.ts.map +1 -1
  79. package/dist/container/Container.js +40 -12
  80. package/dist/container/Container.js.map +1 -1
  81. package/dist/container/types.d.ts +14 -2
  82. package/dist/container/types.d.ts.map +1 -1
  83. package/dist/decorators/Service.d.ts +7 -0
  84. package/dist/decorators/Service.d.ts.map +1 -1
  85. package/dist/decorators/Service.js +23 -3
  86. package/dist/decorators/Service.js.map +1 -1
  87. package/dist/errors/ReamError.d.ts +25 -0
  88. package/dist/errors/ReamError.d.ts.map +1 -1
  89. package/dist/errors/ReamError.js +5 -0
  90. package/dist/errors/ReamError.js.map +1 -1
  91. package/dist/errors/prettyPrintError.d.ts +9 -0
  92. package/dist/errors/prettyPrintError.d.ts.map +1 -0
  93. package/dist/errors/prettyPrintError.js +22 -0
  94. package/dist/errors/prettyPrintError.js.map +1 -0
  95. package/dist/index.d.ts +14 -4
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +16 -2
  98. package/dist/index.js.map +1 -1
  99. package/dist/middleware/Pipeline.d.ts +29 -8
  100. package/dist/middleware/Pipeline.d.ts.map +1 -1
  101. package/dist/middleware/Pipeline.js +39 -1
  102. package/dist/middleware/Pipeline.js.map +1 -1
  103. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  104. package/dist/rpc/RpcRouter.js +13 -2
  105. package/dist/rpc/RpcRouter.js.map +1 -1
  106. package/dist/services/app.d.ts +1 -1
  107. package/dist/services/app.js +1 -1
  108. package/dist/services/console.d.ts +21 -0
  109. package/dist/services/console.d.ts.map +1 -0
  110. package/dist/services/console.js +35 -0
  111. package/dist/services/console.js.map +1 -0
  112. package/dist/testing/ApiResponse.d.ts +218 -0
  113. package/dist/testing/ApiResponse.d.ts.map +1 -0
  114. package/dist/testing/ApiResponse.js +576 -0
  115. package/dist/testing/ApiResponse.js.map +1 -0
  116. package/dist/testing/RequestBuilder.d.ts +132 -72
  117. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  118. package/dist/testing/RequestBuilder.js +389 -222
  119. package/dist/testing/RequestBuilder.js.map +1 -1
  120. package/dist/testing/TestClient.d.ts +34 -39
  121. package/dist/testing/TestClient.d.ts.map +1 -1
  122. package/dist/testing/TestClient.js +48 -56
  123. package/dist/testing/TestClient.js.map +1 -1
  124. package/dist/utils/Macroable.js +3 -3
  125. package/dist/utils/Macroable.js.map +1 -1
  126. package/events.darwin-arm64.node +0 -0
  127. package/events.darwin-x64.node +0 -0
  128. package/events.linux-arm64-gnu.node +0 -0
  129. package/events.linux-x64-gnu.node +0 -0
  130. package/events.win32-x64-msvc.node +0 -0
  131. package/index.darwin-arm64.node +0 -0
  132. package/index.darwin-x64.node +0 -0
  133. package/index.linux-arm64-gnu.node +0 -0
  134. package/index.linux-x64-gnu.node +0 -0
  135. package/index.win32-x64-msvc.node +0 -0
  136. package/package.json +15 -2
  137. package/scheduler.darwin-arm64.node +0 -0
  138. package/scheduler.darwin-x64.node +0 -0
  139. package/scheduler.linux-arm64-gnu.node +0 -0
  140. package/scheduler.linux-x64-gnu.node +0 -0
  141. package/scheduler.win32-x64-msvc.node +0 -0
  142. package/src/Ignitor.ts +340 -42
  143. package/src/console/BaseCommand.ts +467 -0
  144. package/src/console/Console.ts +174 -0
  145. package/src/console/ExceptionHandler.ts +91 -0
  146. package/src/console/HelpCommand.ts +37 -0
  147. package/src/console/IndexGenerator.ts +79 -0
  148. package/src/console/Kernel.ts +1382 -0
  149. package/src/console/ListCommand.ts +55 -0
  150. package/src/console/assertions.ts +114 -0
  151. package/src/console/cliui.ts +756 -0
  152. package/src/console/decorators.ts +199 -0
  153. package/src/console/index.ts +67 -0
  154. package/src/console/loaders.ts +145 -0
  155. package/src/console/parser.ts +598 -0
  156. package/src/console/prompts.ts +556 -0
  157. package/src/console/tracing.ts +22 -0
  158. package/src/console/types.ts +280 -0
  159. package/src/console/ui.ts +37 -0
  160. package/src/console/utils.ts +69 -0
  161. package/src/container/Container.ts +55 -13
  162. package/src/container/types.ts +15 -2
  163. package/src/decorators/Service.ts +28 -3
  164. package/src/errors/ReamError.ts +31 -0
  165. package/src/errors/prettyPrintError.ts +20 -0
  166. package/src/index.ts +30 -4
  167. package/src/middleware/Pipeline.ts +76 -5
  168. package/src/rpc/RpcRouter.ts +16 -2
  169. package/src/services/app.ts +1 -1
  170. package/src/services/console.ts +42 -0
  171. package/src/testing/ApiResponse.ts +691 -0
  172. package/src/testing/RequestBuilder.ts +498 -275
  173. package/src/testing/TestClient.ts +63 -86
  174. package/src/utils/Macroable.ts +3 -3
  175. package/dist/console/CommandRunner.d.ts +0 -18
  176. package/dist/console/CommandRunner.d.ts.map +0 -1
  177. package/dist/console/CommandRunner.js +0 -65
  178. package/dist/console/CommandRunner.js.map +0 -1
  179. package/src/console/CommandRunner.ts +0 -82
@@ -0,0 +1,756 @@
1
+ /**
2
+ * Terminal UI (Console / `@poppinss/cliui` surface).
3
+ *
4
+ * Everything renders through {@link Ui.write}, which is what makes the layer
5
+ * testable: in `raw` mode nothing reaches the terminal and every line is kept
6
+ * in memory for assertions. Colours become `name(text)` there — the same trick
7
+ * Console uses, so an expected log stays a readable string instead of a soup of
8
+ * escape codes.
9
+ */
10
+
11
+ import { stdout } from 'node:process'
12
+ import { ReamError } from '../errors/ReamError.js'
13
+
14
+ /** Style names, in kleur's spelling. */
15
+ const STYLES = {
16
+ reset: 0,
17
+ bold: 1,
18
+ dim: 2,
19
+ italic: 3,
20
+ underline: 4,
21
+ red: 31,
22
+ green: 32,
23
+ yellow: 33,
24
+ blue: 34,
25
+ magenta: 35,
26
+ cyan: 36,
27
+ white: 37,
28
+ grey: 90,
29
+ bgRed: 41,
30
+ bgGreen: 42,
31
+ bgYellow: 43,
32
+ bgBlue: 44,
33
+ } as const
34
+
35
+ export type StyleName = keyof typeof STYLES
36
+
37
+ /**
38
+ * A chainable colour function.
39
+ *
40
+ * Called with text it renders; called with nothing it returns itself, which is
41
+ * what allows Console's `colors.bgGreen().white(' CREATED ')`.
42
+ */
43
+ export type Colors = ((text?: string) => string & Colors) & { [K in StyleName]: Colors }
44
+
45
+ /** Colour is opt-out via NO_COLOR and skipped when piped to a file. */
46
+ function coloursEnabled(): boolean {
47
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
48
+ if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '') return true
49
+ return Boolean(stdout.isTTY)
50
+ }
51
+
52
+ /**
53
+ * The styles were just attached, so this always holds — it is a guard rather
54
+ * than a cast because a cast is how a wrong assumption becomes a runtime
55
+ * surprise somewhere else.
56
+ */
57
+ function carriesStyles(value: object): value is Colors {
58
+ return typeof value === 'function' && 'red' in value && 'bold' in value
59
+ }
60
+
61
+ function buildColors(applied: readonly StyleName[], isRaw: () => boolean): Colors {
62
+ const render = (text: string): string => {
63
+ // Raw mode spells the styles out — `blue(info)` — so a test asserts on
64
+ // something a human can read and diff.
65
+ if (isRaw()) {
66
+ return applied.reduceRight((inner, style) => `${style}(${inner})`, text)
67
+ }
68
+ if (!coloursEnabled() || applied.length === 0) return text
69
+ const open = applied.map((style) => `\u001B[${STYLES[style]}m`).join('')
70
+ return `${open}${text}\u001B[0m`
71
+ }
72
+
73
+ const callable = (text?: string): unknown =>
74
+ text === undefined ? buildColors(applied, isRaw) : render(text)
75
+
76
+ const descriptors: PropertyDescriptorMap = {}
77
+ for (const style of Object.keys(STYLES)) {
78
+ if (!isStyleName(style)) continue
79
+ descriptors[style] = {
80
+ configurable: true,
81
+ get: () => buildColors([...applied, style], isRaw),
82
+ }
83
+ }
84
+ Object.defineProperties(callable, descriptors)
85
+
86
+ if (!carriesStyles(callable)) {
87
+ throw new Error('unreachable: the style accessors were just defined')
88
+ }
89
+ return callable
90
+ }
91
+
92
+ /** `Object.keys` widens to `string`; this narrows it back against the table. */
93
+ function isStyleName(value: string): value is StyleName {
94
+ return Object.hasOwn(STYLES, value)
95
+ }
96
+
97
+ /** Where a line goes when the UI is not in raw mode. */
98
+ type Stream = 'stdout' | 'stderr'
99
+
100
+ /**
101
+ * The console UI: colours, logger and widgets, plus the mode switch that makes
102
+ * all of it assertable.
103
+ */
104
+ /** One captured line, with the stream it would have gone to. */
105
+ export interface CapturedLog {
106
+ message: string
107
+ stream: Stream
108
+ }
109
+
110
+ export class Ui {
111
+ #mode: 'normal' | 'raw' = 'normal'
112
+ readonly #logs: CapturedLog[] = []
113
+ readonly #tableRows: string[][] = []
114
+ #tableHead: string[] = []
115
+
116
+ readonly colors: Colors = buildColors([], () => this.#mode === 'raw')
117
+ readonly logger: Logger
118
+
119
+ constructor() {
120
+ this.logger = new Logger(this)
121
+ }
122
+
123
+ /** `raw` keeps every line in memory instead of printing it. */
124
+ switchMode(mode: 'normal' | 'raw'): void {
125
+ this.#mode = mode
126
+ if (mode === 'raw') {
127
+ this.#logs.length = 0
128
+ this.#tableRows.length = 0
129
+ this.#tableHead = []
130
+ }
131
+ }
132
+
133
+ get mode(): 'normal' | 'raw' {
134
+ return this.#mode
135
+ }
136
+
137
+ /** Everything written since raw mode was switched on. */
138
+ getLogs(): string[] {
139
+ return this.#logs.map((entry) => entry.message)
140
+ }
141
+
142
+ /** The same lines with the stream each one targeted. */
143
+ getCapturedLogs(): CapturedLog[] {
144
+ return [...this.#logs]
145
+ }
146
+
147
+ /**
148
+ * Table rows as they were given, before padding.
149
+ *
150
+ * Assertions compare data, not layout: `assertTableRows` should not break
151
+ * because a column grew wider.
152
+ */
153
+ getTableRows(): string[][] {
154
+ return this.#tableRows.map((row) => [...row])
155
+ }
156
+
157
+ /** @internal Recorded by {@link Table.render}. */
158
+ captureTableRow(cells: readonly string[]): void {
159
+ if (this.#mode === 'raw') this.#tableRows.push([...cells])
160
+ }
161
+
162
+ /** @internal The head row, kept apart from the data rows. */
163
+ captureTableHead(cells: readonly string[]): void {
164
+ if (this.#mode === 'raw') this.#tableHead = [...cells]
165
+ }
166
+
167
+ /**
168
+ * The header cells of the last rendered table.
169
+ *
170
+ * Kept apart from {@link getTableRows} so a caller can assert the data alone.
171
+ * `assertTableRows` looks at both: Console treats the head as one more row, and a
172
+ * test may or may not restate it.
173
+ */
174
+ getTableHead(): string[] {
175
+ return [...this.#tableHead]
176
+ }
177
+
178
+ /** The single sink every widget writes through. */
179
+ write(line: string, stream: Stream = 'stdout'): void {
180
+ if (this.#mode === 'raw') {
181
+ this.#logs.push({ message: line, stream })
182
+ return
183
+ }
184
+ const target = stream === 'stderr' ? process.stderr : stdout
185
+ target.write(`${line}\n`)
186
+ }
187
+
188
+ table(): Table {
189
+ return new Table(this)
190
+ }
191
+
192
+ sticker(): Box {
193
+ return new Box(this, 'sticker')
194
+ }
195
+
196
+ instructions(): Box {
197
+ return new Box(this, 'instructions')
198
+ }
199
+
200
+ tasks(options: TasksOptions = {}): Tasks {
201
+ return new Tasks(this, options)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Command logger.
207
+ *
208
+ * Alert levels go to stderr so a command's data output stays pipeable — a
209
+ * warning must not end up inside `ream list --json | jq`.
210
+ */
211
+ export class Logger {
212
+ readonly #ui: Ui
213
+ #prefixText = ''
214
+ #suffixText = ''
215
+
216
+ constructor(ui: Ui) {
217
+ this.#ui = ui
218
+ }
219
+
220
+ /**
221
+ * Prepend a fixed marker to EVERY message from now on.
222
+ *
223
+ * Console's own `prefix` is per-message (`info(msg, { prefix })`, supported
224
+ * below); this sticky form is kept because a command tagging all its output
225
+ * should not repeat itself on every call.
226
+ */
227
+ prefix(value: string): this {
228
+ this.#prefixText = value
229
+ return this
230
+ }
231
+
232
+ suffix(value: string): this {
233
+ this.#suffixText = value
234
+ return this
235
+ }
236
+
237
+ info(message: string, options: MessageOptions = {}): void {
238
+ this.#write('info', 'blue', message, 'stdout', options)
239
+ }
240
+
241
+ success(message: string, options: MessageOptions = {}): void {
242
+ this.#write('success', 'green', message, 'stdout', options)
243
+ }
244
+
245
+ warning(message: string, options: MessageOptions = {}): void {
246
+ this.#write('warn', 'yellow', message, 'stderr', options)
247
+ }
248
+
249
+ error(message: string | Error, options: MessageOptions = {}): void {
250
+ this.#write('error', 'red', messageOf(message), 'stderr', options)
251
+ }
252
+
253
+ /**
254
+ * A message with animated trailing dots (Console `logger.await`).
255
+ *
256
+ * The animation only ticks on a TTY: on a pipe, or in raw mode, one line per
257
+ * frame would be noise in a log and unassertable in a test.
258
+ */
259
+ await(message: string, options: MessageOptions = {}): Animation {
260
+ return new Animation(this.#ui, message, options)
261
+ }
262
+
263
+ fatal(message: string | Error): void {
264
+ this.error(message)
265
+ if (message instanceof Error && message.stack !== undefined) {
266
+ this.#ui.write(this.#ui.colors.dim(message.stack), 'stderr')
267
+ }
268
+ }
269
+
270
+ /** Only prints when DEBUG is set — the usual escape hatch for noisy detail. */
271
+ debug(message: string): void {
272
+ if (process.env.DEBUG === undefined || process.env.DEBUG === '') return
273
+ this.#write('debug', 'magenta', message, 'stdout')
274
+ }
275
+
276
+ /** A line with no decoration. Use it for the command's own output. */
277
+ log(message: string): void {
278
+ this.#ui.write(message)
279
+ }
280
+
281
+ /**
282
+ * A step whose outcome is reported later (Console `logger.action`).
283
+ *
284
+ * const create = this.logger.action('creating config/auth.ts')
285
+ * try { … ; create.displayDuration().succeeded() }
286
+ * catch (error) { create.failed(error) }
287
+ */
288
+ action(title: string): Action {
289
+ return new Action(this.#ui, title)
290
+ }
291
+
292
+ #write(
293
+ label: string,
294
+ colour: StyleName,
295
+ message: string,
296
+ stream: Stream,
297
+ options: MessageOptions = {},
298
+ ): void {
299
+ const tag = this.#ui.colors[colour](label)
300
+ // Per-message prefix/suffix win over the sticky ones.
301
+ const prefix = options.prefix === undefined ? this.#prefixText : String(options.prefix)
302
+ const suffix = options.suffix === undefined ? this.#suffixText : String(options.suffix)
303
+ const parts = [
304
+ prefix === '' ? '' : this.#ui.colors.dim(prefix),
305
+ `[ ${tag} ]`,
306
+ message,
307
+ suffix === '' ? '' : this.#ui.colors.dim(suffix),
308
+ ].filter((part) => part !== '')
309
+ this.#ui.write(parts.join(' '), stream)
310
+ }
311
+ }
312
+
313
+ /** Per-message decoration (Console `logger.info(msg, { prefix, suffix })`). */
314
+ export interface MessageOptions {
315
+ prefix?: string | number
316
+ suffix?: string | number
317
+ }
318
+
319
+ /**
320
+ * An "in progress" message with animated dots (Console `logger.await`).
321
+ *
322
+ * Only animates on a TTY. Elsewhere it prints the message once and each
323
+ * `update` once more, which keeps logs and test assertions readable.
324
+ */
325
+ export class Animation {
326
+ readonly #ui: Ui
327
+ #message: string
328
+ #options: MessageOptions
329
+ #timer: ReturnType<typeof setInterval> | undefined
330
+ #frame = 0
331
+
332
+ constructor(ui: Ui, message: string, options: MessageOptions) {
333
+ this.#ui = ui
334
+ this.#message = message
335
+ this.#options = options
336
+ }
337
+
338
+ start(): this {
339
+ this.#print()
340
+ if (this.#animatable()) {
341
+ this.#timer = setInterval(() => {
342
+ this.#frame = (this.#frame + 1) % 4
343
+ this.#redraw()
344
+ }, 300)
345
+ // Never hold the process open for a decoration.
346
+ this.#timer.unref?.()
347
+ }
348
+ return this
349
+ }
350
+
351
+ update(message: string, options: MessageOptions = {}): this {
352
+ this.#message = message
353
+ this.#options = options
354
+ this.#frame = 0
355
+ if (this.#animatable()) this.#redraw()
356
+ else this.#print()
357
+ return this
358
+ }
359
+
360
+ stop(): void {
361
+ if (this.#timer !== undefined) {
362
+ clearInterval(this.#timer)
363
+ this.#timer = undefined
364
+ stdout.write('\n')
365
+ }
366
+ }
367
+
368
+ #animatable(): boolean {
369
+ return this.#ui.mode === 'normal' && Boolean(stdout.isTTY)
370
+ }
371
+
372
+ #line(): string {
373
+ const dots = '.'.repeat(this.#frame)
374
+ const suffix =
375
+ this.#options.suffix === undefined
376
+ ? ''
377
+ : ` ${this.#ui.colors.dim(String(this.#options.suffix))}`
378
+ const prefix =
379
+ this.#options.prefix === undefined
380
+ ? ''
381
+ : `${this.#ui.colors.dim(String(this.#options.prefix))} `
382
+ return `${prefix}${this.#message}${dots}${suffix}`
383
+ }
384
+
385
+ #print(): void {
386
+ this.#ui.write(this.#line())
387
+ }
388
+
389
+ #redraw(): void {
390
+ // Rewrite in place instead of stacking a line per frame.
391
+ stdout.write(`\r\u001B[2K${this.#line()}`)
392
+ }
393
+ }
394
+
395
+ /** A pending action, reported through one of its terminal methods. */
396
+ export class Action {
397
+ readonly #ui: Ui
398
+ readonly #title: string
399
+ readonly #startedAt: number
400
+ #withDuration = false
401
+
402
+ constructor(ui: Ui, title: string, startedAt = Date.now()) {
403
+ this.#ui = ui
404
+ this.#title = title
405
+ this.#startedAt = startedAt
406
+ }
407
+
408
+ /** Append how long the action took. */
409
+ displayDuration(): this {
410
+ this.#withDuration = true
411
+ return this
412
+ }
413
+
414
+ succeeded(): void {
415
+ this.#report(this.#ui.colors.green('DONE'), 'stdout')
416
+ }
417
+
418
+ skipped(reason?: string): void {
419
+ const suffix = reason === undefined ? '' : ` (${reason})`
420
+ this.#report(`${this.#ui.colors.yellow('SKIPPED')}${suffix}`, 'stdout')
421
+ }
422
+
423
+ failed(error: unknown): void {
424
+ this.#report(`${this.#ui.colors.red('FAILED')} ${messageOf(error)}`, 'stderr')
425
+ }
426
+
427
+ #report(status: string, stream: Stream): void {
428
+ const duration = this.#withDuration ? ` ${this.#ui.colors.dim(`(${this.#elapsed()})`)}` : ''
429
+ this.#ui.write(`${status} ${this.#title}${duration}`, stream)
430
+ }
431
+
432
+ #elapsed(): string {
433
+ const ms = Date.now() - this.#startedAt
434
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`
435
+ }
436
+ }
437
+
438
+ /** A cell with its alignment (Console `{ content, hAlign }`). */
439
+ export interface TableCell {
440
+ content: string
441
+ hAlign?: 'left' | 'right' | 'center'
442
+ }
443
+
444
+ export type TableInput = string | TableCell
445
+
446
+ /** A column-aligned table (Console `ui.table()`). */
447
+ export class Table {
448
+ readonly #ui: Ui
449
+ #headCells: TableCell[] = []
450
+ readonly #rows: TableCell[][] = []
451
+ #full = false
452
+ #fluidColumn = 0
453
+
454
+ constructor(ui: Ui) {
455
+ this.#ui = ui
456
+ }
457
+
458
+ head(columns: readonly TableInput[]): this {
459
+ this.#headCells = columns.map(toCell)
460
+ return this
461
+ }
462
+
463
+ row(cells: readonly TableInput[]): this {
464
+ this.#rows.push(cells.map(toCell))
465
+ return this
466
+ }
467
+
468
+ /**
469
+ * Stretch the table to the terminal width, the first column absorbing the
470
+ * slack (Console `fullWidth`). Falls back to content width when the width is
471
+ * unknown — a pipe has no columns.
472
+ */
473
+ fullWidth(): this {
474
+ this.#full = true
475
+ return this
476
+ }
477
+
478
+ /**
479
+ * Which column absorbs the slack in full-width mode (Console
480
+ * `fluidColumnIndex`). Defaults to the first.
481
+ */
482
+ fluidColumnIndex(index: number): this {
483
+ // A negative or fractional index would make `fullWidth()` silently do
484
+ // nothing — the kind of failure that is noticed three screens later.
485
+ if (!Number.isInteger(index) || index < 0) {
486
+ throw new ReamError(
487
+ 'E_CONSOLE_INVALID_COLUMN',
488
+ `fluidColumnIndex(${index}) is not a column position.`,
489
+ { hint: 'Pass a zero-based integer index.' },
490
+ )
491
+ }
492
+ this.#fluidColumn = index
493
+ return this
494
+ }
495
+
496
+ render(): void {
497
+ const all = this.#headCells.length > 0 ? [this.#headCells, ...this.#rows] : this.#rows
498
+ if (all.length === 0) return
499
+
500
+ // Widths are measured on the VISIBLE text: a cell coloured with
501
+ // `colors.green('DONE')` carries escape codes that must not shift columns.
502
+ const columns = Math.max(...all.map((row) => row.length))
503
+ const widths = Array.from({ length: columns }, (_, index) =>
504
+ Math.max(...all.map((row) => visibleWidth(row[index]?.content ?? ''))),
505
+ )
506
+
507
+ if (this.#full) {
508
+ // Known only now: the column count comes from the rows.
509
+ if (this.#fluidColumn >= columns) {
510
+ throw new ReamError(
511
+ 'E_CONSOLE_INVALID_COLUMN',
512
+ `fluidColumnIndex(${this.#fluidColumn}) is out of range — the table has ${columns} column(s).`,
513
+ )
514
+ }
515
+ const available = stdout.columns ?? 0
516
+ const used = widths.reduce((total, width) => total + width, 0) + (columns - 1) * 2
517
+ // Only ever grows the fluid column; shrinking would truncate content.
518
+ const fluid = this.#fluidColumn
519
+ if (available > used && widths[fluid] !== undefined) widths[fluid] += available - used
520
+ }
521
+
522
+ const line = (cells: readonly TableCell[]): string =>
523
+ cells
524
+ .map((cell, index) => align(cell, widths[index] ?? 0))
525
+ .join(' ')
526
+ .trimEnd()
527
+
528
+ if (this.#headCells.length > 0) {
529
+ this.#ui.write(
530
+ line(
531
+ this.#headCells.map((cell) => ({
532
+ ...cell,
533
+ content: this.#ui.colors.bold(cell.content),
534
+ })),
535
+ ),
536
+ )
537
+ this.#ui.write(widths.map((width) => '─'.repeat(width)).join(' '))
538
+ this.#ui.captureTableHead(this.#headCells.map((cell) => cell.content))
539
+ }
540
+ for (const row of this.#rows) {
541
+ this.#ui.write(line(row))
542
+ // Recorded unpadded: an assertion compares data, not layout.
543
+ this.#ui.captureTableRow(row.map((cell) => cell.content))
544
+ }
545
+ }
546
+ }
547
+
548
+ function toCell(input: TableInput): TableCell {
549
+ return typeof input === 'string' ? { content: input } : { ...input }
550
+ }
551
+
552
+ /** Pad a cell to `width`, honouring its horizontal alignment. */
553
+ function align(cell: TableCell, width: number): string {
554
+ const slack = Math.max(0, width - visibleWidth(cell.content))
555
+ if (cell.hAlign === 'right') return ' '.repeat(slack) + cell.content
556
+ if (cell.hAlign === 'center') {
557
+ const left = Math.floor(slack / 2)
558
+ return ' '.repeat(left) + cell.content + ' '.repeat(slack - left)
559
+ }
560
+ return cell.content + ' '.repeat(slack)
561
+ }
562
+
563
+ /**
564
+ * A bordered box — `sticker()` for a highlighted block, `instructions()` for a
565
+ * decorated list of steps.
566
+ */
567
+ export class Box {
568
+ readonly #ui: Ui
569
+ readonly #kind: 'sticker' | 'instructions'
570
+ readonly #lines: string[] = []
571
+
572
+ constructor(ui: Ui, kind: 'sticker' | 'instructions') {
573
+ this.#ui = ui
574
+ this.#kind = kind
575
+ }
576
+
577
+ add(line: string): this {
578
+ this.#lines.push(line)
579
+ return this
580
+ }
581
+
582
+ render(): void {
583
+ if (this.#lines.length === 0) return
584
+
585
+ // Instructions are steps, marked with a pointer as Console renders them; a
586
+ // sticker is a plain highlighted block.
587
+ const decorated =
588
+ this.#kind === 'instructions'
589
+ ? this.#lines.map((line) => (line === '' ? line : `${this.#ui.colors.dim('>')} ${line}`))
590
+ : this.#lines
591
+
592
+ const width = Math.max(...decorated.map(visibleWidth))
593
+ const border = this.#ui.colors.dim('─'.repeat(width + 2))
594
+
595
+ this.#ui.write(`${this.#ui.colors.dim('┌')}${border}${this.#ui.colors.dim('┐')}`)
596
+ for (const line of decorated) {
597
+ this.#ui.write(`${this.#ui.colors.dim('│')} ${pad(line, width)} ${this.#ui.colors.dim('│')}`)
598
+ }
599
+ this.#ui.write(`${this.#ui.colors.dim('└')}${border}${this.#ui.colors.dim('┘')}`)
600
+ }
601
+ }
602
+
603
+ /** Handed to a task callback so it can report progress and failure. */
604
+ export class TaskContext {
605
+ readonly #ui: Ui
606
+ readonly #title: string
607
+ readonly #verbose: boolean
608
+ #lastMessage = ''
609
+ #failure: Error | undefined
610
+
611
+ constructor(ui: Ui, title: string, verbose = false) {
612
+ this.#ui = ui
613
+ this.#title = title
614
+ this.#verbose = verbose
615
+ }
616
+
617
+ /**
618
+ * Report progress.
619
+ *
620
+ * Verbose prints every message; minimal keeps only the last one, surfaced on
621
+ * the task's final line. A progress loop otherwise floods the output with a
622
+ * line per percent.
623
+ */
624
+ update(message: string): void {
625
+ this.#lastMessage = message
626
+ if (this.#verbose) {
627
+ this.#ui.write(` ${this.#ui.colors.dim(`${this.#title}: ${message}`)}`)
628
+ }
629
+ }
630
+
631
+ /** @internal The last progress message, shown when the task ends. */
632
+ get lastMessage(): string {
633
+ return this.#lastMessage
634
+ }
635
+
636
+ /**
637
+ * Mark the task as failed. Returned from the callback rather than thrown, so
638
+ * an expected failure reads like a value — Console's `return task.error(…)`.
639
+ */
640
+ error(reason: string | Error): Error {
641
+ this.#failure = reason instanceof Error ? reason : new Error(reason)
642
+ return this.#failure
643
+ }
644
+
645
+ /** @internal */
646
+ get failure(): Error | undefined {
647
+ return this.#failure
648
+ }
649
+ }
650
+
651
+ export interface TaskOutcome {
652
+ title: string
653
+ state: 'succeeded' | 'failed'
654
+ message: string
655
+ error?: Error
656
+ }
657
+
658
+ /**
659
+ * A sequence of steps with their outcome (Console `ui.tasks()`).
660
+ *
661
+ * Sequential on purpose: the point is a readable progress report, and running
662
+ * them concurrently would interleave the lines into noise. A failing task stops
663
+ * the run — the ones after it usually depend on it.
664
+ */
665
+ export interface TasksOptions {
666
+ /**
667
+ * Print every progress message instead of only the last one.
668
+ *
669
+ * Minimal is the default, as in Console: a hundred `Downloaded 42%` lines make a
670
+ * transcript unreadable. Verbose is what a `--verbose` flag turns on.
671
+ */
672
+ verbose?: boolean
673
+ }
674
+
675
+ export class Tasks {
676
+ readonly #ui: Ui
677
+ readonly #verbose: boolean
678
+ readonly #entries: Array<{ title: string; work: (task: TaskContext) => Promise<unknown> }> = []
679
+ readonly #outcomes: TaskOutcome[] = []
680
+
681
+ constructor(ui: Ui, options: TasksOptions = {}) {
682
+ this.#ui = ui
683
+ this.#verbose = options.verbose === true
684
+ }
685
+
686
+ add(title: string, work: (task: TaskContext) => Promise<unknown>): this {
687
+ this.#entries.push({ title, work })
688
+ return this
689
+ }
690
+
691
+ /** Results in declaration order, including the task that failed. */
692
+ get outcomes(): TaskOutcome[] {
693
+ return [...this.#outcomes]
694
+ }
695
+
696
+ async run(): Promise<TaskOutcome[]> {
697
+ for (const entry of this.#entries) {
698
+ const context = new TaskContext(this.#ui, entry.title, this.#verbose)
699
+ let message = ''
700
+ let failure: Error | undefined
701
+
702
+ try {
703
+ const returned = await entry.work(context)
704
+ failure = context.failure ?? (returned instanceof Error ? returned : undefined)
705
+ if (failure === undefined) {
706
+ message = returned === undefined ? context.lastMessage : String(returned)
707
+ }
708
+ } catch (err) {
709
+ failure = err instanceof Error ? err : new Error(String(err))
710
+ }
711
+
712
+ if (failure === undefined) {
713
+ this.#outcomes.push({ title: entry.title, state: 'succeeded', message })
714
+ this.#ui.write(
715
+ `${this.#ui.colors.green('✔')} ${entry.title}` +
716
+ (message === '' ? '' : ` ${this.#ui.colors.dim(message)}`),
717
+ )
718
+ continue
719
+ }
720
+
721
+ this.#outcomes.push({
722
+ title: entry.title,
723
+ state: 'failed',
724
+ message: failure.message,
725
+ error: failure,
726
+ })
727
+ this.#ui.write(
728
+ `${this.#ui.colors.red('✖')} ${entry.title} ${this.#ui.colors.dim(failure.message)}`,
729
+ 'stderr',
730
+ )
731
+ // Stop here: later steps normally build on this one.
732
+ break
733
+ }
734
+
735
+ return this.outcomes
736
+ }
737
+ }
738
+
739
+ function messageOf(value: unknown): string {
740
+ if (value instanceof Error) return value.message
741
+ return String(value)
742
+ }
743
+
744
+ /** Width without ANSI escapes, so colour never shifts a column. */
745
+ function visibleWidth(text: string): number {
746
+ return stripAnsi(text).length
747
+ }
748
+
749
+ function pad(text: string, width: number): string {
750
+ return text + ' '.repeat(Math.max(0, width - visibleWidth(text)))
751
+ }
752
+
753
+ function stripAnsi(text: string): string {
754
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escapes is the point
755
+ return text.replace(/\u001B\[[0-9;]*m/g, '')
756
+ }