@kaynooo/cli-kit 0.0.0-stage → 0.7.3
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 +59 -2
- package/package.json +36 -4
- package/src/args.ts +189 -0
- package/src/chart.ts +61 -0
- package/src/cli.ts +234 -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 +211 -0
- package/src/prompt.ts +331 -0
- package/src/shell.ts +373 -0
- package/src/style.ts +73 -0
- package/src/test.ts +42 -0
- package/src/text.ts +21 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, resolve } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { fail } from './args'
|
|
5
|
+
|
|
6
|
+
// The nearest of `names` in `from` or a folder above it, the first name winning within one folder.
|
|
7
|
+
export function findUp(names: string | string[], from = process.cwd()): string | undefined {
|
|
8
|
+
const wanted = typeof names === 'string' ? [names] : names
|
|
9
|
+
|
|
10
|
+
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
11
|
+
const found = wanted.map(name => join(dir, name)).find(path => existsSync(path))
|
|
12
|
+
|
|
13
|
+
if (found !== undefined)
|
|
14
|
+
return found
|
|
15
|
+
|
|
16
|
+
if (dirname(dir) === dir)
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ConfigSettings {
|
|
22
|
+
// Where the search starts, the current folder by default.
|
|
23
|
+
from?: string
|
|
24
|
+
// A file named on the command line, read instead of searching; resolved against `from`.
|
|
25
|
+
path?: string | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LoadedConfig {
|
|
29
|
+
path: string
|
|
30
|
+
// The folder holding the config, which paths written in it are relative to.
|
|
31
|
+
dir: string
|
|
32
|
+
// Its default export, for the caller to check.
|
|
33
|
+
config: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Imports the nearest config named `names` (`.ts`, `.js` or `.json`), or the one `path` names; undefined when none is found. A named file missing, one failing to import or one without a default export ends the command.
|
|
37
|
+
export async function loadConfig(names: string | string[], { from = process.cwd(), path }: ConfigSettings = {}): Promise<LoadedConfig | undefined> {
|
|
38
|
+
const file = path === undefined ? findUp(names, from) : resolve(from, path)
|
|
39
|
+
|
|
40
|
+
if (file === undefined)
|
|
41
|
+
return undefined
|
|
42
|
+
|
|
43
|
+
if (!existsSync(file))
|
|
44
|
+
fail(`no file ${file}`, 1)
|
|
45
|
+
|
|
46
|
+
let module: { default?: unknown }
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
module = await import(file) as { default?: unknown }
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
fail(`${file} does not load: ${error instanceof Error ? error.message : String(error)}`, 1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (module.default === undefined)
|
|
56
|
+
fail(`${file} has no default export`, 1)
|
|
57
|
+
|
|
58
|
+
return { path: file, dir: dirname(file), config: module.default }
|
|
59
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { paint } from './style'
|
|
3
|
+
|
|
4
|
+
export { formatFileSize, formatUnit } from '@kaynooo/utils/format'
|
|
5
|
+
|
|
6
|
+
const UNITS = [['d', 86_400], ['h', 3600], ['m', 60], ['s', 1]] as const
|
|
7
|
+
|
|
8
|
+
// Renders as "13m 28s": the largest unit and the next one, dropped when zero.
|
|
9
|
+
export function formatSeconds(total: number): string {
|
|
10
|
+
let rest = Math.max(0, Math.round(total))
|
|
11
|
+
const amounts = UNITS.map(([unit, size]) => {
|
|
12
|
+
const amount = Math.floor(rest / size)
|
|
13
|
+
|
|
14
|
+
rest -= amount * size
|
|
15
|
+
|
|
16
|
+
return `${amount}${unit}`
|
|
17
|
+
})
|
|
18
|
+
const first = amounts.findIndex(amount => !amount.startsWith('0'))
|
|
19
|
+
|
|
20
|
+
if (first === -1)
|
|
21
|
+
return '0s'
|
|
22
|
+
|
|
23
|
+
const next = amounts[first + 1]
|
|
24
|
+
|
|
25
|
+
return next === undefined || next.startsWith('0') ? amounts[first] ?? '0s' : `${amounts[first]} ${next}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Like formatSeconds from a duration in milliseconds, but under a minute with one decimal so a fast step reads `0.3s`, not `0s`.
|
|
29
|
+
export function formatDuration(ms: number): string {
|
|
30
|
+
return ms < 60_000 ? `${(Math.max(0, ms) / 1000).toFixed(1)}s` : formatSeconds(ms / 1000)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The text up to the first separator, or all of it.
|
|
34
|
+
export function upTo(text: string, separator: string): string {
|
|
35
|
+
const end = text.indexOf(separator)
|
|
36
|
+
|
|
37
|
+
return end === -1 ? text : text.slice(0, end)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatAge(date: string | number, now = Date.now()): string {
|
|
41
|
+
const at = typeof date === 'number' ? date : Date.parse(date)
|
|
42
|
+
|
|
43
|
+
if (Number.isNaN(at))
|
|
44
|
+
return '-'
|
|
45
|
+
|
|
46
|
+
const seconds = (now - at) / 1000
|
|
47
|
+
|
|
48
|
+
return seconds < 60 ? 'just now' : `${upTo(formatSeconds(seconds), ' ')} ago`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function truncate(text: string, width: number): string {
|
|
52
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const LINE_BREAKS = /\s*\n\s*/g
|
|
56
|
+
const ESCAPE = String.fromCharCode(27)
|
|
57
|
+
|
|
58
|
+
export type Align = 'left' | 'right'
|
|
59
|
+
|
|
60
|
+
export interface TableSettings {
|
|
61
|
+
// Flattens each cell's line breaks to a space and cuts it to this many characters.
|
|
62
|
+
cellWidth?: number
|
|
63
|
+
// Takes the first row for column names, bold on a terminal.
|
|
64
|
+
header?: boolean
|
|
65
|
+
// Per column, `right` for numbers; a column not named is left-aligned.
|
|
66
|
+
align?: Align[]
|
|
67
|
+
// Narrows the widest column until a row fits this many characters, `process.stdout.columns` for the terminal's own width. Cells holding colours are never cut.
|
|
68
|
+
fit?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Columns in the visible width of a cell, escapes and wide characters accounted for.
|
|
72
|
+
export function textWidth(text: string): number {
|
|
73
|
+
return Bun.stringWidth(text)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function table(rows: string[][], { cellWidth, header = false, align = [], fit }: TableSettings = {}): string {
|
|
77
|
+
const cells = cellWidth === undefined ? rows : rows.map(row => row.map(cell => truncate(cell.replaceAll(LINE_BREAKS, ' '), cellWidth)))
|
|
78
|
+
const widths: number[] = []
|
|
79
|
+
|
|
80
|
+
for (const row of cells) {
|
|
81
|
+
for (const [index, cell] of row.entries())
|
|
82
|
+
widths[index] = Math.max(widths[index] ?? 0, textWidth(cell))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const shown = fit === undefined ? cells : narrow(cells, widths, fit)
|
|
86
|
+
const colors = paint(process.stdout)
|
|
87
|
+
const lines = shown.map(row => row.map((cell, index) => {
|
|
88
|
+
const width = widths[index] ?? 0
|
|
89
|
+
const gap = ' '.repeat(Math.max(0, width - textWidth(cell)))
|
|
90
|
+
|
|
91
|
+
if (align[index] === 'right')
|
|
92
|
+
return `${gap}${cell}`
|
|
93
|
+
|
|
94
|
+
return index === row.length - 1 ? cell : `${cell}${gap}`
|
|
95
|
+
}).join(' ').trimEnd())
|
|
96
|
+
|
|
97
|
+
return lines.map((line, index) => header && index === 0 ? colors.bold(line) : line).join('\n')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The rows with the widest cuttable column cut so every line fits `limit`, `widths` shrinking with it.
|
|
101
|
+
function narrow(rows: string[][], widths: number[], limit: number): string[][] {
|
|
102
|
+
const used = () => widths.reduce((sum, width) => sum + width, 0) + 2 * Math.max(0, widths.length - 1)
|
|
103
|
+
const cuttable = (index: number) => rows.every(row => !(row[index] ?? '').includes(ESCAPE))
|
|
104
|
+
const floor = 8
|
|
105
|
+
|
|
106
|
+
while (used() > limit) {
|
|
107
|
+
let widest = -1
|
|
108
|
+
|
|
109
|
+
for (const [index, width] of widths.entries()) {
|
|
110
|
+
if (cuttable(index) && width > floor && width > (widths[widest] ?? 0))
|
|
111
|
+
widest = index
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (widest === -1)
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
widths[widest] = Math.max(floor, (widths[widest] ?? 0) - (used() - limit))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return rows.map(row => row.map((cell, index) => textWidth(cell) > (widths[index] ?? 0) ? truncate(cell, widths[index] ?? 0) : cell))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// What a `--json` flag prints: the value indented by two spaces, on stdout.
|
|
124
|
+
export function printJson(value: unknown): void {
|
|
125
|
+
console.log(JSON.stringify(value, undefined, 2))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// `0.62` as `62%`.
|
|
129
|
+
export function formatPercent(ratio: number, digits = 0): string {
|
|
130
|
+
return `${(ratio * 100).toFixed(digits)}%`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A change with its sign: `+3`, `-3`, `0`.
|
|
134
|
+
export function formatDelta(value: number, digits = 0): string {
|
|
135
|
+
const text = value.toFixed(digits)
|
|
136
|
+
|
|
137
|
+
if (Number(text) === 0)
|
|
138
|
+
return text.replace('-', '')
|
|
139
|
+
|
|
140
|
+
return value > 0 ? `+${text}` : text
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ChangeSettings {
|
|
144
|
+
// Which way is an improvement; the other way is painted red.
|
|
145
|
+
better?: 'lower' | 'higher'
|
|
146
|
+
stream?: { isTTY?: boolean }
|
|
147
|
+
env?: Record<string, string | undefined>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// How `after` moved from `before` as a signed percentage (`-36%`), green when it improved and red when it did not; `-` when `before` is zero.
|
|
151
|
+
export function formatChange(before: number, after: number, { better = 'lower', stream = process.stdout, env = process.env }: ChangeSettings = {}): string {
|
|
152
|
+
if (before === 0)
|
|
153
|
+
return '-'
|
|
154
|
+
|
|
155
|
+
const change = (after - before) / before
|
|
156
|
+
const text = `${formatDelta(change * 100)}%`
|
|
157
|
+
const colors = paint(stream, env)
|
|
158
|
+
|
|
159
|
+
if (change === 0)
|
|
160
|
+
return colors.dim(text)
|
|
161
|
+
|
|
162
|
+
return (change < 0) === (better === 'lower') ? colors.green(text) : colors.red(text)
|
|
163
|
+
}
|
package/src/index.ts
ADDED
package/src/process.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { constants } from 'node:os'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
export interface Chunk {
|
|
6
|
+
stream: 'stdout' | 'stderr'
|
|
7
|
+
data: Uint8Array
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Ran {
|
|
11
|
+
code: number
|
|
12
|
+
// Both streams in the order they arrived.
|
|
13
|
+
chunks: Chunk[]
|
|
14
|
+
// The bytes of every chunk joined.
|
|
15
|
+
output: Buffer
|
|
16
|
+
text: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunSettings {
|
|
20
|
+
cwd?: string
|
|
21
|
+
env?: Record<string, string | undefined>
|
|
22
|
+
stdin?: 'inherit' | 'ignore'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HeldSignals {
|
|
26
|
+
// The last signal received, undefined while none has come.
|
|
27
|
+
readonly signal: NodeJS.Signals | undefined
|
|
28
|
+
// Passes each signal received from now on to `child`, so it ends and its code is the one to exit with.
|
|
29
|
+
forward: (child: { kill: (signal: NodeJS.Signals) => unknown }) => void
|
|
30
|
+
// Takes the handlers off again, and may be called more than once.
|
|
31
|
+
release: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
|
|
35
|
+
// A scheme of two letters or more, so a Windows drive is read as a path.
|
|
36
|
+
const SCHEME = /^[a-z][\w+.-]+:/i
|
|
37
|
+
const OPENERS: Partial<Record<NodeJS.Platform, string[]>> = { darwin: ['open'], win32: ['cmd', '/c', 'start', ''] }
|
|
38
|
+
|
|
39
|
+
async function collect(stream: ReadableStream<Uint8Array>, name: Chunk['stream'], chunks: Chunk[]): Promise<void> {
|
|
40
|
+
for await (const data of stream)
|
|
41
|
+
chunks.push({ stream: name, data })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The exit code a shell would report: the code, or 128 plus the signal.
|
|
45
|
+
export function exitCodeOf(code: number | null, signal: NodeJS.Signals | number | null): number {
|
|
46
|
+
if (code !== null)
|
|
47
|
+
return code
|
|
48
|
+
|
|
49
|
+
if (typeof signal === 'number')
|
|
50
|
+
return 128 + signal
|
|
51
|
+
|
|
52
|
+
return signal === null ? 129 : 128 + constants.signals[signal]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Takes SIGINT, SIGTERM and SIGHUP over until released. A signal goes to the child given to `forward`; without one, `cleanup` runs and the signal is raised again so the process ends as it would have; without either, it is only kept in `signal` for the caller to wind down on.
|
|
56
|
+
export function holdSignals(cleanup?: (signal: NodeJS.Signals) => void): HeldSignals {
|
|
57
|
+
let received: NodeJS.Signals | undefined
|
|
58
|
+
let child: Parameters<HeldSignals['forward']>[0] | undefined
|
|
59
|
+
const held: HeldSignals = {
|
|
60
|
+
get signal() {
|
|
61
|
+
return received
|
|
62
|
+
},
|
|
63
|
+
forward: (to) => {
|
|
64
|
+
child = to
|
|
65
|
+
},
|
|
66
|
+
release: () => {
|
|
67
|
+
for (const signal of SIGNALS)
|
|
68
|
+
process.off(signal, onSignal)
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onSignal(signal: NodeJS.Signals) {
|
|
73
|
+
received = signal
|
|
74
|
+
|
|
75
|
+
if (child !== undefined) {
|
|
76
|
+
child.kill(signal)
|
|
77
|
+
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (cleanup === undefined)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
held.release()
|
|
85
|
+
cleanup(signal)
|
|
86
|
+
process.kill(process.pid, signal)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const signal of SIGNALS)
|
|
90
|
+
process.on(signal, onSignal)
|
|
91
|
+
|
|
92
|
+
return held
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Opens a URL, or a file path, with the command BROWSER names or the platform's opener, run in `env` without waiting for it; false when that command cannot start.
|
|
96
|
+
export function openUrl(target: string, env: Record<string, string | undefined> = process.env, platform: NodeJS.Platform = process.platform): boolean {
|
|
97
|
+
const opener = env.BROWSER === undefined || env.BROWSER === '' ? OPENERS[platform] ?? ['xdg-open'] : [env.BROWSER]
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
Bun.spawn([...opener, SCHEME.test(target) ? target : resolve(target)], { env, stdio: ['ignore', 'ignore', 'ignore'] }).unref()
|
|
101
|
+
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Runs a command with stdout and stderr captured; throws when it cannot be started.
|
|
110
|
+
export async function runCommand(argv: string[], { cwd, env, stdin = 'ignore' }: RunSettings = {}): Promise<Ran> {
|
|
111
|
+
const chunks: Chunk[] = []
|
|
112
|
+
const proc = Bun.spawn(argv, { ...(cwd === undefined ? {} : { cwd }), ...(env === undefined ? {} : { env }), stdin, stdout: 'pipe', stderr: 'pipe' })
|
|
113
|
+
|
|
114
|
+
await Promise.all([collect(proc.stdout, 'stdout', chunks), collect(proc.stderr, 'stderr', chunks)])
|
|
115
|
+
await proc.exited
|
|
116
|
+
|
|
117
|
+
const output = Buffer.concat(chunks.map(({ data }) => data))
|
|
118
|
+
|
|
119
|
+
return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() }
|
|
120
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Colors } from './style'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { formatDuration } from './format'
|
|
4
|
+
import { holdSignals } from './process'
|
|
5
|
+
import { marked, paint, takesControlCodes } from './style'
|
|
6
|
+
|
|
7
|
+
export interface Progress {
|
|
8
|
+
step: string
|
|
9
|
+
done?: number | undefined
|
|
10
|
+
total?: number | undefined
|
|
11
|
+
unit?: string | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ProgressStream {
|
|
15
|
+
isTTY?: boolean
|
|
16
|
+
columns?: number | undefined
|
|
17
|
+
write: (text: string) => unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProgressLine {
|
|
21
|
+
update: (progress: Progress) => void
|
|
22
|
+
// Clears the line and leaves nothing behind.
|
|
23
|
+
stop: () => void
|
|
24
|
+
// Clears the line and leaves `✓ <text> <time since the line began>` in its place.
|
|
25
|
+
done: (text: string) => void
|
|
26
|
+
// Clears the line and leaves `✗ <text>` in its place.
|
|
27
|
+
failed: (text: string) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Env = Record<string, string | undefined>
|
|
31
|
+
|
|
32
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
33
|
+
const FRAME_MS = 80
|
|
34
|
+
const BAR_WIDTH = 20
|
|
35
|
+
// Back to the start of the line, then erases it.
|
|
36
|
+
const CLEAR_LINE = '\r\x1B[2K'
|
|
37
|
+
// What the console prints with, to the terminal the line is drawn on or the other.
|
|
38
|
+
const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug', 'dir', 'dirxml', 'table', 'trace', 'group', 'groupCollapsed', 'count', 'timeEnd', 'timeLog', 'assert'] as const
|
|
39
|
+
|
|
40
|
+
// A piece of the line and how it is tinted, so the line can be cut to the terminal's width on its visible text.
|
|
41
|
+
interface Part { text: string, tint: (colors: Colors) => (text: string) => string }
|
|
42
|
+
|
|
43
|
+
// One line on `stream`, redrawn in place while a task runs: a spinner, the step, a bar where its total is known, and the time since the step began.
|
|
44
|
+
// Drawn only on a terminal that takes control codes, so a log, CI or `NO_COLOR` gets `done` and `failed` lines alone; whatever else is printed meanwhile clears the line first, and the next frame draws it again under it.
|
|
45
|
+
export function progressLine(stream: ProgressStream = process.stderr, env: Env = process.env): ProgressLine {
|
|
46
|
+
if (!takesControlCodes(stream, env)) {
|
|
47
|
+
const begun = performance.now()
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
update: () => undefined,
|
|
51
|
+
stop: () => undefined,
|
|
52
|
+
done: text => stream.write(`${marked('ok', text, stream, formatDuration(performance.now() - begun), env)}\n`),
|
|
53
|
+
failed: text => stream.write(`${marked('problem', text, stream, undefined, env)}\n`),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const colors = paint(stream, env)
|
|
58
|
+
// Its own write, taken before the line clears itself ahead of every other.
|
|
59
|
+
const write = stream.write.bind(stream)
|
|
60
|
+
const begun = performance.now()
|
|
61
|
+
let started = begun
|
|
62
|
+
let current: Progress | undefined
|
|
63
|
+
let frame = 0
|
|
64
|
+
let drawn = false
|
|
65
|
+
const clear = () => {
|
|
66
|
+
if (drawn)
|
|
67
|
+
write(CLEAR_LINE)
|
|
68
|
+
|
|
69
|
+
drawn = false
|
|
70
|
+
}
|
|
71
|
+
const draw = () => {
|
|
72
|
+
if (current === undefined)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
// A terminal that does not say its width, as a pseudo-terminal may, answers 0.
|
|
76
|
+
const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1
|
|
77
|
+
|
|
78
|
+
write(`${CLEAR_LINE}${fit(progressParts(current, FRAMES[frame++ % FRAMES.length]!, performance.now() - started), width, colors)}`)
|
|
79
|
+
drawn = true
|
|
80
|
+
}
|
|
81
|
+
const timer = setInterval(draw, FRAME_MS)
|
|
82
|
+
const restore = interceptOutput(clear)
|
|
83
|
+
const onExit = () => clear()
|
|
84
|
+
// Interrupted, the line goes before the shell's prompt comes back.
|
|
85
|
+
const signals = holdSignals(stop)
|
|
86
|
+
|
|
87
|
+
function stop() {
|
|
88
|
+
clearInterval(timer)
|
|
89
|
+
clear()
|
|
90
|
+
restore()
|
|
91
|
+
process.off('exit', onExit)
|
|
92
|
+
signals.release()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
timer.unref()
|
|
96
|
+
process.on('exit', onExit)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
// A new step is drawn at once, its clock started again; a count moving on waits for the next frame, so a burst of updates writes one.
|
|
100
|
+
update: (progress) => {
|
|
101
|
+
const step = current?.step
|
|
102
|
+
|
|
103
|
+
current = progress
|
|
104
|
+
|
|
105
|
+
if (progress.step !== step) {
|
|
106
|
+
started = performance.now()
|
|
107
|
+
draw()
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
stop,
|
|
111
|
+
done: (text) => {
|
|
112
|
+
stop()
|
|
113
|
+
write(`${marked('ok', text, stream, formatDuration(performance.now() - begun), env)}\n`)
|
|
114
|
+
},
|
|
115
|
+
failed: (text) => {
|
|
116
|
+
stop()
|
|
117
|
+
write(`${marked('problem', text, stream, undefined, env)}\n`)
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Runs one step under a spinner on a terminal, then leaves `✓ <label> <time>`, or `✗ <label>` and throws again; off a terminal those lines alone print, as `ok:` and `error:`.
|
|
123
|
+
export async function task<T>(label: string, run: (update: (progress: Omit<Progress, 'step'>) => void) => Promise<T>, stream: ProgressStream = process.stderr, env: Env = process.env): Promise<T> {
|
|
124
|
+
const line = progressLine(stream, env)
|
|
125
|
+
|
|
126
|
+
line.update({ step: label })
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const result = await run(progress => line.update({ ...progress, step: label }))
|
|
130
|
+
|
|
131
|
+
line.done(label)
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
line.failed(label)
|
|
137
|
+
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `⠹ prerendering ████████░░░░░░░░░░░░ 8/20 routes 4.2s`; a step with no total has no bar.
|
|
143
|
+
export function progressText(progress: Progress, spinner: string, ms: number): string {
|
|
144
|
+
return progressParts(progress, spinner, ms).map(part => part.text).join('')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function progressParts({ step, done, total, unit }: Progress, spinner: string, ms: number): Part[] {
|
|
148
|
+
const parts: Part[] = [{ text: spinner, tint: colors => colors.cyan }, { text: ` ${step}`, tint: () => plain }]
|
|
149
|
+
|
|
150
|
+
if (total !== undefined && total > 0) {
|
|
151
|
+
const counted = Math.min(done ?? 0, total)
|
|
152
|
+
const filled = Math.round(counted / total * BAR_WIDTH)
|
|
153
|
+
|
|
154
|
+
parts.push(
|
|
155
|
+
{ text: ' ', tint: () => plain },
|
|
156
|
+
{ text: '█'.repeat(filled), tint: colors => colors.cyan },
|
|
157
|
+
{ text: '░'.repeat(BAR_WIDTH - filled), tint: colors => colors.dim },
|
|
158
|
+
{ text: ` ${counted}/${total}${unit === undefined ? '' : ` ${unit}`}`, tint: colors => colors.dim },
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
parts.push({ text: ` ${(ms / 1000).toFixed(1)}s`, tint: colors => colors.dim })
|
|
163
|
+
|
|
164
|
+
return parts
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function plain(text: string): string {
|
|
168
|
+
return text
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// The parts cut to `width` visible characters, each tinted after the cut so no escape is split.
|
|
172
|
+
function fit(parts: Part[], width: number, colors: Colors): string {
|
|
173
|
+
let room = width
|
|
174
|
+
let line = ''
|
|
175
|
+
|
|
176
|
+
for (const { text, tint } of parts) {
|
|
177
|
+
const kept = [...text].slice(0, Math.max(room, 0)).join('')
|
|
178
|
+
|
|
179
|
+
room -= kept.length
|
|
180
|
+
|
|
181
|
+
if (kept !== '')
|
|
182
|
+
line += tint(colors)(kept)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return line
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Clears the line before anything the console or a stream writes, the caller's own code meanwhile included, and answers how to put them back.
|
|
189
|
+
function interceptOutput(clear: () => void): () => void {
|
|
190
|
+
const targets = [...CONSOLE_METHODS.map(name => [console, name] as const), [process.stdout, 'write'] as const, [process.stderr, 'write'] as const]
|
|
191
|
+
const wrapped = targets.map(([target, name]) => {
|
|
192
|
+
const original = Reflect.get(target, name) as (...args: unknown[]) => unknown
|
|
193
|
+
const wrapper = (...args: unknown[]) => {
|
|
194
|
+
clear()
|
|
195
|
+
|
|
196
|
+
return original.apply(target, args)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
Reflect.set(target, name, wrapper)
|
|
200
|
+
|
|
201
|
+
return { target, name, original, wrapper }
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// What the caller replaced meanwhile is its own, and stays.
|
|
205
|
+
return () => {
|
|
206
|
+
for (const { target, name, original, wrapper } of wrapped) {
|
|
207
|
+
if (Reflect.get(target, name) === wrapper)
|
|
208
|
+
Reflect.set(target, name, original)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|