@kaynooo/cli-kit 0.12.0 → 0.14.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 +3 -2
- package/package.json +1 -1
- package/src/args.ts +13 -5
- package/src/progress.ts +37 -1
- package/src/shell.ts +6 -1
- package/src/test.ts +22 -0
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
44
44
|
- `<bin> __complete <words>... <word>` is answered by `runCli` and `runMain` for shell completion: the subcommands with their summaries, a leaf command's options (read by running it until its `parse` call throws them, so every command parses its arguments first), or `__files__` where a path fits.
|
|
45
45
|
- `parse(args, usage, options, { rest: true })` stops at the first `--` and returns what follows untouched as `rest` (`out -n 200 -- <cmd>`), `undefined` without a `--`; `{ allowNegative: true }` takes `--no-<flag>` as `false` for a boolean flag.
|
|
46
46
|
- `readInput(path, { noStdin })` reads a file, or stdin when the path is `-`; a missing file or a stdin left on the terminal ends the command with a message.
|
|
47
|
+
- `isStdinPiped()` is true when stdin is a pipe or a file, false on a terminal or `/dev/null`, so a command can read what is piped in without waiting on a terminal.
|
|
47
48
|
- `runCommand(argv, { cwd, env, stdin, timeoutMs, signal, maxBytes, onChunk, onStart })` runs a command and returns `{ code, text, output, chunks, timedOut, truncated }`, the output of both streams (in arrival order, tagged by stream in `chunks`); it throws when the command cannot start. With `timeoutMs`, `signal`, `onChunk` or `onStart` the child leads its own process group, ended whole (SIGTERM, SIGKILL after 2 s) when the timeout passes, the signal aborts or `onChunk` throws, the pipes still read to the end: `timedOut` is then true, for the caller to exit 2. `maxBytes` caps what `chunks`, `output` and `text` keep (`truncated`), `onChunk` sees every chunk, and `onStart` hands over `{ kill }` for the whole group, to pass to `holdSignals().forward`. `exitCodeOf(code, signal)` is the exit code a shell reports.
|
|
48
49
|
- `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).
|
|
49
50
|
- `privateLogDirectory(name)` returns a folder for log files only this user reads (`$XDG_RUNTIME_DIR` or `~/.cache/agent-tools`, mode 0700, verified with `lstat` when it exists, else a fresh temp folder), `createLog(path)` opens a 0600 file that must not exist (returning its descriptor), and `sweepLogs(dir, maxAgeMs?)` removes what is older than a day, subfolders included.
|
|
@@ -58,7 +59,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
58
59
|
- `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`).
|
|
59
60
|
- `ok(text, detail?)`, `note`, `warn` and `problem` print a status line marked with a green ✓, a dimmed ·, a yellow ! or a red ✗ (`ok` and `note` on stdout, the others on stderr), the detail dimmed after the text; plain, the marks read `ok:`, `note:`, `warning:` and `error:`. `marked(mark, text, stream, detail?)` returns such a line, `paint(stream)` the colours for a stream and `takesControlCodes(stream)` whether it draws in place.
|
|
60
61
|
- `isSecretName(name)` is true for the name of a variable, column or JSON key that holds a secret (`API_TOKEN`, `DB_PASS`, `SMTP_PWD`, `clientSecret`, `session_id`, `ssn`), by whole words so `compass` and `author` stay plain; `ev`, `redact` and `db` share it.
|
|
61
|
-
- `capture(fn)` from `@kaynooo/cli-kit/test` runs a command in-process and returns `{ result, out, err }`, what it printed through `console` and `process.stdout`/`stderr`, so a test needs no spy.
|
|
62
|
+
- `capture(fn)` from `@kaynooo/cli-kit/test` runs a command in-process and returns `{ result, out, err }`, what it printed through `console` and `process.stdout`/`stderr`, so a test needs no spy. `tempDirs()`, called at the top of a test file, answers a `temp(prefix?)` making fresh folders under the system temp folder, all removed once the file's tests are done.
|
|
62
63
|
- `findUp(names, from?)` is the nearest of `names` in the current folder (or `from`) or one above it; `loadConfig(names, { from?, path? })` imports the nearest config (`.ts`, `.js` or `.json`), or the file `path` names, and returns `{ path, dir, config }` with its default export for the caller to check, `undefined` when there is none. A named file missing, one failing to import or one without a default export ends the command with exit 1.
|
|
63
64
|
- `@kaynooo/cli-kit/prompt` asks on the terminal, on stderr:
|
|
64
65
|
- `select(question, choices, { initial })` picks one `{ value, label, hint }`: typing filters the choices (every word typed in the label or hint, case ignored, Backspace editing), the arrows move and Enter picks.
|
|
@@ -69,7 +70,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
69
70
|
- `@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.
|
|
70
71
|
- `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.
|
|
71
72
|
- `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.
|
|
72
|
-
- `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
|
|
73
|
+
- `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`; keys typed on the terminal meanwhile are swallowed, Ctrl+C aside, so an Enter cannot push it down and leave a line behind. `ft ci` and `ft ci` and `waitfor` show what they wait on with it.
|
|
73
74
|
- `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`.
|
|
74
75
|
- `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.
|
|
75
76
|
- `@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.14.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 <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|
package/src/args.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ParseArgsOptionDescriptor } from 'node:util'
|
|
2
|
+
import { fstatSync } from 'node:fs'
|
|
2
3
|
import process from 'node:process'
|
|
3
4
|
import { parseArgs } from 'node:util'
|
|
4
5
|
import { upTo } from './format'
|
|
@@ -291,11 +292,11 @@ export function wholeNumber(value: string | undefined, name: string, fallback: n
|
|
|
291
292
|
return number
|
|
292
293
|
}
|
|
293
294
|
|
|
294
|
-
const DURATION = /^(\d+(?:\.\d+)?)(ms|[
|
|
295
|
-
const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 } as const
|
|
295
|
+
const DURATION = /^(\d+(?:\.\d+)?)(ms|[smhd])?$/
|
|
296
|
+
const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
|
|
296
297
|
|
|
297
|
-
// A duration such as `500ms`, `90s`, `5m` or `
|
|
298
|
-
export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' = 's'): number {
|
|
298
|
+
// A duration such as `500ms`, `90s`, `5m`, `1h` or `7d` in milliseconds, a bare number counting in `unit`.
|
|
299
|
+
export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' | 'h' = 's'): number {
|
|
299
300
|
if (value === undefined)
|
|
300
301
|
return fallback
|
|
301
302
|
|
|
@@ -303,11 +304,18 @@ export function duration(value: string | undefined, name: string, fallback: numb
|
|
|
303
304
|
const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
|
|
304
305
|
|
|
305
306
|
if (ms < 1)
|
|
306
|
-
badUsage(`--${name} takes a duration such as 30s, 5m or
|
|
307
|
+
badUsage(`--${name} takes a duration such as 30s, 5m, 1h or 7d, not ${value}`)
|
|
307
308
|
|
|
308
309
|
return ms
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
// Whether stdin is a pipe or a file to read, rather than a terminal or /dev/null that would leave a command waiting or reading nothing.
|
|
313
|
+
export function isStdinPiped(): boolean {
|
|
314
|
+
const stat = fstatSync(0)
|
|
315
|
+
|
|
316
|
+
return stat.isFIFO() || stat.isFile()
|
|
317
|
+
}
|
|
318
|
+
|
|
311
319
|
export interface InputSettings {
|
|
312
320
|
// What to say when stdin is a terminal, as nothing is piped.
|
|
313
321
|
noStdin?: string
|
package/src/progress.ts
CHANGED
|
@@ -62,6 +62,8 @@ const ESC = String.fromCharCode(27)
|
|
|
62
62
|
// A colour or cursor code, or an OSC 8 link's opening or closing, read where `lastIndex` points.
|
|
63
63
|
const ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, 'y')
|
|
64
64
|
const RESET_STYLE = `${ESC}[0m`
|
|
65
|
+
// What Ctrl+C sends once raw mode stops the terminal turning it into SIGINT.
|
|
66
|
+
const CTRL_C = 3
|
|
65
67
|
const CLOSE_LINK = `${ESC}]8;;${ESC}\\`
|
|
66
68
|
// What the console prints with, to the terminal the line is drawn on or the other.
|
|
67
69
|
const CONSOLE_METHODS = ['log', 'info', 'warn', 'error', 'debug', 'dir', 'dirxml', 'table', 'trace', 'group', 'groupCollapsed', 'count', 'timeEnd', 'timeLog', 'assert'] as const
|
|
@@ -102,7 +104,11 @@ export function liveRegion(render: (spinner: string) => string[], stream: Progre
|
|
|
102
104
|
}
|
|
103
105
|
const timer = setInterval(draw, FRAME_MS)
|
|
104
106
|
const restore = interceptOutput(clear)
|
|
105
|
-
const
|
|
107
|
+
const release = swallowInput()
|
|
108
|
+
const onExit = () => {
|
|
109
|
+
clear()
|
|
110
|
+
release()
|
|
111
|
+
}
|
|
106
112
|
// Interrupted, the block goes before the shell's prompt comes back.
|
|
107
113
|
const signals = holdSignals(stop)
|
|
108
114
|
|
|
@@ -110,6 +116,7 @@ export function liveRegion(render: (spinner: string) => string[], stream: Progre
|
|
|
110
116
|
clearInterval(timer)
|
|
111
117
|
clear()
|
|
112
118
|
restore()
|
|
119
|
+
release()
|
|
113
120
|
process.off('exit', onExit)
|
|
114
121
|
signals.release()
|
|
115
122
|
}
|
|
@@ -288,6 +295,35 @@ function cutToWidth(line: string, width: number): string {
|
|
|
288
295
|
return `${kept}${RESET_STYLE}${linked ? CLOSE_LINK : ''}`
|
|
289
296
|
}
|
|
290
297
|
|
|
298
|
+
// Keeps keys typed on the terminal from being echoed while the region is drawn: an Enter would push it down a line and leave its first line behind on every redraw. Ctrl+C still interrupts.
|
|
299
|
+
function swallowInput(): () => void {
|
|
300
|
+
const input = process.stdin
|
|
301
|
+
|
|
302
|
+
if (!input.isTTY)
|
|
303
|
+
return () => undefined
|
|
304
|
+
|
|
305
|
+
const onData = (data: Buffer) => {
|
|
306
|
+
if (data.includes(CTRL_C))
|
|
307
|
+
process.kill(process.pid, 'SIGINT')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
input.setRawMode(true)
|
|
311
|
+
input.on('data', onData)
|
|
312
|
+
input.resume()
|
|
313
|
+
|
|
314
|
+
let released = false
|
|
315
|
+
|
|
316
|
+
return () => {
|
|
317
|
+
if (released)
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
released = true
|
|
321
|
+
input.off('data', onData)
|
|
322
|
+
input.pause()
|
|
323
|
+
input.setRawMode(false)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
291
327
|
// 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.
|
|
292
328
|
function interceptOutput(clear: () => void): () => void {
|
|
293
329
|
const targets = [...CONSOLE_METHODS.map(name => [console, name] as const), [process.stdout, 'write'] as const, [process.stderr, 'write'] as const]
|
package/src/shell.ts
CHANGED
|
@@ -592,8 +592,13 @@ export function parseCommand(text: string): Invocation {
|
|
|
592
592
|
return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
// A word as a POSIX shell or zsh reads it back unchanged: left bare when it holds nothing the shell would act on (zsh expands a leading `=`), else single-quoted.
|
|
596
|
+
export function shellQuote(word: string): string {
|
|
597
|
+
return SAFE_WORD.test(word) && !word.startsWith('=') ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`
|
|
598
|
+
}
|
|
599
|
+
|
|
595
600
|
function quoted(word: string): string {
|
|
596
|
-
return
|
|
601
|
+
return word === '{}' ? word : shellQuote(word)
|
|
597
602
|
}
|
|
598
603
|
|
|
599
604
|
// The command strings a piece hands to a shell with `-c`, wherever the shell sits in it (`docker exec db sh -c '...'`, `su -c '...'`).
|
package/src/test.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
1
4
|
import process from 'node:process'
|
|
2
5
|
import { format } from 'node:util'
|
|
6
|
+
import { afterAll } from 'bun:test'
|
|
3
7
|
|
|
4
8
|
export interface Captured<T> {
|
|
5
9
|
result: T
|
|
@@ -9,6 +13,24 @@ export interface Captured<T> {
|
|
|
9
13
|
err: string
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
// Called at the top of a test file, answers a maker of fresh folders under the system temp folder, each removed with its content once the file's tests are done.
|
|
17
|
+
export function tempDirs(): (prefix?: string) => string {
|
|
18
|
+
const made: string[] = []
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
for (const dir of made.splice(0))
|
|
22
|
+
rmSync(dir, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return (prefix = 'test-') => {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), prefix))
|
|
27
|
+
|
|
28
|
+
made.push(dir)
|
|
29
|
+
|
|
30
|
+
return dir
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
// Runs a command in-process and returns what it printed, so a test needs no spy on console. Output is restored even when the command throws.
|
|
13
35
|
export async function capture<T>(run: () => Promise<T> | T): Promise<Captured<T>> {
|
|
14
36
|
const written = { out: '', err: '' }
|