@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/cli.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { Example, Options } from './args'
|
|
2
|
+
import type { Colors } from './style'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { applyOutput, Completing, completion, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, UsageError, usageLine } from './args'
|
|
5
|
+
import { paint, takesControlCodes } from './style'
|
|
6
|
+
import { distance } from './text'
|
|
7
|
+
|
|
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
|
+
} & (
|
|
13
|
+
| { run: (args: string[]) => Promise<number> }
|
|
14
|
+
// A group of subcommands dispatched the same way, with its own help.
|
|
15
|
+
| { commands: Record<string, Command>, aliases?: Record<string, string>, examples?: Example[] }
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
export interface Cli {
|
|
19
|
+
name: string
|
|
20
|
+
version: string
|
|
21
|
+
// One line after `<name> <version>: ` in the help.
|
|
22
|
+
about: string
|
|
23
|
+
commands: Record<string, Command>
|
|
24
|
+
// Names sessions reach for that mean an existing command.
|
|
25
|
+
aliases?: Record<string, string>
|
|
26
|
+
// The command that takes the arguments when the first one names no command, so `db "select 1"` runs `db query "select 1"`; `--help` lists its options under the commands.
|
|
27
|
+
defaultCommand?: string
|
|
28
|
+
// Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
|
|
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)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A command the name starts, else one within two edits (one for a name under four letters).
|
|
77
|
+
export function closest(name: string, names: string[]): string | undefined {
|
|
78
|
+
const started = names.find(known => known.startsWith(name))
|
|
79
|
+
|
|
80
|
+
if (started !== undefined)
|
|
81
|
+
return started
|
|
82
|
+
|
|
83
|
+
const [best] = names.map(known => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far)
|
|
84
|
+
|
|
85
|
+
return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Level {
|
|
89
|
+
// The words typed to reach this level, `ft plan`.
|
|
90
|
+
path: string[]
|
|
91
|
+
header: (colors: Colors) => string
|
|
92
|
+
commands: Record<string, Command>
|
|
93
|
+
aliases: Record<string, string> | undefined
|
|
94
|
+
defaultCommand: string | undefined
|
|
95
|
+
examples: Example[] | undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The level's help; a default command's options follow the commands, as they apply with no command named.
|
|
99
|
+
async function help({ path, header, commands, defaultCommand, examples }: Level, stream: { isTTY?: boolean }): Promise<string> {
|
|
100
|
+
const colors = paint(stream)
|
|
101
|
+
const prefix = path.join(' ')
|
|
102
|
+
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
|
|
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))
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
header(colors),
|
|
109
|
+
'',
|
|
110
|
+
usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
|
|
111
|
+
'',
|
|
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),
|
|
118
|
+
'',
|
|
119
|
+
`${colors.dim('Run')} ${prefix} ${colors.cyan('<command>')} --help ${colors.dim('for its options.')}`,
|
|
120
|
+
].join('\n')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// `error <prefix>: <message>` on stderr, the cause and the hint each on a line of their own under it; plain words off a terminal.
|
|
124
|
+
export function printError(prefix: string, message: string, hint?: string, cause?: string): void {
|
|
125
|
+
const colors = paint(process.stderr)
|
|
126
|
+
const styled = takesControlCodes(process.stderr)
|
|
127
|
+
const head = styled ? `${colors.red.bold('error')} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`
|
|
128
|
+
const under = (label: string, text: string | undefined, tint: (text: string) => string) => text === undefined ? '' : `\n ${styled ? tint(label) : `${label}:`} ${text}`
|
|
129
|
+
|
|
130
|
+
console.error(`${head} ${message}${under('cause', cause, colors.dim)}${under('hint', hint, colors.yellow)}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// What a thrown error prints as and exits with: an Exit its own code and hint, a UsageError pointed at the help, HelpShown nothing at all and 0, anything else 1.
|
|
134
|
+
// An error other than an Exit is named unless a plain `Error` (`MigrationError: ...`), and its cause's message printed under it, never the cause itself.
|
|
135
|
+
function reportThrown(prefix: string, error: unknown, redact: (text: string) => string = text => text): number {
|
|
136
|
+
if (error instanceof HelpShown)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined
|
|
140
|
+
const named = error instanceof Error && !(error instanceof Exit) && error.name !== 'Error' ? `${error.name}: ${error.message}` : undefined
|
|
141
|
+
const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined
|
|
142
|
+
|
|
143
|
+
printError(prefix, redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
|
|
144
|
+
|
|
145
|
+
return error instanceof Exit ? error.code : 1
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function dispatch(level: Level, typed: string[], { version, redact }: { version: string, redact: Cli['redact'] | undefined }): Promise<number> {
|
|
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
|
|
160
|
+
|
|
161
|
+
if (name === undefined && level.path.length > 1) {
|
|
162
|
+
console.error(await help(level, process.stderr))
|
|
163
|
+
|
|
164
|
+
return 2
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (name === undefined || name === 'help' || name === '--help' || name === '-h') {
|
|
168
|
+
console.log(await help(level, process.stdout))
|
|
169
|
+
|
|
170
|
+
return 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (level.path.length === 1 && (name === '--version' || name === '-v')) {
|
|
174
|
+
console.log(version)
|
|
175
|
+
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let command = level.commands[level.aliases?.[name] ?? name]
|
|
180
|
+
let rest = args
|
|
181
|
+
|
|
182
|
+
if (command === undefined && level.defaultCommand !== undefined) {
|
|
183
|
+
command = level.commands[level.defaultCommand]
|
|
184
|
+
rest = argv
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (command === undefined) {
|
|
188
|
+
const meant = closest(name, Object.keys(level.commands))
|
|
189
|
+
|
|
190
|
+
printError(prefix, `no command ${name}`, meant === undefined ? `run ${prefix} --help for its commands` : `did you mean ${meant}?`)
|
|
191
|
+
|
|
192
|
+
return 2
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if ('commands' in command) {
|
|
196
|
+
const { summary } = command
|
|
197
|
+
|
|
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 })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
return await command.run(rest)
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
return reportThrown(`${prefix} ${name}`, error, redact)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The hidden first argument a shell completion calls a bin with: `<bin> __complete <words typed after it>... <word being typed>`.
|
|
210
|
+
export const COMPLETE = '__complete'
|
|
211
|
+
// Printed instead of candidates where a path fits, for the shell to complete files itself.
|
|
212
|
+
export const FILES = '__files__'
|
|
213
|
+
|
|
214
|
+
// A command's options, read by running it until its parse call throws them: every command parses its arguments before anything else.
|
|
215
|
+
async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Options> {
|
|
216
|
+
completion.active = true
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await run([])
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error instanceof Completing)
|
|
223
|
+
return error.options
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
completion.active = false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A leaf command's candidates: its long options for a word starting with a dash, else paths.
|
|
233
|
+
async function leafCandidates(run: (args: string[]) => Promise<number>, words: string[]): Promise<string[]> {
|
|
234
|
+
if (!(words.at(-1) ?? '').startsWith('-'))
|
|
235
|
+
return [FILES]
|
|
236
|
+
|
|
237
|
+
return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name}\t${option.description}`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The candidates for the last word, `value\tdescription` per line, after walking the subcommands the earlier words name.
|
|
241
|
+
async function candidates(commands: Record<string, Command>, aliases: Record<string, string> | undefined, defaultCommand: string | undefined, words: string[]): Promise<string[]> {
|
|
242
|
+
const [word = '', ...rest] = words
|
|
243
|
+
const command = commands[aliases?.[word] ?? word]
|
|
244
|
+
|
|
245
|
+
if (rest.length === 0 && !word.startsWith('-'))
|
|
246
|
+
return Object.entries(commands).map(([name, { summary }]) => `${name}\t${summary}`)
|
|
247
|
+
|
|
248
|
+
if (command === undefined) {
|
|
249
|
+
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
|
|
250
|
+
|
|
251
|
+
return fallback !== undefined && 'run' in fallback ? leafCandidates(fallback.run, words) : []
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return 'commands' in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function printCandidates(found: Promise<string[]>): Promise<number> {
|
|
258
|
+
for (const line of await found)
|
|
259
|
+
console.log(line)
|
|
260
|
+
|
|
261
|
+
return 0
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Runs the command argv names and returns the exit code: 0 for help and version, 2 for an unknown command.
|
|
265
|
+
export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<number> {
|
|
266
|
+
if (argv[0] === COMPLETE)
|
|
267
|
+
return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)))
|
|
268
|
+
|
|
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 })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface Main {
|
|
273
|
+
name: string
|
|
274
|
+
version: string
|
|
275
|
+
run: (args: string[]) => Promise<number>
|
|
276
|
+
redact?: (text: string) => string
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Runs a CLI that is a single command: `--version` or `-v` alone prints the version, an `Exit` or any error prints as `error: <name>: <message>`. Returns the exit code.
|
|
280
|
+
export async function runMain({ name, version, run, redact }: Main, argv = process.argv.slice(2)): Promise<number> {
|
|
281
|
+
if (argv[0] === COMPLETE)
|
|
282
|
+
return printCandidates(leafCandidates(run, argv.slice(1)))
|
|
283
|
+
|
|
284
|
+
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
|
|
285
|
+
console.log(version)
|
|
286
|
+
|
|
287
|
+
return 0
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
return await run(argv)
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
return reportThrown(name, error, redact)
|
|
295
|
+
}
|
|
296
|
+
}
|
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
|
+
}
|