@kaynooo/cli-kit 0.0.0-stage → 0.8.0
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/LICENSE +21 -0
- package/README.md +65 -2
- package/package.json +36 -4
- package/src/args.ts +234 -0
- package/src/chart.ts +88 -0
- package/src/cli.ts +296 -0
- package/src/config.ts +59 -0
- package/src/format.ts +163 -0
- package/src/index.ts +8 -0
- package/src/process.ts +120 -0
- package/src/progress.ts +314 -0
- package/src/prompt.ts +331 -0
- package/src/shell.ts +373 -0
- package/src/style.ts +103 -0
- package/src/test.ts +42 -0
- package/src/text.ts +21 -0
package/src/shell.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// `text` is the piece as written, `command` the same less its heredoc bodies, which are data and never commands; `separator` is what ends it (`|`, `&&`, `;`, a newline...), empty for the last; `expanded` holds the bodies of its unquoted heredocs, where the shell still runs `$(...)`.
|
|
2
|
+
export interface Piece { text: string, start: number, end: number, command: string, separator: string, expanded: string }
|
|
3
|
+
export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean }
|
|
4
|
+
|
|
5
|
+
const SEPARATORS = new Set(['\n', ';', '|'])
|
|
6
|
+
const PLAIN_BREAKERS = new Set(['<', '>', '(', ')', '&', '|'])
|
|
7
|
+
const WHITESPACE = /\s/
|
|
8
|
+
const ASSIGNMENT = /^\w+=/
|
|
9
|
+
const TRAILING_SLASHES = /\/+$/
|
|
10
|
+
const LEADING_TILDE = /^~(?=\/|$)/
|
|
11
|
+
const LEADING_TABS = /^\t+/
|
|
12
|
+
const OPENERS = /^[({]+/
|
|
13
|
+
const CLOSERS = /[)}]+$/
|
|
14
|
+
const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/
|
|
15
|
+
// Programs that run the command following them, which is the one that counts.
|
|
16
|
+
const WRAPPERS = new Set(['command', 'env', 'sudo', 'exec', 'nohup', 'time'])
|
|
17
|
+
|
|
18
|
+
// A lone `&` sends the command before it to the background, unless it belongs to a redirection such as `2>&1` or `&>`.
|
|
19
|
+
function separatorWidth(command: string, i: number): number {
|
|
20
|
+
const pair = command.slice(i, i + 2)
|
|
21
|
+
|
|
22
|
+
if (pair === '&&' || pair === '||')
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
const char = command[i] ?? ''
|
|
26
|
+
|
|
27
|
+
if (char === '&')
|
|
28
|
+
return command[i - 1] === '>' || command[i - 1] === '<' || command[i + 1] === '>' ? 0 : 1
|
|
29
|
+
|
|
30
|
+
return SEPARATORS.has(char) ? 1 : 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Where the heredocs opened on a line end: from the newline closing that line to the end of the last delimiter line.
|
|
34
|
+
function heredocEnd(command: string, newline: number, words: readonly { word: string, stripsTabs: boolean }[]): number {
|
|
35
|
+
let lineStart = newline + 1
|
|
36
|
+
|
|
37
|
+
for (const { word, stripsTabs } of words) {
|
|
38
|
+
while (lineStart <= command.length) {
|
|
39
|
+
const next = command.indexOf('\n', lineStart)
|
|
40
|
+
const lineEnd = next === -1 ? command.length : next
|
|
41
|
+
const line = command.slice(lineStart, lineEnd)
|
|
42
|
+
|
|
43
|
+
lineStart = lineEnd + 1
|
|
44
|
+
|
|
45
|
+
if ((stripsTabs ? line.replace(LEADING_TABS, '') : line) === word)
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Math.min(lineStart - 1, command.length)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function withoutRanges(command: string, start: number, end: number, ranges: readonly [number, number][]): string {
|
|
54
|
+
let text = ''
|
|
55
|
+
let at = start
|
|
56
|
+
|
|
57
|
+
for (const [from, to] of ranges) {
|
|
58
|
+
if (to <= start || from >= end)
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
text += command.slice(at, Math.max(at, from))
|
|
62
|
+
at = Math.max(at, to)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return text + command.slice(at, end)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Splits on the shell separators outside quotes and heredocs, so the `;` and `|` of an inline script stay in its piece, each piece keeping where it sits in the command.
|
|
69
|
+
export function splitCommand(command: string): Piece[] {
|
|
70
|
+
const bounds: [number, number, string][] = []
|
|
71
|
+
const bodies: [number, number, boolean][] = []
|
|
72
|
+
let heredocs: { word: string, stripsTabs: boolean, isQuoted: boolean }[] = []
|
|
73
|
+
let start = 0
|
|
74
|
+
let quote: string | null = null
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < command.length; i++) {
|
|
77
|
+
const char = command[i] ?? ''
|
|
78
|
+
|
|
79
|
+
if (char === '\\' && quote !== '\'') {
|
|
80
|
+
i++
|
|
81
|
+
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (quote !== null) {
|
|
86
|
+
if (char === quote)
|
|
87
|
+
quote = null
|
|
88
|
+
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (char === '\'' || char === '"') {
|
|
93
|
+
quote = char
|
|
94
|
+
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' ? HEREDOC.exec(command.slice(i)) : null
|
|
99
|
+
|
|
100
|
+
if (heredoc) {
|
|
101
|
+
heredocs.push({ word: heredoc[2] ?? heredoc[3] ?? heredoc[4] ?? '', stripsTabs: heredoc[1] === '-', isQuoted: heredoc[4] === undefined })
|
|
102
|
+
i += heredoc[0].length - 1
|
|
103
|
+
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (char === '\n' && heredocs.length > 0) {
|
|
108
|
+
const end = heredocEnd(command, i, heredocs)
|
|
109
|
+
|
|
110
|
+
bodies.push([i, end, heredocs.some(each => !each.isQuoted)])
|
|
111
|
+
heredocs = []
|
|
112
|
+
i = end - 1
|
|
113
|
+
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const width = separatorWidth(command, i)
|
|
118
|
+
|
|
119
|
+
if (width === 0)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
bounds.push([start, i, command.slice(i, i + width)])
|
|
123
|
+
i += width - 1
|
|
124
|
+
start = i + 1
|
|
125
|
+
}
|
|
126
|
+
bounds.push([start, command.length, ''])
|
|
127
|
+
|
|
128
|
+
const ranges = bodies.map(([from, to]): [number, number] => [from, to])
|
|
129
|
+
|
|
130
|
+
return bounds.map(([from, to, separator]) => ({
|
|
131
|
+
text: command.slice(from, to),
|
|
132
|
+
start: from,
|
|
133
|
+
end: to,
|
|
134
|
+
command: withoutRanges(command, from, to, ranges),
|
|
135
|
+
separator,
|
|
136
|
+
expanded: bodies.filter(([body, , expands]) => expands && body >= from && body < to).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join(''),
|
|
137
|
+
}))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// True when the text holds no redirect, substitution, subshell or group outside single quotes, and closes every quote it opens.
|
|
141
|
+
export function isPlain(text: string): boolean {
|
|
142
|
+
let quote: string | null = null
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < text.length; i++) {
|
|
145
|
+
const char = text[i] ?? ''
|
|
146
|
+
const isSubstitution = char === '`' || (char === '$' && text[i + 1] === '(')
|
|
147
|
+
|
|
148
|
+
if (char === '\\' && quote !== '\'')
|
|
149
|
+
i++
|
|
150
|
+
else if (quote !== null && char === quote)
|
|
151
|
+
quote = null
|
|
152
|
+
else if (quote === null && (char === '"' || char === '\''))
|
|
153
|
+
quote = char
|
|
154
|
+
else if (quote !== '\'' && (isSubstitution || (quote === null && PLAIN_BREAKERS.has(char))))
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return quote === null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The index of the `)` closing the `(` at `open`, past nested parentheses and quotes; the text's end when it never closes.
|
|
162
|
+
function closingParen(text: string, open: number): number {
|
|
163
|
+
let depth = 0
|
|
164
|
+
let quote: string | null = null
|
|
165
|
+
|
|
166
|
+
for (let i = open; i < text.length; i++) {
|
|
167
|
+
const char = text[i] ?? ''
|
|
168
|
+
|
|
169
|
+
if (char === '\\' && quote !== '\'') {
|
|
170
|
+
i++
|
|
171
|
+
}
|
|
172
|
+
else if (quote !== null) {
|
|
173
|
+
if (char === quote)
|
|
174
|
+
quote = null
|
|
175
|
+
}
|
|
176
|
+
else if (char === '\'' || char === '"') {
|
|
177
|
+
quote = char
|
|
178
|
+
}
|
|
179
|
+
else if (char === '(') {
|
|
180
|
+
depth++
|
|
181
|
+
}
|
|
182
|
+
else if (char === ')' && --depth === 0) {
|
|
183
|
+
return i
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return text.length
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The index of the backquote closing the one at `open`, past escaped characters; the text's end when it never closes.
|
|
191
|
+
function closingBackquote(text: string, open: number): number {
|
|
192
|
+
for (let i = open + 1; i < text.length; i++) {
|
|
193
|
+
if (text[i] === '\\')
|
|
194
|
+
i++
|
|
195
|
+
else if (text[i] === '`')
|
|
196
|
+
return i
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return text.length
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The bodies of the outermost `$(...)` and backquotes; quotes only count where `isQuoting`, as a heredoc body holds them as plain text.
|
|
203
|
+
function bodies(text: string, isQuoting: boolean): string[] {
|
|
204
|
+
const found: string[] = []
|
|
205
|
+
let quote: string | null = null
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < text.length; i++) {
|
|
208
|
+
const char = text[i] ?? ''
|
|
209
|
+
|
|
210
|
+
if (quote === '\'') {
|
|
211
|
+
if (char === '\'')
|
|
212
|
+
quote = null
|
|
213
|
+
}
|
|
214
|
+
else if (char === '\\') {
|
|
215
|
+
i++
|
|
216
|
+
}
|
|
217
|
+
else if (char === '$' && text[i + 1] === '(') {
|
|
218
|
+
const end = closingParen(text, i + 1)
|
|
219
|
+
|
|
220
|
+
found.push(text.slice(i + 2, end))
|
|
221
|
+
i = end
|
|
222
|
+
}
|
|
223
|
+
else if (char === '`') {
|
|
224
|
+
const end = closingBackquote(text, i)
|
|
225
|
+
|
|
226
|
+
found.push(text.slice(i + 1, end))
|
|
227
|
+
i = end
|
|
228
|
+
}
|
|
229
|
+
else if (isQuoting && char === '"') {
|
|
230
|
+
quote = quote === null ? char : null
|
|
231
|
+
}
|
|
232
|
+
else if (isQuoting && char === '\'' && quote === null) {
|
|
233
|
+
quote = char
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return found
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The commands each piece runs through `$(...)` or backquotes, inside double quotes and unquoted heredoc bodies too; a nested one is found by reading each result in turn.
|
|
241
|
+
export function substitutions(command: string): string[] {
|
|
242
|
+
return splitCommand(command).flatMap(piece => [...bodies(piece.command, true), ...bodies(piece.expanded, false)])
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A word as the shell would pass it; `isProse` when a quoted part of it holds whitespace, a message or a pattern rather than a path.
|
|
246
|
+
export interface Word { text: string, isProse: boolean }
|
|
247
|
+
|
|
248
|
+
// The words of a piece, quotes and line continuations removed.
|
|
249
|
+
export function words(text: string): Word[] {
|
|
250
|
+
const found: Word[] = []
|
|
251
|
+
let current = ''
|
|
252
|
+
let isStarted = false
|
|
253
|
+
let isProse = false
|
|
254
|
+
let quote: string | null = null
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < text.length; i++) {
|
|
257
|
+
const char = text[i] ?? ''
|
|
258
|
+
const isContinuation = char === '\\' && quote !== '\'' && text[i + 1] === '\n'
|
|
259
|
+
|
|
260
|
+
if (isContinuation)
|
|
261
|
+
i++
|
|
262
|
+
|
|
263
|
+
if (quote === null && (isContinuation || WHITESPACE.test(char))) {
|
|
264
|
+
if (isStarted)
|
|
265
|
+
found.push({ text: current, isProse })
|
|
266
|
+
|
|
267
|
+
current = ''
|
|
268
|
+
isStarted = false
|
|
269
|
+
isProse = false
|
|
270
|
+
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (isContinuation)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
isStarted = true
|
|
278
|
+
|
|
279
|
+
if (char === '\\' && quote !== '\'')
|
|
280
|
+
current += text[++i] ?? ''
|
|
281
|
+
else if (quote !== null && char === quote)
|
|
282
|
+
quote = null
|
|
283
|
+
else if (quote === null && (char === '"' || char === '\''))
|
|
284
|
+
quote = char
|
|
285
|
+
else
|
|
286
|
+
current += char
|
|
287
|
+
|
|
288
|
+
isProse ||= quote !== null && WHITESPACE.test(char)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (isStarted)
|
|
292
|
+
found.push({ text: current, isProse })
|
|
293
|
+
|
|
294
|
+
return found
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The words of a piece as the shell would pass them, quotes and line continuations removed.
|
|
298
|
+
export function tokenize(text: string): string[] {
|
|
299
|
+
return words(text).map(word => word.text)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The program a piece runs, by its base name, past any subshell opener, variable assignment or wrapper such as `env` or `sudo`.
|
|
303
|
+
export function parseCommand(text: string): Invocation {
|
|
304
|
+
const tokens = tokenize(text)
|
|
305
|
+
const assignments: string[] = []
|
|
306
|
+
let isWrapped = false
|
|
307
|
+
|
|
308
|
+
for (;;) {
|
|
309
|
+
const first = tokens[0] ?? ''
|
|
310
|
+
|
|
311
|
+
if (OPENERS.test(first)) {
|
|
312
|
+
isWrapped = true
|
|
313
|
+
const rest = first.replace(OPENERS, '')
|
|
314
|
+
|
|
315
|
+
if (rest === '')
|
|
316
|
+
tokens.shift()
|
|
317
|
+
else tokens[0] = rest
|
|
318
|
+
}
|
|
319
|
+
else if (ASSIGNMENT.test(first)) {
|
|
320
|
+
assignments.push(tokens.shift() ?? '')
|
|
321
|
+
}
|
|
322
|
+
else if (WRAPPERS.has(first)) {
|
|
323
|
+
isWrapped = true
|
|
324
|
+
tokens.shift()
|
|
325
|
+
|
|
326
|
+
while (tokens[0]?.startsWith('-')) tokens.shift()
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
break
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// the closing of a subshell or a group, `(git stash)` or `{ cd x && npm i; }`, ends the last word
|
|
333
|
+
const last = (tokens.at(-1) ?? '').replace(CLOSERS, '')
|
|
334
|
+
|
|
335
|
+
if (tokens.length > 0)
|
|
336
|
+
tokens.splice(-1, 1, ...(last === '' ? [] : [last]))
|
|
337
|
+
|
|
338
|
+
const [program = '', ...args] = tokens
|
|
339
|
+
|
|
340
|
+
return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Swaps each piece's text for its replacement, keeping the separators between them.
|
|
344
|
+
export function replacePieces(command: string, replacements: ReadonlyMap<Piece, string>): string {
|
|
345
|
+
let result = command
|
|
346
|
+
|
|
347
|
+
for (const [piece, text] of [...replacements].sort(([a], [b]) => b.start - a.start)) {
|
|
348
|
+
result = result.slice(0, piece.start) + text + result.slice(piece.end)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return result
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function parentDir(path: string): string {
|
|
355
|
+
const cut = path.replace(TRAILING_SLASHES, '').lastIndexOf('/')
|
|
356
|
+
|
|
357
|
+
return cut <= 0 ? '/' : path.slice(0, cut)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Resolves a shell path argument against a directory, `~` included, `.` and `..` folded.
|
|
361
|
+
export function resolvePath(base: string, arg: string, home: string): string {
|
|
362
|
+
const expanded = arg.replace(LEADING_TILDE, home)
|
|
363
|
+
const parts: string[] = []
|
|
364
|
+
|
|
365
|
+
for (const part of (expanded.startsWith('/') ? expanded : `${base}/${expanded}`).split('/')) {
|
|
366
|
+
if (part === '..')
|
|
367
|
+
parts.pop()
|
|
368
|
+
else if (part !== '' && part !== '.')
|
|
369
|
+
parts.push(part)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return `/${parts.join('/')}`
|
|
373
|
+
}
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { createColors } from '@kaynooo/utils/tools/colors'
|
|
3
|
+
|
|
4
|
+
export type Colors = ReturnType<typeof createColors>
|
|
5
|
+
|
|
6
|
+
export type Mark = 'ok' | 'warn' | 'problem' | 'note'
|
|
7
|
+
|
|
8
|
+
// The symbol each mark draws on a terminal, and the word it prints as anywhere else, so a log stays greppable.
|
|
9
|
+
const MARKS: Record<Mark, { symbol: string, word: string, paint: (colors: Colors) => (text: string) => string }> = {
|
|
10
|
+
ok: { symbol: '✓', word: 'ok:', paint: colors => colors.green },
|
|
11
|
+
warn: { symbol: '!', word: 'warning:', paint: colors => colors.yellow },
|
|
12
|
+
problem: { symbol: '✗', word: 'error:', paint: colors => colors.red },
|
|
13
|
+
note: { symbol: '·', word: 'note:', paint: colors => colors.dim },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A terminal that draws in place: not a log or a pipe, and not asked for plain output by `NO_COLOR` or a dumb `TERM`.
|
|
17
|
+
export function takesControlCodes(stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): boolean {
|
|
18
|
+
return stream.isTTY === true && env.NO_COLOR === undefined && env.NODE_DISABLE_COLORS === undefined && env.TERM !== 'dumb'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Colours that write escapes only to a stream taking control codes.
|
|
22
|
+
export function paint(stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): Colors {
|
|
23
|
+
return createColors({ stream: { isTTY: takesControlCodes(stream, env) }, env })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `✓ text` on a terminal, `ok: text` elsewhere; `detail` follows dimmed.
|
|
27
|
+
export function marked(mark: Mark, text: string, stream: { isTTY?: boolean }, detail?: string, env: Record<string, string | undefined> = process.env): string {
|
|
28
|
+
const colors = paint(stream, env)
|
|
29
|
+
const { symbol, word, paint: tint } = MARKS[mark]
|
|
30
|
+
const head = takesControlCodes(stream, env) ? tint(colors)(symbol) : word
|
|
31
|
+
|
|
32
|
+
return `${head} ${text}${detail === undefined ? '' : ` ${colors.dim(detail)}`}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ok(text: string, detail?: string): void {
|
|
36
|
+
console.log(marked('ok', text, process.stdout, detail))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function note(text: string, detail?: string): void {
|
|
40
|
+
console.log(marked('note', text, process.stdout, detail))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function warn(text: string, detail?: string): void {
|
|
44
|
+
console.error(marked('warn', text, process.stderr, detail))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function problem(text: string, detail?: string): void {
|
|
48
|
+
console.error(marked('problem', text, process.stderr, detail))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Role = 'accent' | 'id' | 'path' | 'label' | 'value'
|
|
52
|
+
|
|
53
|
+
// The tint each kind of data is drawn in, so every CLI colours the same things the same way.
|
|
54
|
+
const ROLES: Record<Role, (colors: Colors) => (text: string) => string> = {
|
|
55
|
+
accent: colors => colors.cyan,
|
|
56
|
+
id: colors => colors.yellow,
|
|
57
|
+
path: colors => colors.blue,
|
|
58
|
+
label: colors => colors.dim,
|
|
59
|
+
value: colors => colors.bold,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// `role('id', process.stdout)('#49')`: plain text on a stream that takes no control codes.
|
|
63
|
+
export function role(name: Role, stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): (text: string) => string {
|
|
64
|
+
return ROLES[name](paint(stream, env))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type Status = 'passed' | 'failed' | 'running' | 'queued' | 'skipped'
|
|
68
|
+
|
|
69
|
+
// What each status draws, and the words CI services report it by.
|
|
70
|
+
const STATUSES: Record<Status, { symbol: string, words: string[], paint: (colors: Colors) => (text: string) => string }> = {
|
|
71
|
+
passed: { symbol: '✓', words: ['passed', 'success', 'succeeded', 'ok'], paint: colors => colors.green },
|
|
72
|
+
failed: { symbol: '✗', words: ['failed', 'failure', 'error', 'errored'], paint: colors => colors.red },
|
|
73
|
+
running: { symbol: '●', words: ['running', 'in_progress', 'started'], paint: colors => colors.cyan },
|
|
74
|
+
queued: { symbol: '○', words: ['queued', 'waiting', 'pending', 'created', 'blocked', 'scheduled'], paint: colors => colors.yellow },
|
|
75
|
+
skipped: { symbol: '-', words: ['skipped', 'cancelled', 'canceled', 'manual'], paint: colors => colors.dim },
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const STATUS_OF = new Map(Object.entries(STATUSES).flatMap(([status, { words }]) => words.map(word => [word, status as Status])))
|
|
79
|
+
|
|
80
|
+
// The status a word such as `success` or `in_progress` stands for, undefined for one it does not know.
|
|
81
|
+
export function statusOf(word: string): Status | undefined {
|
|
82
|
+
return STATUS_OF.get(word.toLowerCase())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `✓ success` in green, `✗ failure` in red, `● running`, `○ waiting` or `- skipped` on a terminal; the word alone elsewhere, and a word of no known status as given.
|
|
86
|
+
export function badge(word: string, stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): string {
|
|
87
|
+
const status = statusOf(word)
|
|
88
|
+
|
|
89
|
+
if (status === undefined || !takesControlCodes(stream, env))
|
|
90
|
+
return word
|
|
91
|
+
|
|
92
|
+
const { symbol, paint: tint } = STATUSES[status]
|
|
93
|
+
|
|
94
|
+
return tint(paint(stream, env))(`${symbol} ${word}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A clickable OSC 8 link on a terminal; elsewhere `text (url)`, or the text alone when it is the url.
|
|
98
|
+
export function link(text: string, url: string, stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): string {
|
|
99
|
+
if (takesControlCodes(stream, env))
|
|
100
|
+
return `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\`
|
|
101
|
+
|
|
102
|
+
return text === url ? text : `${text} (${url})`
|
|
103
|
+
}
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { format } from 'node:util'
|
|
3
|
+
|
|
4
|
+
export interface Captured<T> {
|
|
5
|
+
result: T
|
|
6
|
+
// Everything printed to stdout (console.log, console.info and process.stdout.write).
|
|
7
|
+
out: string
|
|
8
|
+
// Everything printed to stderr (console.error, console.warn and process.stderr.write).
|
|
9
|
+
err: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Runs a command in-process and returns what it printed, so a test needs no spy on console. Output is restored even when the command throws.
|
|
13
|
+
export async function capture<T>(run: () => Promise<T> | T): Promise<Captured<T>> {
|
|
14
|
+
const written = { out: '', err: '' }
|
|
15
|
+
const saved = { log: console.log, info: console.info, error: console.error, warn: console.warn, stdout: process.stdout.write.bind(process.stdout), stderr: process.stderr.write.bind(process.stderr) }
|
|
16
|
+
const line = (name: 'out' | 'err') => (...parts: unknown[]) => {
|
|
17
|
+
written[name] += `${format(...parts)}\n`
|
|
18
|
+
}
|
|
19
|
+
const chunk = (name: 'out' | 'err') => (data: string | Uint8Array) => {
|
|
20
|
+
written[name] += typeof data === 'string' ? data : Buffer.from(data).toString()
|
|
21
|
+
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log = line('out')
|
|
26
|
+
console.info = line('out')
|
|
27
|
+
console.error = line('err')
|
|
28
|
+
console.warn = line('err')
|
|
29
|
+
process.stdout.write = chunk('out')
|
|
30
|
+
process.stderr.write = chunk('err')
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const result = await run()
|
|
34
|
+
|
|
35
|
+
return { result, ...written }
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
Object.assign(console, { log: saved.log, info: saved.info, error: saved.error, warn: saved.warn })
|
|
39
|
+
process.stdout.write = saved.stdout
|
|
40
|
+
process.stderr.write = saved.stderr
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[\\d;?]*[a-z]`, 'gi')
|
|
2
|
+
|
|
3
|
+
export function stripAnsi(text: string): string {
|
|
4
|
+
return text.replace(ANSI, '')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Edit distance between two names, for pointing a mistyped name at the one meant.
|
|
8
|
+
export function distance(a: string, b: string): number {
|
|
9
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index)
|
|
10
|
+
|
|
11
|
+
for (const [i, left] of [...a].entries()) {
|
|
12
|
+
const current = [i + 1]
|
|
13
|
+
|
|
14
|
+
for (const [j, right] of [...b].entries())
|
|
15
|
+
current.push(Math.min((previous[j + 1] ?? 0) + 1, (current[j] ?? 0) + 1, (previous[j] ?? 0) + (left === right ? 0 : 1)))
|
|
16
|
+
|
|
17
|
+
previous = current
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return previous[b.length] ?? 0
|
|
21
|
+
}
|