@kaynooo/cli-kit 0.0.0-stage → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }
@@ -0,0 +1,211 @@
1
+ import type { Colors } from './style'
2
+ import process from 'node:process'
3
+ import { formatDuration } from './format'
4
+ import { holdSignals } from './process'
5
+ import { marked, paint, takesControlCodes } from './style'
6
+
7
+ export interface Progress {
8
+ step: string
9
+ done?: number | undefined
10
+ total?: number | undefined
11
+ unit?: string | undefined
12
+ }
13
+
14
+ interface ProgressStream {
15
+ isTTY?: boolean
16
+ columns?: number | undefined
17
+ write: (text: string) => unknown
18
+ }
19
+
20
+ export interface ProgressLine {
21
+ update: (progress: Progress) => void
22
+ // Clears the line and leaves nothing behind.
23
+ stop: () => void
24
+ // Clears the line and leaves `✓ <text> <time since the line began>` in its place.
25
+ done: (text: string) => void
26
+ // Clears the line and leaves `✗ <text>` in its place.
27
+ failed: (text: string) => void
28
+ }
29
+
30
+ type Env = Record<string, string | undefined>
31
+
32
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
33
+ const FRAME_MS = 80
34
+ const BAR_WIDTH = 20
35
+ // Back to the start of the line, then erases it.
36
+ const CLEAR_LINE = '\r\x1B[2K'
37
+ // What the console prints with, to the terminal the line is drawn on or the other.
38
+ const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug', 'dir', 'dirxml', 'table', 'trace', 'group', 'groupCollapsed', 'count', 'timeEnd', 'timeLog', 'assert'] as const
39
+
40
+ // A piece of the line and how it is tinted, so the line can be cut to the terminal's width on its visible text.
41
+ interface Part { text: string, tint: (colors: Colors) => (text: string) => string }
42
+
43
+ // One line on `stream`, redrawn in place while a task runs: a spinner, the step, a bar where its total is known, and the time since the step began.
44
+ // Drawn only on a terminal that takes control codes, so a log, CI or `NO_COLOR` gets `done` and `failed` lines alone; whatever else is printed meanwhile clears the line first, and the next frame draws it again under it.
45
+ export function progressLine(stream: ProgressStream = process.stderr, env: Env = process.env): ProgressLine {
46
+ if (!takesControlCodes(stream, env)) {
47
+ const begun = performance.now()
48
+
49
+ return {
50
+ update: () => undefined,
51
+ stop: () => undefined,
52
+ done: text => stream.write(`${marked('ok', text, stream, formatDuration(performance.now() - begun), env)}\n`),
53
+ failed: text => stream.write(`${marked('problem', text, stream, undefined, env)}\n`),
54
+ }
55
+ }
56
+
57
+ const colors = paint(stream, env)
58
+ // Its own write, taken before the line clears itself ahead of every other.
59
+ const write = stream.write.bind(stream)
60
+ const begun = performance.now()
61
+ let started = begun
62
+ let current: Progress | undefined
63
+ let frame = 0
64
+ let drawn = false
65
+ const clear = () => {
66
+ if (drawn)
67
+ write(CLEAR_LINE)
68
+
69
+ drawn = false
70
+ }
71
+ const draw = () => {
72
+ if (current === undefined)
73
+ return
74
+
75
+ // A terminal that does not say its width, as a pseudo-terminal may, answers 0.
76
+ const width = (stream.columns === undefined || stream.columns === 0 ? 80 : stream.columns) - 1
77
+
78
+ write(`${CLEAR_LINE}${fit(progressParts(current, FRAMES[frame++ % FRAMES.length]!, performance.now() - started), width, colors)}`)
79
+ drawn = true
80
+ }
81
+ const timer = setInterval(draw, FRAME_MS)
82
+ const restore = interceptOutput(clear)
83
+ const onExit = () => clear()
84
+ // Interrupted, the line goes before the shell's prompt comes back.
85
+ const signals = holdSignals(stop)
86
+
87
+ function stop() {
88
+ clearInterval(timer)
89
+ clear()
90
+ restore()
91
+ process.off('exit', onExit)
92
+ signals.release()
93
+ }
94
+
95
+ timer.unref()
96
+ process.on('exit', onExit)
97
+
98
+ return {
99
+ // A new step is drawn at once, its clock started again; a count moving on waits for the next frame, so a burst of updates writes one.
100
+ update: (progress) => {
101
+ const step = current?.step
102
+
103
+ current = progress
104
+
105
+ if (progress.step !== step) {
106
+ started = performance.now()
107
+ draw()
108
+ }
109
+ },
110
+ stop,
111
+ done: (text) => {
112
+ stop()
113
+ write(`${marked('ok', text, stream, formatDuration(performance.now() - begun), env)}\n`)
114
+ },
115
+ failed: (text) => {
116
+ stop()
117
+ write(`${marked('problem', text, stream, undefined, env)}\n`)
118
+ },
119
+ }
120
+ }
121
+
122
+ // Runs one step under a spinner on a terminal, then leaves `✓ <label> <time>`, or `✗ <label>` and throws again; off a terminal those lines alone print, as `ok:` and `error:`.
123
+ export async function task<T>(label: string, run: (update: (progress: Omit<Progress, 'step'>) => void) => Promise<T>, stream: ProgressStream = process.stderr, env: Env = process.env): Promise<T> {
124
+ const line = progressLine(stream, env)
125
+
126
+ line.update({ step: label })
127
+
128
+ try {
129
+ const result = await run(progress => line.update({ ...progress, step: label }))
130
+
131
+ line.done(label)
132
+
133
+ return result
134
+ }
135
+ catch (error) {
136
+ line.failed(label)
137
+
138
+ throw error
139
+ }
140
+ }
141
+
142
+ // `⠹ prerendering ████████░░░░░░░░░░░░ 8/20 routes 4.2s`; a step with no total has no bar.
143
+ export function progressText(progress: Progress, spinner: string, ms: number): string {
144
+ return progressParts(progress, spinner, ms).map(part => part.text).join('')
145
+ }
146
+
147
+ function progressParts({ step, done, total, unit }: Progress, spinner: string, ms: number): Part[] {
148
+ const parts: Part[] = [{ text: spinner, tint: colors => colors.cyan }, { text: ` ${step}`, tint: () => plain }]
149
+
150
+ if (total !== undefined && total > 0) {
151
+ const counted = Math.min(done ?? 0, total)
152
+ const filled = Math.round(counted / total * BAR_WIDTH)
153
+
154
+ parts.push(
155
+ { text: ' ', tint: () => plain },
156
+ { text: '█'.repeat(filled), tint: colors => colors.cyan },
157
+ { text: '░'.repeat(BAR_WIDTH - filled), tint: colors => colors.dim },
158
+ { text: ` ${counted}/${total}${unit === undefined ? '' : ` ${unit}`}`, tint: colors => colors.dim },
159
+ )
160
+ }
161
+
162
+ parts.push({ text: ` ${(ms / 1000).toFixed(1)}s`, tint: colors => colors.dim })
163
+
164
+ return parts
165
+ }
166
+
167
+ function plain(text: string): string {
168
+ return text
169
+ }
170
+
171
+ // The parts cut to `width` visible characters, each tinted after the cut so no escape is split.
172
+ function fit(parts: Part[], width: number, colors: Colors): string {
173
+ let room = width
174
+ let line = ''
175
+
176
+ for (const { text, tint } of parts) {
177
+ const kept = [...text].slice(0, Math.max(room, 0)).join('')
178
+
179
+ room -= kept.length
180
+
181
+ if (kept !== '')
182
+ line += tint(colors)(kept)
183
+ }
184
+
185
+ return line
186
+ }
187
+
188
+ // Clears the line before anything the console or a stream writes, the caller's own code meanwhile included, and answers how to put them back.
189
+ function interceptOutput(clear: () => void): () => void {
190
+ const targets = [...CONSOLE_METHODS.map(name => [console, name] as const), [process.stdout, 'write'] as const, [process.stderr, 'write'] as const]
191
+ const wrapped = targets.map(([target, name]) => {
192
+ const original = Reflect.get(target, name) as (...args: unknown[]) => unknown
193
+ const wrapper = (...args: unknown[]) => {
194
+ clear()
195
+
196
+ return original.apply(target, args)
197
+ }
198
+
199
+ Reflect.set(target, name, wrapper)
200
+
201
+ return { target, name, original, wrapper }
202
+ })
203
+
204
+ // What the caller replaced meanwhile is its own, and stays.
205
+ return () => {
206
+ for (const { target, name, original, wrapper } of wrapped) {
207
+ if (Reflect.get(target, name) === wrapper)
208
+ Reflect.set(target, name, original)
209
+ }
210
+ }
211
+ }