@kaynooo/cli-kit 0.7.3 → 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/README.md +6 -0
- package/package.json +1 -1
- package/src/args.ts +50 -5
- package/src/chart.ts +27 -0
- package/src/cli.ts +75 -13
- package/src/progress.ts +151 -48
- package/src/style.ts +30 -0
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
43
43
|
- `runCommand(argv, { cwd, env, stdin })` runs a command and returns `{ code, text, output, chunks }`, the output of both streams (in arrival order, tagged by stream in `chunks`); it throws when the command cannot start. `exitCodeOf(code, signal)` is the exit code a shell reports.
|
|
44
44
|
- `holdSignals(cleanup?)` takes SIGINT, SIGTERM and SIGHUP over until `release()`: a signal goes to the child given to `forward(child)`, so the CLI exits with the child's code; without one, `cleanup(signal)` runs and the signal is raised again, ending the process as it would have; without either, it is only kept in `signal`, for the caller to wind down (`base` holds them while its worktree exists).
|
|
45
45
|
- `openUrl(target, env?)` opens a URL, or a file path made absolute, with the command `BROWSER` names or the platform's opener (`xdg-open`, `open`, `start`), without waiting; it returns `false` when that command cannot start.
|
|
46
|
+
- Every command's `parse`, and `runCli` before the command name (`ft --plain runs`), takes `--color auto|always|never` and `--plain` (`--color never`), unless the command has its own option of that name. `always` sets `FORCE_COLOR=1` and `never` sets `NO_COLOR=1`, each dropping the other, so the kit's colours, marks and redrawn lines follow, and so do the processes the command starts; `always` colours a pipe but never redraws in one. `applyOutput(color, plain, env?)` is what they run.
|
|
47
|
+
- `--help` lists the commands under `Commands:`, then a heading per `group` a command names (`{ summary, group: 'Admin', run }`), then the level's `Options:` and the `Examples:` the CLI or a group's `examples` give (`['ft ci', ['ft ci HEAD~1', 'the commit before']]`, a note shown dimmed after `#`); a command's own examples go to `parse(args, usage, options, { examples })`, shown under its options.
|
|
46
48
|
- `jsonOption(holds?)` is the `--json` flag, worded `print JSON` or `print JSON: <holds>` alike in every CLI, and `printJson(value)` prints the value indented on stdout.
|
|
47
49
|
- `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
|
|
48
50
|
- `table(rows, { cellWidth, header })` also flattens each cell's line breaks and cuts it to `cellWidth`, and with `header` prints the first row bold on a terminal; and `formatDuration(ms)` formats milliseconds like `formatSeconds` but with one decimal under a minute (`0.3s`).
|
|
@@ -57,4 +59,8 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
57
59
|
- Off a terminal, a prompt fails at once with exit 2, `cannot ask "<question>" off a terminal`, with `hint` under it when given (`{ hint: 'pass --yes' }`); none reads a piped line. Each takes a `terminal: { stdin, stderr }`, the process's by default, for a test to drive.
|
|
58
60
|
- `@kaynooo/cli-kit/progress` draws `progressLine(stream?, env?)` on stderr, one line redrawn in place while a task runs (`⠹ prerendering ████████░░░░░░░░░░░░ 8/20 routes 4.2s`, the bar cyan on a dimmed track), fed `update({ step, done?, total?, unit? })` and ended with `stop()`, `done(text)` leaving `✓ <text> <time>` or `failed(text)` leaving `✗ <text>`. It draws only where `takesControlCodes`; elsewhere `update` and `stop` do nothing and `done` and `failed` print their plain line. Anything printed meanwhile clears it first, and an interrupt clears it before the process ends.
|
|
59
61
|
- `task(label, run)` from `/progress` runs one step under a spinner, `run` given an `update({ done?, total?, unit? })` for a bar, and ends on `✓ <label> <time>`, or `✗ <label>` and the error thrown again.
|
|
62
|
+
- `taskList(stream?, env?)` from `/progress` stacks a progress line per task running side by side: `add(label)` returns `{ update, done(text?), failed(text?) }`, and a task ending leaves its `✓` or `✗` line above those still running.
|
|
63
|
+
- `liveRegion(render, stream?, env?)` from `/progress` is the block both draw with: the lines `render(spinner)` returns, redrawn every frame (`redraw()` draws at once), each cut to the terminal's width and the block to its height, `print(text)` printing above it and `stop()` clearing it. Like the progress line it draws only where `takesControlCodes`, and `ft ci` and `waitfor` show what they wait on with it.
|
|
64
|
+
- `badge(word, stream)` marks a status word on a terminal, the same five ways everywhere: `✓ success` green, `✗ failure` red, `● running` cyan, `○ waiting` yellow, `- skipped` dimmed; the word alone elsewhere. `statusOf(word)` reads the words Forgejo and GitLab report (`success`, `failed`, `pending`, `canceled`...) as `passed`, `failed`, `running`, `queued` or `skipped`.
|
|
65
|
+
- `keyValue(pairs)` aligns `label value` lines, `sparkline(values)` draws `▁▃▇`, `bar(ratio)` and `barChart(rows)` draw cyan bars on a dimmed track, and `tree(roots)` hangs `{ label, children? }` nodes on dimmed `├─`, `└─` branches.
|
|
60
66
|
- `@kaynooo/cli-kit/shell` reads shell command lines the way a hook vetting them needs: `splitCommand` cuts one into pieces at the separators outside quotes and heredocs, `parseCommand` names a piece's program past `sudo`/`env` wrappers and assignments, `tokenize`, `words`, `substitutions` and `replacePieces` take it apart and put it back.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-kit",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"description": "What every @kaynooo CLI shares: argument parsing with --help, a command dispatcher with typo hints and shell completion, and output formatting.",
|
|
6
6
|
"author": "Kaynooo <kaynooo@kevyn.fr>",
|
|
7
7
|
"license": "MIT",
|
package/src/args.ts
CHANGED
|
@@ -56,8 +56,16 @@ export function usageLine(usage: string, colors = paint(process.stdout)): string
|
|
|
56
56
|
return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function helpText(usage: string, options: Options, colors = paint(process.stdout)): string {
|
|
60
|
-
return [usageLine(usage, colors), '', colors.bold('Options:'), ...optionRows(options, colors)].join('\n')
|
|
59
|
+
export function helpText(usage: string, options: Options, colors = paint(process.stdout), examples: Example[] = []): string {
|
|
60
|
+
return [usageLine(usage, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// An `Examples:` section, after a blank line, with a line per example; nothing without one.
|
|
64
|
+
export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
|
|
65
|
+
if (examples.length === 0)
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
// A line per option, `-n, --times <value> how many`, aligned on the visible text.
|
|
@@ -85,11 +93,44 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
|
|
|
85
93
|
allowNegative?: boolean
|
|
86
94
|
// The positionals the command takes, by name as its usage writes them: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more. One missing or one too many is a usage error, and the positionals come back typed by these names.
|
|
87
95
|
positionals?: Names
|
|
96
|
+
// Command lines shown under `Examples:` in the help, each with an optional dimmed note: `['ft ci HEAD~1', 'wait for the commit before the last']`.
|
|
97
|
+
examples?: Example[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type Example = string | [line: string, note: string]
|
|
101
|
+
|
|
102
|
+
// The output flags every command takes, before its own help.
|
|
103
|
+
export const OUTPUT_OPTIONS = {
|
|
104
|
+
color: { type: 'string', description: 'auto (on a terminal alone), always or never' },
|
|
105
|
+
plain: { type: 'boolean', description: 'plain text, no colour or spinner: --color never' },
|
|
106
|
+
} as const satisfies Options
|
|
107
|
+
|
|
108
|
+
// Applies --color or --plain to the environment, where the kit's colours and redrawn lines read it and so do the processes a command starts; `auto` leaves it as it is.
|
|
109
|
+
export function applyOutput(color: string | undefined, plain: boolean | undefined, env: Record<string, string | undefined> = process.env): void {
|
|
110
|
+
const when = plain === true ? 'never' : color ?? 'auto'
|
|
111
|
+
|
|
112
|
+
if (when === 'auto')
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
if (when !== 'always' && when !== 'never')
|
|
116
|
+
badUsage(`--color takes auto, always or never, not ${when}`)
|
|
117
|
+
|
|
118
|
+
// FORCE_COLOR wins over NO_COLOR for the colours, so each choice drops the other.
|
|
119
|
+
if (when === 'always') {
|
|
120
|
+
env.FORCE_COLOR = '1'
|
|
121
|
+
delete env.NO_COLOR
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
env.NO_COLOR = '1'
|
|
125
|
+
delete env.FORCE_COLOR
|
|
126
|
+
}
|
|
88
127
|
}
|
|
89
128
|
|
|
90
129
|
// Parses a command's arguments; on --help, prints its help and throws HelpShown, which ends the command with exit 0.
|
|
91
130
|
export function parse<const T extends Options, const Names extends readonly string[] = string[]>(args: string[], usage: string, options: T, settings: ParseSettings<Names> = {}) {
|
|
92
|
-
|
|
131
|
+
// A command's own --color or --plain is its own to read.
|
|
132
|
+
const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options))) as Omit<typeof OUTPUT_OPTIONS, keyof T>
|
|
133
|
+
const all = { ...options, ...output, help: { type: 'boolean', short: 'h', description: 'show this help' } } as const
|
|
93
134
|
|
|
94
135
|
if (completion.active)
|
|
95
136
|
throw new Completing(all)
|
|
@@ -107,8 +148,12 @@ export function parse<const T extends Options, const Names extends readonly stri
|
|
|
107
148
|
badUsage(parseError(error))
|
|
108
149
|
}
|
|
109
150
|
|
|
110
|
-
|
|
111
|
-
|
|
151
|
+
const values = parsed.values as { help?: boolean, color?: unknown, plain?: unknown }
|
|
152
|
+
|
|
153
|
+
applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
|
|
154
|
+
|
|
155
|
+
if (values.help === true) {
|
|
156
|
+
console.log(helpText(usage, all, undefined, settings.examples))
|
|
112
157
|
|
|
113
158
|
throw new HelpShown()
|
|
114
159
|
}
|
package/src/chart.ts
CHANGED
|
@@ -59,3 +59,30 @@ export function barChart(rows: [label: string, value: number][], { width = 24, f
|
|
|
59
59
|
|
|
60
60
|
return rows.map(([label, value]) => `${label}${' '.repeat(labels - textWidth(label))} ${bar(largest === 0 ? 0 : value / largest, { width, stream, env })} ${' '.repeat(values - textWidth(format(value)))}${format(value)}`).join('\n')
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
export interface TreeNode {
|
|
64
|
+
label: string
|
|
65
|
+
children?: TreeNode[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Each root on a line of its own and its children under it, hung on dimmed `├─`, `└─` and `│` branches.
|
|
69
|
+
export function tree(roots: TreeNode[], { stream = process.stdout, env = process.env }: KeyValueSettings = {}): string {
|
|
70
|
+
const colors = paint(stream, env)
|
|
71
|
+
const lines: string[] = []
|
|
72
|
+
|
|
73
|
+
function branch(nodes: TreeNode[], indent: string) {
|
|
74
|
+
for (const [index, { label, children = [] }] of nodes.entries()) {
|
|
75
|
+
const last = index === nodes.length - 1
|
|
76
|
+
|
|
77
|
+
lines.push(`${colors.dim(`${indent}${last ? '└─' : '├─'}`)} ${label}`)
|
|
78
|
+
branch(children, `${indent}${last ? ' ' : '│ '}`)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const { label, children = [] } of roots) {
|
|
83
|
+
lines.push(label)
|
|
84
|
+
branch(children, '')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return lines.join('\n')
|
|
88
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import type { Options } from './args'
|
|
1
|
+
import type { Example, Options } from './args'
|
|
2
2
|
import type { Colors } from './style'
|
|
3
3
|
import process from 'node:process'
|
|
4
|
-
import { Completing, completion, Exit, HelpShown, optionRows, UsageError, usageLine } from './args'
|
|
4
|
+
import { applyOutput, Completing, completion, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, UsageError, usageLine } from './args'
|
|
5
5
|
import { paint, takesControlCodes } from './style'
|
|
6
6
|
import { distance } from './text'
|
|
7
7
|
|
|
8
|
-
export type Command = {
|
|
8
|
+
export type Command = {
|
|
9
|
+
summary: string
|
|
10
|
+
// The heading the command is listed under in the help, after those with none.
|
|
11
|
+
group?: string
|
|
12
|
+
} & (
|
|
9
13
|
| { run: (args: string[]) => Promise<number> }
|
|
10
14
|
// A group of subcommands dispatched the same way, with its own help.
|
|
11
|
-
| { commands: Record<string, Command>, aliases?: Record<string, string
|
|
15
|
+
| { commands: Record<string, Command>, aliases?: Record<string, string>, examples?: Example[] }
|
|
12
16
|
)
|
|
13
17
|
|
|
14
18
|
export interface Cli {
|
|
@@ -23,6 +27,50 @@ export interface Cli {
|
|
|
23
27
|
defaultCommand?: string
|
|
24
28
|
// Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
|
|
25
29
|
redact?: (text: string) => string
|
|
30
|
+
// Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
|
|
31
|
+
examples?: Example[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LEVEL_OPTIONS = {
|
|
35
|
+
...OUTPUT_OPTIONS,
|
|
36
|
+
help: { type: 'boolean', short: 'h', description: 'show this help' },
|
|
37
|
+
} as const satisfies Options
|
|
38
|
+
const TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: 'boolean', short: 'v', description: 'print the version' } } as const satisfies Options
|
|
39
|
+
|
|
40
|
+
// The rows under `Commands:`, then a heading per group in the order first met, names aligned across them all.
|
|
41
|
+
function commandRows(commands: Record<string, Command>, colors: Colors): string[] {
|
|
42
|
+
const width = Math.max(...Object.keys(commands).map(name => name.length))
|
|
43
|
+
const groups = new Map<string, string[]>([['Commands:', []]])
|
|
44
|
+
|
|
45
|
+
for (const [name, { summary, group }] of Object.entries(commands)) {
|
|
46
|
+
const heading = group === undefined ? 'Commands:' : `${group}:`
|
|
47
|
+
const rows = groups.get(heading) ?? []
|
|
48
|
+
|
|
49
|
+
rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`)
|
|
50
|
+
groups.set(heading, rows)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [''], colors.bold(heading), ...rows])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The output flags typed before the command name, applied and dropped: `ft --plain runs`.
|
|
57
|
+
function takeOutputFlags(argv: string[]): string[] {
|
|
58
|
+
let index = 0
|
|
59
|
+
|
|
60
|
+
for (; index < argv.length; index++) {
|
|
61
|
+
const word = argv[index]!
|
|
62
|
+
|
|
63
|
+
if (word === '--plain')
|
|
64
|
+
applyOutput(undefined, true)
|
|
65
|
+
else if (word.startsWith('--color='))
|
|
66
|
+
applyOutput(word.slice('--color='.length), undefined)
|
|
67
|
+
else if (word === '--color' && argv[index + 1] !== undefined)
|
|
68
|
+
applyOutput(argv[++index], undefined)
|
|
69
|
+
else
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return argv.slice(index)
|
|
26
74
|
}
|
|
27
75
|
|
|
28
76
|
// A command the name starts, else one within two edits (one for a name under four letters).
|
|
@@ -44,24 +92,29 @@ interface Level {
|
|
|
44
92
|
commands: Record<string, Command>
|
|
45
93
|
aliases: Record<string, string> | undefined
|
|
46
94
|
defaultCommand: string | undefined
|
|
95
|
+
examples: Example[] | undefined
|
|
47
96
|
}
|
|
48
97
|
|
|
49
98
|
// The level's help; a default command's options follow the commands, as they apply with no command named.
|
|
50
|
-
async function help({ path, header, commands, defaultCommand }: Level, stream: { isTTY?: boolean }): Promise<string> {
|
|
99
|
+
async function help({ path, header, commands, defaultCommand, examples }: Level, stream: { isTTY?: boolean }): Promise<string> {
|
|
51
100
|
const colors = paint(stream)
|
|
52
|
-
const width = Math.max(...Object.keys(commands).map(name => name.length))
|
|
53
101
|
const prefix = path.join(' ')
|
|
54
102
|
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
|
|
55
103
|
const options = fallback !== undefined && 'run' in fallback ? await optionsOf(fallback.run) : undefined
|
|
104
|
+
// The default command's own run lists the output flags again, so they show once, under the level's.
|
|
105
|
+
const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS))
|
|
56
106
|
|
|
57
107
|
return [
|
|
58
108
|
header(colors),
|
|
59
109
|
'',
|
|
60
110
|
usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
|
|
61
111
|
'',
|
|
62
|
-
colors
|
|
63
|
-
...
|
|
64
|
-
|
|
112
|
+
...commandRows(commands, colors),
|
|
113
|
+
...own.length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(Object.fromEntries(own), colors)],
|
|
114
|
+
'',
|
|
115
|
+
colors.bold('Options:'),
|
|
116
|
+
...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
|
|
117
|
+
...exampleRows(examples ?? [], colors),
|
|
65
118
|
'',
|
|
66
119
|
`${colors.dim('Run')} ${prefix} ${colors.cyan('<command>')} --help ${colors.dim('for its options.')}`,
|
|
67
120
|
].join('\n')
|
|
@@ -92,9 +145,18 @@ function reportThrown(prefix: string, error: unknown, redact: (text: string) =>
|
|
|
92
145
|
return error instanceof Exit ? error.code : 1
|
|
93
146
|
}
|
|
94
147
|
|
|
95
|
-
async function dispatch(level: Level,
|
|
96
|
-
const [name, ...args] = argv
|
|
148
|
+
async function dispatch(level: Level, typed: string[], { version, redact }: { version: string, redact: Cli['redact'] | undefined }): Promise<number> {
|
|
97
149
|
const prefix = level.path.join(' ')
|
|
150
|
+
let argv: string[]
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
argv = takeOutputFlags(typed)
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return reportThrown(prefix, error)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const [name, ...args] = argv
|
|
98
160
|
|
|
99
161
|
if (name === undefined && level.path.length > 1) {
|
|
100
162
|
console.error(await help(level, process.stderr))
|
|
@@ -133,7 +195,7 @@ async function dispatch(level: Level, argv: string[], { version, redact }: { ver
|
|
|
133
195
|
if ('commands' in command) {
|
|
134
196
|
const { summary } = command
|
|
135
197
|
|
|
136
|
-
return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined }, rest, { version, redact })
|
|
198
|
+
return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples }, rest, { version, redact })
|
|
137
199
|
}
|
|
138
200
|
|
|
139
201
|
try {
|
|
@@ -204,7 +266,7 @@ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<nu
|
|
|
204
266
|
if (argv[0] === COMPLETE)
|
|
205
267
|
return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)))
|
|
206
268
|
|
|
207
|
-
return dispatch({ path: [cli.name], header: colors => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand }, argv, { version: cli.version, redact: cli.redact })
|
|
269
|
+
return dispatch({ path: [cli.name], header: colors => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples }, argv, { version: cli.version, redact: cli.redact })
|
|
208
270
|
}
|
|
209
271
|
|
|
210
272
|
export interface Main {
|
package/src/progress.ts
CHANGED
|
@@ -14,9 +14,31 @@ export interface Progress {
|
|
|
14
14
|
interface ProgressStream {
|
|
15
15
|
isTTY?: boolean
|
|
16
16
|
columns?: number | undefined
|
|
17
|
+
rows?: number | undefined
|
|
17
18
|
write: (text: string) => unknown
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
export interface LiveRegion {
|
|
22
|
+
// Draws the lines again now, rather than at the next frame.
|
|
23
|
+
redraw: () => void
|
|
24
|
+
// Prints `text` above the region, where it stays once the region goes.
|
|
25
|
+
print: (text: string) => void
|
|
26
|
+
// Clears the region and leaves nothing behind.
|
|
27
|
+
stop: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TaskHandle {
|
|
31
|
+
update: (progress: Omit<Progress, 'step'>) => void
|
|
32
|
+
// Leaves `✓ <text> <time since the task began>` above the tasks still running, the label when no text is given.
|
|
33
|
+
done: (text?: string) => void
|
|
34
|
+
// Leaves `✗ <text>`, the label when no text is given.
|
|
35
|
+
failed: (text?: string) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TaskList {
|
|
39
|
+
add: (label: string) => TaskHandle
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
export interface ProgressLine {
|
|
21
43
|
update: (progress: Progress) => void
|
|
22
44
|
// Clears the line and leaves nothing behind.
|
|
@@ -34,54 +56,54 @@ const FRAME_MS = 80
|
|
|
34
56
|
const BAR_WIDTH = 20
|
|
35
57
|
// Back to the start of the line, then erases it.
|
|
36
58
|
const CLEAR_LINE = '\r\x1B[2K'
|
|
59
|
+
// Up a line, then erases it.
|
|
60
|
+
const LINE_UP = '\x1B[1A\x1B[2K'
|
|
61
|
+
const ESC = String.fromCharCode(27)
|
|
62
|
+
// A colour or cursor code, or an OSC 8 link's opening or closing, read where `lastIndex` points.
|
|
63
|
+
const ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, 'y')
|
|
64
|
+
const RESET_STYLE = `${ESC}[0m`
|
|
65
|
+
const CLOSE_LINK = `${ESC}]8;;${ESC}\\`
|
|
37
66
|
// What the console prints with, to the terminal the line is drawn on or the other.
|
|
38
67
|
const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug', 'dir', 'dirxml', 'table', 'trace', 'group', 'groupCollapsed', 'count', 'timeEnd', 'timeLog', 'assert'] as const
|
|
39
68
|
|
|
40
69
|
// 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
70
|
interface Part { text: string, tint: (colors: Colors) => (text: string) => string }
|
|
42
71
|
|
|
43
|
-
//
|
|
44
|
-
// Drawn only on a terminal that takes control codes, so
|
|
45
|
-
export function
|
|
46
|
-
if (!takesControlCodes(stream, env))
|
|
47
|
-
|
|
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
|
-
}
|
|
72
|
+
// Lines on `stream` redrawn in place every frame, `render` handed the spinner's next glyph, each line cut to the terminal's width and the block to its height.
|
|
73
|
+
// Drawn only on a terminal that takes control codes, so elsewhere `render` never runs and the caller prints its own outcome; whatever else is printed meanwhile clears the block first, the next frame drawing it again under it, and an interrupt clears it before the process ends.
|
|
74
|
+
export function liveRegion(render: (spinner: string) => string[], stream: ProgressStream = process.stderr, env: Env = process.env): LiveRegion {
|
|
75
|
+
if (!takesControlCodes(stream, env))
|
|
76
|
+
return { redraw: () => undefined, print: text => void stream.write(`${text}\n`), stop: () => undefined }
|
|
56
77
|
|
|
57
|
-
|
|
58
|
-
// Its own write, taken before the line clears itself ahead of every other.
|
|
78
|
+
// Its own write, taken before the region clears itself ahead of every other.
|
|
59
79
|
const write = stream.write.bind(stream)
|
|
60
|
-
const begun = performance.now()
|
|
61
|
-
let started = begun
|
|
62
|
-
let current: Progress | undefined
|
|
63
80
|
let frame = 0
|
|
64
|
-
let drawn =
|
|
81
|
+
let drawn = 0
|
|
65
82
|
const clear = () => {
|
|
66
|
-
if (drawn)
|
|
67
|
-
write(CLEAR_LINE)
|
|
83
|
+
if (drawn > 0)
|
|
84
|
+
write(`${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}`)
|
|
68
85
|
|
|
69
|
-
drawn =
|
|
86
|
+
drawn = 0
|
|
70
87
|
}
|
|
71
88
|
const draw = () => {
|
|
72
|
-
|
|
73
|
-
return
|
|
74
|
-
|
|
75
|
-
// A terminal that does not say its width, as a pseudo-terminal may, answers 0.
|
|
89
|
+
// A terminal that does not say its size, as a pseudo-terminal may, answers 0.
|
|
76
90
|
const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1
|
|
91
|
+
const height = stream.rows === undefined || stream.rows === 0 ? 24 : stream.rows
|
|
92
|
+
const lines = render(FRAMES[frame++ % FRAMES.length]!).slice(0, Math.max(1, height - 1)).map(line => cutToWidth(line, width))
|
|
93
|
+
|
|
94
|
+
if (lines.length === 0) {
|
|
95
|
+
clear()
|
|
96
|
+
|
|
97
|
+
return
|
|
98
|
+
}
|
|
77
99
|
|
|
78
|
-
write(`${CLEAR_LINE}${
|
|
79
|
-
drawn =
|
|
100
|
+
write(`${drawn > 0 ? `${CLEAR_LINE}${LINE_UP.repeat(drawn - 1)}` : CLEAR_LINE}${lines.join('\n')}`)
|
|
101
|
+
drawn = lines.length
|
|
80
102
|
}
|
|
81
103
|
const timer = setInterval(draw, FRAME_MS)
|
|
82
104
|
const restore = interceptOutput(clear)
|
|
83
105
|
const onExit = () => clear()
|
|
84
|
-
// Interrupted, the
|
|
106
|
+
// Interrupted, the block goes before the shell's prompt comes back.
|
|
85
107
|
const signals = holdSignals(stop)
|
|
86
108
|
|
|
87
109
|
function stop() {
|
|
@@ -95,6 +117,32 @@ export function progressLine(stream: ProgressStream = process.stderr, env: Env =
|
|
|
95
117
|
timer.unref()
|
|
96
118
|
process.on('exit', onExit)
|
|
97
119
|
|
|
120
|
+
return {
|
|
121
|
+
redraw: draw,
|
|
122
|
+
print: (text) => {
|
|
123
|
+
clear()
|
|
124
|
+
write(`${text}\n`)
|
|
125
|
+
draw()
|
|
126
|
+
},
|
|
127
|
+
stop,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 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.
|
|
132
|
+
// Off a terminal taking control codes, a log, CI or `NO_COLOR` gets the `done` and `failed` lines alone.
|
|
133
|
+
export function progressLine(stream: ProgressStream = process.stderr, env: Env = process.env): ProgressLine {
|
|
134
|
+
const colors = paint(stream, env)
|
|
135
|
+
// Its own write, taken before the region clears itself ahead of every other.
|
|
136
|
+
const write = stream.write.bind(stream)
|
|
137
|
+
const begun = performance.now()
|
|
138
|
+
let started = begun
|
|
139
|
+
let current: Progress | undefined
|
|
140
|
+
const region = liveRegion(spinner => current === undefined ? [] : [tinted(progressParts(current, spinner, performance.now() - started), colors)], stream, env)
|
|
141
|
+
const end = (line: string) => {
|
|
142
|
+
region.stop()
|
|
143
|
+
write(`${line}\n`)
|
|
144
|
+
}
|
|
145
|
+
|
|
98
146
|
return {
|
|
99
147
|
// 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
148
|
update: (progress) => {
|
|
@@ -104,17 +152,48 @@ export function progressLine(stream: ProgressStream = process.stderr, env: Env =
|
|
|
104
152
|
|
|
105
153
|
if (progress.step !== step) {
|
|
106
154
|
started = performance.now()
|
|
107
|
-
|
|
155
|
+
region.redraw()
|
|
108
156
|
}
|
|
109
157
|
},
|
|
110
|
-
stop,
|
|
111
|
-
done: (text)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
158
|
+
stop: region.stop,
|
|
159
|
+
done: text => end(marked('ok', text, stream, formatDuration(performance.now() - begun), env)),
|
|
160
|
+
failed: text => end(marked('problem', text, stream, undefined, env)),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stacked lines for tasks running side by side, each drawn as a progress line is; a task ending leaves its ✓ or ✗ line above those still running, and the block goes once none is.
|
|
165
|
+
export function taskList(stream: ProgressStream = process.stderr, env: Env = process.env): TaskList {
|
|
166
|
+
const colors = paint(stream, env)
|
|
167
|
+
const running = new Set<{ progress: Progress, started: number }>()
|
|
168
|
+
let region: LiveRegion | undefined
|
|
169
|
+
const render = (spinner: string) => [...running].map(({ progress, started }) => tinted(progressParts(progress, spinner, performance.now() - started), colors))
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
add: (label) => {
|
|
173
|
+
const entry = { progress: { step: label } as Progress, started: performance.now() }
|
|
174
|
+
const end = (line: string) => {
|
|
175
|
+
if (!running.delete(entry))
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
region?.print(line)
|
|
179
|
+
|
|
180
|
+
if (running.size === 0) {
|
|
181
|
+
region?.stop()
|
|
182
|
+
region = undefined
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
running.add(entry)
|
|
187
|
+
region ??= liveRegion(render, stream, env)
|
|
188
|
+
region.redraw()
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
update: (progress) => {
|
|
192
|
+
entry.progress = { ...progress, step: label }
|
|
193
|
+
},
|
|
194
|
+
done: text => end(marked('ok', text ?? label, stream, formatDuration(performance.now() - entry.started), env)),
|
|
195
|
+
failed: text => end(marked('problem', text ?? label, stream, undefined, env)),
|
|
196
|
+
}
|
|
118
197
|
},
|
|
119
198
|
}
|
|
120
199
|
}
|
|
@@ -168,24 +247,48 @@ function plain(text: string): string {
|
|
|
168
247
|
return text
|
|
169
248
|
}
|
|
170
249
|
|
|
171
|
-
|
|
172
|
-
|
|
250
|
+
function tinted(parts: Part[], colors: Colors): string {
|
|
251
|
+
return parts.map(({ text, tint }) => tint(colors)(text)).join('')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The line cut to `width` columns of visible text, its escapes kept whole, and the styles and a link ended after a cut so none runs into the next line.
|
|
255
|
+
function cutToWidth(line: string, width: number): string {
|
|
256
|
+
if (Bun.stringWidth(line) <= width)
|
|
257
|
+
return line
|
|
258
|
+
|
|
173
259
|
let room = width
|
|
174
|
-
let
|
|
260
|
+
let kept = ''
|
|
261
|
+
let index = 0
|
|
262
|
+
let linked = false
|
|
263
|
+
|
|
264
|
+
while (index < line.length) {
|
|
265
|
+
ESCAPE_CODE.lastIndex = index
|
|
266
|
+
|
|
267
|
+
const escape = ESCAPE_CODE.exec(line)
|
|
268
|
+
|
|
269
|
+
if (escape !== null) {
|
|
270
|
+
kept += escape[0]
|
|
271
|
+
linked ||= escape[0].startsWith(`${ESC}]`)
|
|
272
|
+
index = ESCAPE_CODE.lastIndex
|
|
273
|
+
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
175
276
|
|
|
176
|
-
|
|
177
|
-
const
|
|
277
|
+
const char = String.fromCodePoint(line.codePointAt(index)!)
|
|
278
|
+
const columns = Bun.stringWidth(char)
|
|
178
279
|
|
|
179
|
-
|
|
280
|
+
if (columns > room)
|
|
281
|
+
break
|
|
180
282
|
|
|
181
|
-
|
|
182
|
-
|
|
283
|
+
kept += char
|
|
284
|
+
room -= columns
|
|
285
|
+
index += char.length
|
|
183
286
|
}
|
|
184
287
|
|
|
185
|
-
return
|
|
288
|
+
return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ''}`
|
|
186
289
|
}
|
|
187
290
|
|
|
188
|
-
// Clears the
|
|
291
|
+
// Clears the region before anything the console or a stream writes, the caller's own code meanwhile included, and answers how to put them back.
|
|
189
292
|
function interceptOutput(clear: () => void): () => void {
|
|
190
293
|
const targets = [...CONSOLE_METHODS.map(name => [console, name] as const), [process.stdout, 'write'] as const, [process.stderr, 'write'] as const]
|
|
191
294
|
const wrapped = targets.map(([target, name]) => {
|
package/src/style.ts
CHANGED
|
@@ -64,6 +64,36 @@ export function role(name: Role, stream: { isTTY?: boolean }, env: Record<string
|
|
|
64
64
|
return ROLES[name](paint(stream, env))
|
|
65
65
|
}
|
|
66
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
|
+
|
|
67
97
|
// A clickable OSC 8 link on a terminal; elsewhere `text (url)`, or the text alone when it is the url.
|
|
68
98
|
export function link(text: string, url: string, stream: { isTTY?: boolean }, env: Record<string, string | undefined> = process.env): string {
|
|
69
99
|
if (takesControlCodes(stream, env))
|