@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 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`, and `ft ci` and `waitfor` show what they wait on with it.
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.12.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|[smh])?$/
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 `1h` in milliseconds, a bare number counting in `unit`.
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 1h, not ${value}`)
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 onExit = () => clear()
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 SAFE_WORD.test(word) || word === '{}' ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`
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: '' }