@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/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
@@ -0,0 +1,8 @@
1
+ export * from './args'
2
+ export * from './chart'
3
+ export * from './cli'
4
+ export * from './config'
5
+ export * from './format'
6
+ export * from './process'
7
+ export * from './style'
8
+ export * from './text'
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
+ }