@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.
- package/README.md +65 -0
- package/dist/Ignitor.d.ts +115 -7
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +233 -37
- package/dist/Ignitor.js.map +1 -1
- package/dist/console/BaseCommand.d.ts +217 -0
- package/dist/console/BaseCommand.d.ts.map +1 -0
- package/dist/console/BaseCommand.js +340 -0
- package/dist/console/BaseCommand.js.map +1 -0
- package/dist/console/Console.d.ts +86 -0
- package/dist/console/Console.d.ts.map +1 -0
- package/dist/console/Console.js +134 -0
- package/dist/console/Console.js.map +1 -0
- package/dist/console/ExceptionHandler.d.ts +28 -0
- package/dist/console/ExceptionHandler.d.ts.map +1 -0
- package/dist/console/ExceptionHandler.js +76 -0
- package/dist/console/ExceptionHandler.js.map +1 -0
- package/dist/console/HelpCommand.d.ts +16 -0
- package/dist/console/HelpCommand.d.ts.map +1 -0
- package/dist/console/HelpCommand.js +40 -0
- package/dist/console/HelpCommand.js.map +1 -0
- package/dist/console/IndexGenerator.d.ts +24 -0
- package/dist/console/IndexGenerator.d.ts.map +1 -0
- package/dist/console/IndexGenerator.js +65 -0
- package/dist/console/IndexGenerator.js.map +1 -0
- package/dist/console/Kernel.d.ts +298 -0
- package/dist/console/Kernel.d.ts.map +1 -0
- package/dist/console/Kernel.js +1120 -0
- package/dist/console/Kernel.js.map +1 -0
- package/dist/console/ListCommand.d.ts +18 -0
- package/dist/console/ListCommand.d.ts.map +1 -0
- package/dist/console/ListCommand.js +60 -0
- package/dist/console/ListCommand.js.map +1 -0
- package/dist/console/assertions.d.ts +20 -0
- package/dist/console/assertions.d.ts.map +1 -0
- package/dist/console/assertions.js +84 -0
- package/dist/console/assertions.js.map +1 -0
- package/dist/console/cliui.d.ts +248 -0
- package/dist/console/cliui.d.ts.map +1 -0
- package/dist/console/cliui.js +587 -0
- package/dist/console/cliui.js.map +1 -0
- package/dist/console/decorators.d.ts +90 -0
- package/dist/console/decorators.d.ts.map +1 -0
- package/dist/console/decorators.js +142 -0
- package/dist/console/decorators.js.map +1 -0
- package/dist/console/index.d.ts +32 -0
- package/dist/console/index.d.ts.map +1 -0
- package/dist/console/index.js +24 -0
- package/dist/console/index.js.map +1 -0
- package/dist/console/loaders.d.ts +52 -0
- package/dist/console/loaders.d.ts.map +1 -0
- package/dist/console/loaders.js +131 -0
- package/dist/console/loaders.js.map +1 -0
- package/dist/console/parser.d.ts +86 -0
- package/dist/console/parser.d.ts.map +1 -0
- package/dist/console/parser.js +440 -0
- package/dist/console/parser.js.map +1 -0
- package/dist/console/prompts.d.ts +124 -0
- package/dist/console/prompts.d.ts.map +1 -0
- package/dist/console/prompts.js +364 -0
- package/dist/console/prompts.js.map +1 -0
- package/dist/console/tracing.d.ts +17 -0
- package/dist/console/tracing.d.ts.map +1 -0
- package/dist/console/tracing.js +11 -0
- package/dist/console/tracing.js.map +1 -0
- package/dist/console/types.d.ts +255 -0
- package/dist/console/types.d.ts.map +1 -0
- package/dist/console/types.js +25 -0
- package/dist/console/types.js.map +1 -0
- package/dist/console/ui.d.ts +20 -0
- package/dist/console/ui.d.ts.map +1 -0
- package/dist/console/ui.js +31 -0
- package/dist/console/ui.js.map +1 -0
- package/dist/console/utils.d.ts +27 -0
- package/dist/console/utils.d.ts.map +1 -0
- package/dist/console/utils.js +56 -0
- package/dist/console/utils.js.map +1 -0
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +40 -12
- package/dist/container/Container.js.map +1 -1
- package/dist/container/types.d.ts +14 -2
- package/dist/container/types.d.ts.map +1 -1
- package/dist/decorators/Service.d.ts +7 -0
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +23 -3
- package/dist/decorators/Service.js.map +1 -1
- package/dist/errors/ReamError.d.ts +25 -0
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +5 -0
- package/dist/errors/ReamError.js.map +1 -1
- package/dist/errors/prettyPrintError.d.ts +9 -0
- package/dist/errors/prettyPrintError.d.ts.map +1 -0
- package/dist/errors/prettyPrintError.js +22 -0
- package/dist/errors/prettyPrintError.js.map +1 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts +29 -8
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +39 -1
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +13 -2
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/services/app.d.ts +1 -1
- package/dist/services/app.js +1 -1
- package/dist/services/console.d.ts +21 -0
- package/dist/services/console.d.ts.map +1 -0
- package/dist/services/console.js +35 -0
- package/dist/services/console.js.map +1 -0
- package/dist/testing/ApiResponse.d.ts +218 -0
- package/dist/testing/ApiResponse.d.ts.map +1 -0
- package/dist/testing/ApiResponse.js +576 -0
- package/dist/testing/ApiResponse.js.map +1 -0
- package/dist/testing/RequestBuilder.d.ts +132 -72
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +389 -222
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +34 -39
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +48 -56
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.js +3 -3
- package/dist/utils/Macroable.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +15 -2
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Ignitor.ts +340 -42
- package/src/console/BaseCommand.ts +467 -0
- package/src/console/Console.ts +174 -0
- package/src/console/ExceptionHandler.ts +91 -0
- package/src/console/HelpCommand.ts +37 -0
- package/src/console/IndexGenerator.ts +79 -0
- package/src/console/Kernel.ts +1382 -0
- package/src/console/ListCommand.ts +55 -0
- package/src/console/assertions.ts +114 -0
- package/src/console/cliui.ts +756 -0
- package/src/console/decorators.ts +199 -0
- package/src/console/index.ts +67 -0
- package/src/console/loaders.ts +145 -0
- package/src/console/parser.ts +598 -0
- package/src/console/prompts.ts +556 -0
- package/src/console/tracing.ts +22 -0
- package/src/console/types.ts +280 -0
- package/src/console/ui.ts +37 -0
- package/src/console/utils.ts +69 -0
- package/src/container/Container.ts +55 -13
- package/src/container/types.ts +15 -2
- package/src/decorators/Service.ts +28 -3
- package/src/errors/ReamError.ts +31 -0
- package/src/errors/prettyPrintError.ts +20 -0
- package/src/index.ts +30 -4
- package/src/middleware/Pipeline.ts +76 -5
- package/src/rpc/RpcRouter.ts +16 -2
- package/src/services/app.ts +1 -1
- package/src/services/console.ts +42 -0
- package/src/testing/ApiResponse.ts +691 -0
- package/src/testing/RequestBuilder.ts +498 -275
- package/src/testing/TestClient.ts +63 -86
- package/src/utils/Macroable.ts +3 -3
- package/dist/console/CommandRunner.d.ts +0 -18
- package/dist/console/CommandRunner.d.ts.map +0 -1
- package/dist/console/CommandRunner.js +0 -65
- package/dist/console/CommandRunner.js.map +0 -1
- 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
|
+
}
|