@kaynooo/cli-kit 0.19.0 → 0.21.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
@@ -24,7 +24,30 @@ process.exitCode = await runCli({
24
24
 
25
25
  ## Exit codes
26
26
 
27
- 0 for success, help and version; 2 for a usage error (an unknown command, a bad option, a missing argument, what `parse` and `fail` throw by default); 1 for a command that ran and failed (`fail(message, 1)`, or any other error a command throws).
27
+ Every bin answers by one table, stated in each command's `--help` under `Exits:` (the `exits` setting of `parse`, a list of `[code, meaning]`).
28
+
29
+ | Code | Meaning |
30
+ | --- | --- |
31
+ | 0 | clean: it did what was asked, found nothing wrong; also `--help` and `--version` |
32
+ | 1 | a finding (`audit`, `doctor`, `guard`), nothing found (`peek`, `hotspots`, `todo-scan`, `ports`, `pick`, `view`), or an operation that ran and failed (Forgejo, GitLab or the database refused, a step failed: `fail(message, 1)` and any other error a command throws) |
33
+ | 2 | a usage error (an unknown command or option, a bad value, a missing argument: what `parse` and `fail` throw by default), or could not check (a timeout, an unreachable registry, no lockfile): never reported as clean |
34
+
35
+ Exceptions, each stated in the command's own `Exits:`:
36
+
37
+ - A bin running another command (`base`, `proof`, `out`, `slot`, `dc`, `dx`, `sf`, `gb`, `gc` on a signal) exits with that command's code: 126 when it cannot be run, 127 when it cannot be started, 128 plus the signal when interrupted.
38
+ - `probe` exits 1 when the site could not be reached, that being its finding, and 0 when it answered, whatever it warns about.
39
+ - `guard hook` never exits 2, which would block the tool call it vets.
40
+ - `waitfor` and `ft watch`/`ft ci` exit 2 on a timeout, 1 on the failure they waited for.
41
+
42
+ `tests/exits.test.ts` holds every bin to this: each command states its codes, only from the table, and answers `--help` with 0 and an unknown option with 2.
43
+
44
+ ## Flags that mean the same everywhere
45
+
46
+ - `-n` is always a count: `--limit` (rows of a listing, with `--full` for all of them, from `limitOptions`), `--rows` (`db`, once `--limit`), `--lines` (`dlog`, `out` and `waitfor task`: lines of output kept), `--count` (`sub`, `dev uuid`) and `--slots` (`slot`). A listing cuts to its default and says `N more, rerun with --full`; `--json` is never cut on its own, only by an explicit `-n`.
47
+ - `-t`/`--timeout` is how long to wait before giving up, a duration (`30s`, `5m`, `1h`). A bare number counts in the unit the help names: minutes for `ft ci`, `ft watch`, `ft release` and `gl ci`, seconds for `waitfor`, `slot` and `pkg bump`.
48
+ - `-r`/`--repo` is the forge repository (`ft`, `gl`, `changelog`). Bins with no repository keep `-r` for their own noun: `--ref` (`base`, `proof`), `--regex` (`sub`), `--refs` (`peek`).
49
+ - `--dry-run` shows what would change without changing it (`sub`, `ft release`, `pkg bump`, `changelog`; `changelog` keeps `--dry`).
50
+ - A renamed flag keeps its old spelling through `aliases` on the option (`rows` answers `--limit`, `changelog` answers `--gitea` and `--dry`), left out of the help and of `__complete`.
28
51
 
29
52
  ## Look
30
53
 
@@ -32,6 +55,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
32
55
 
33
56
  ## API
34
57
 
58
+ - `version` is optional on `runCli`, `runMain` and `mainArgs`: it defaults to `packageVersion()`, the version of the nearest `package.json` above the entry file (links resolved, so `src/cli.ts` and the built `dist/cli.js` both find the package's own), which saves each bin its `import { version } from '../package.json'`.
35
59
  - `runCli({ name, version, about, commands, aliases })` dispatches `<cli> <command> [args]`: `--help` lists the commands, `--version` prints the version, a typo names the command it likely meant, and an `Exit` thrown by a command prints `error: <cli> <command>: <message>`, its hint under it, and exits with its code. Any other error exits 1, named by its class unless a plain `Error` (`error: tool sync: MigrationError: ...`), its cause's message on a `cause:` line under it when the cause is an `Error`; `redact(text)` rewrites both before they print, for errors that may quote what a log must not keep.
36
60
  - `runTool(label, argv, { timeoutMs, cwd, onStart })` runs a command through `runCommand` with the current environment and returns `{ code, stdout, stderr }`; it throws, naming `label`, when the command cannot start (noting a removed working directory) or outlives `timeoutMs`. `isPermanentFailure(error)` is true for what a retry cannot mend: a missing binary, or an answer of 401, 403 or 404. `cwdGone()` tells that the working directory was deleted.
37
61
  - `git(args, timeoutMs?)`, `gitOk`, `resolveCommit`, `fetchBranch`, `defaultBranch` and `leaveWorktree` are the git helpers the forge CLIs share, over `runTool`.
@@ -42,6 +66,8 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
42
66
  - `runMain({ name, version, run })` runs a CLI that is a single command: `--version` or `-v` alone prints the version, and an `Exit` or any error prints `error: <name>: <message>` and becomes the exit code. `await` it into `process.exitCode`.
43
67
  - `runCli` also takes `defaultCommand`, the command that gets the arguments when the first one names no command (`db "select 1"` runs `db query "select 1"`, and an error there reads `error: db query: ...`), its options listed in `<cli> --help` under the commands, and `runBare: true` to have a bare `<cli>` run it instead of printing the help, and a command may be a group, `{ summary, commands, aliases? }`, dispatched with its own help and typo hints (`ft plan add`). A command taking `hidden: true` runs as usual but is left out of `--help`, `KIT_HELP=json`, `__complete` and the typo hints, for one only a script or a shell binding calls.
44
68
  - Commands known only once something is read (a project's config adding some) are lazy: `extraCommands: async (words) => ({ ... })` on the CLI or on a group adds them to its `commands` (which win a shared name), and `commands` itself may be such a function when every command is lazy. The function gets the words typed so far from the CLI's name on (`['team', 'hello', 'b']`) and is called only when the first word names no command the level already has, or for `--help`, `help` and `__complete`, never for a plain run of one it has, so existing `Cli` literals are unchanged. A `summary` may be an async function too, awaited only where a help, the `KIT_HELP=json` description or `__complete` lists it, never for a run. An error the function throws is not caught: handle it inside.
69
+ - `runCli` and `runMain` record each run in `$XDG_STATE_HOME/kaynooo-cli/usage.jsonl` (`~/.local/state/...`, 0600): the bin, its command, the flag names it saw (never a value or an argument), the exit code and the unknown option of a usage error. `KAYNOOO_CLI_NO_USAGE=1` stops it, `NODE_ENV=test` too, and `usage: false` on the `Cli` or `Main` opts a bin out (`guard`, run on every tool call). `readUsage` and `summarizeUsage` read it, `cli stats` shows it.
70
+ - Shell completion: `cli completions fish --install` writes one file per bin, `source <(cli completions bash)` in `~/.bashrc` loads one function for all of them, and zsh reads that same script through `bashcompinit`. Each bin answers `<bin> __complete <words>... <word>`, below.
45
71
  - `<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. `parse`'s `candidates: { '<project>': async () => ['alpha', ['beta', 'its description']] }` (by the positional's name as `positionals` writes it) gives the values of a positional, and `candidates: () => [...]` on a string option those of its value, besides `choices`; the function runs only while completing, and a command that gives none still answers `__files__`.
46
72
  - `parse` options also take `optional: { bare: '1', accepts: /^\d+$/ }` on a string option (`--out [n]`: bare it reads `bare`, a next word is its value unless it starts with a dash or fails `accepts`, and then stays a positional), `conflicts: ['pdf']` (both given is a usage error naming them) and, with `allowNegative`, `negative: 'keep the cached copy'` on a boolean option, which the help and `__complete` then list as `--no-<name>` with that description, the positive flag only when it has a `description` of its own.
47
73
  - `parse(args, usage, options, { positionals, trailing: true })` hands everything after the last declared positional back as `rest`, dashes included and without a `--` (`athome compose <project> exec -T php bash`); options typed before it parse as usual.
@@ -54,6 +80,9 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
54
80
  - `openUrl(target, env?)` opens a URL, or a file path made absolute, with the command `BROWSER` names or the platform's opener (`xdg-open`, `open`, `start`), without waiting; it returns `false` when that command cannot start.
55
81
  - Every command's `parse`, and `runCli` before the command name (`ft --plain runs`), takes `--color auto|always|never` and `--plain` (`--color never`), unless the command has its own option of that name. `always` sets `FORCE_COLOR=1` and `never` sets `NO_COLOR=1`, each dropping the other, so the kit's colours, marks and redrawn lines follow, and so do the processes the command starts; `always` colours a pipe but never redraws in one. `applyOutput(color, plain, env?)` is what they run.
56
82
  - `--help` lists the commands under `Commands:`, then a heading per `group` a command names (`{ summary, group: 'Admin', run }`), then the level's `Options:` and the `Examples:` the CLI or a group's `examples` give (`['ft ci', ['ft ci HEAD~1', 'the commit before']]`, a note shown dimmed after `#`); a command's own examples go to `parse(args, usage, options, { examples })`, shown under its options.
83
+ - `exits: [[0, 'clean'], [1, 'a finding'], [2, 'a usage error']]` in `parse`'s settings prints an `Exits:` block after the options (a code may be a word, `['its own', 'the code of the command']`), and `KIT_HELP=json` carries it as `exits: [{code, meaning}]`.
84
+ - `aliases: ['limit']` on an option keeps an old long name working: it is read as the option, bare or with `=`, up to a `--`, and shown in neither the help nor `__complete`.
85
+ - `compose(args, { flags, cwd, env })` runs `docker compose <flags> <args>` and returns `{ code, stdout, stderr }` whatever the exit code (it throws only when docker cannot start), `captureOutput(argv, { cwd, env })` is the same for any command, `composeError(output)` words a failure as the last line of stderr or `exit <code>`, and `jsonLines<T>(text)` reads `compose ps --format json`, one object per line or the older array.
57
86
  - `replaces` on the CLI or in `parse`'s settings (`'sed -n N,Mp, grep -n chains'`) adds an `Instead of:` line under the usage. With `KIT_HELP=json` in the environment, `--help` prints `{synopsis, summary, replaces?, examples}` instead, the usage's first line split at its first `: `, which is how `cli list` reads every bin.
58
87
  - `runHint(command, effect?)` words the hint of a refusal that a command resolves, ``run `ft take 12 --take-stale` to take it over``, so every CLI writes it alike: `fail(message, 1, runHint('ft take 12 --take-stale', 'take it over'))`. `printHint(hint)` prints the `hint:` line under a refusal a command prints itself (one issue of several refused).
59
88
  - `limitOptions(fallback)` is the `--limit <n>` (`-n`) and `--full` flags of a listing, `limitOf(values, fallback)` the row count they ask for (Infinity with `--full`; with `json: true` in the values, Infinity unless `--limit` is given, so JSON is never cut on its own), and `capped(rows, limit)` the first rows plus the footer `N more, rerun with --full` when some are left out.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
3
  "type": "module",
4
- "version": "0.19.0",
4
+ "version": "0.21.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",
@@ -23,7 +23,8 @@
23
23
  "./progress": "./src/progress.ts",
24
24
  "./prompt": "./src/prompt.ts",
25
25
  "./shell": "./src/shell.ts",
26
- "./test": "./src/test.ts"
26
+ "./test": "./src/test.ts",
27
+ "./fzf": "./src/fzf.ts"
27
28
  },
28
29
  "files": ["src"],
29
30
  "engines": {
@@ -0,0 +1,22 @@
1
+ export const SEVERITIES = ['low', 'moderate', 'high', 'critical'] as const
2
+
3
+ export type Severity = (typeof SEVERITIES)[number]
4
+
5
+ /** One advisory as `audit` and `pkg audit` both list it: `ghsa` is read from the url and `fixed` is the version its range ends below, both absent when none applies. */
6
+ export interface AdvisoryRow {
7
+ id: number
8
+ ghsa?: string | undefined
9
+ url: string
10
+ title: string
11
+ severity: Severity
12
+ fixed?: string | undefined
13
+ }
14
+
15
+ export function severityRank(severity: Severity): number {
16
+ return SEVERITIES.indexOf(severity)
17
+ }
18
+
19
+ /** Whether `--ignore` skips an advisory, by its GHSA or numeric id. */
20
+ export function isIgnored(advisory: Pick<AdvisoryRow, 'id' | 'ghsa'>, ignore: readonly string[]): boolean {
21
+ return ignore.includes(advisory.ghsa ?? '') || ignore.includes(String(advisory.id))
22
+ }
package/src/args.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import type { ParseArgsOptionDescriptor } from 'node:util'
2
- import { fstatSync } from 'node:fs'
2
+ import type { ExitRow } from './help'
3
3
  import process from 'node:process'
4
4
  import { parseArgs } from 'node:util'
5
5
  import { upTo } from './format'
6
- import { paint } from './style'
6
+ import { description, helpText, wantsHelpJson } from './help'
7
7
  import { closest } from './text'
8
8
 
9
+ const DASHES = /^-+/
10
+
9
11
  interface OptionExtras {
10
12
  description?: string
11
13
  // A string option taking `choices` accepts only those values: another is a usage error naming them, the help shows them as the value and `__complete` offers them.
@@ -18,6 +20,8 @@ interface OptionExtras {
18
20
  conflicts?: readonly string[]
19
21
  // With `allowNegative`, a boolean option's `--no-<name>` as the help and `__complete` list it, with this description; the positive flag is then left out of both unless it has a `description` of its own, for a flag that defaults on.
20
22
  negative?: string
23
+ // Other long names the option answers to, kept for the spelling it had before a rename; the help and `__complete` list only its own name.
24
+ aliases?: readonly string[]
21
25
  }
22
26
 
23
27
  export type Options = Record<string, ParseArgsOptionDescriptor & OptionExtras>
@@ -69,77 +73,6 @@ export type Candidates = () => Candidate[] | Promise<Candidate[]>
69
73
 
70
74
  export const completion = { active: false }
71
75
 
72
- // Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
73
- const OPTIONAL = /\[[^\]]+\]/g
74
- const USAGE_PREFIX = /^usage:\s*/i
75
- const FULL_STOP = /\.$/
76
- const DASHES = /^-+/
77
-
78
- // `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
79
- export function usageLine(usage: string, colors = paint(process.stdout)): string {
80
- return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
81
- }
82
-
83
- export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces }: Described = {}): string {
84
- return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
85
- }
86
-
87
- // An `Instead of:` line, after a blank line; nothing without one.
88
- export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
89
- return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
90
- }
91
-
92
- // What a bin's --help prints under KIT_HELP=json, for `cli list` to read without parsing the text.
93
- export interface Description {
94
- synopsis: string
95
- summary: string
96
- replaces?: string
97
- examples: { line: string, note?: string }[]
98
- }
99
-
100
- export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
101
- return env.KIT_HELP === 'json'
102
- }
103
-
104
- // The synopsis and the summary a usage's first line splits into at its first `: `, else the first line after the synopses as the summary, with the examples spelled out.
105
- export function description(usage: string, { examples = [], replaces }: Described = {}): Description {
106
- const lines = usage.replace(USAGE_PREFIX, '').split('\n')
107
- const first = lines[0]!
108
- const colon = first.indexOf(': ')
109
- const prose = lines[lines.indexOf('') + 1] ?? ''
110
- const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
111
-
112
- return {
113
- synopsis: colon === -1 ? first : first.slice(0, colon),
114
- summary: colon === -1 ? fallback : first.slice(colon + 2),
115
- ...replaces === undefined ? {} : { replaces },
116
- examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
117
- }
118
- }
119
-
120
- // An `Examples:` section, after a blank line, with a line per example; nothing without one.
121
- export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
122
- if (examples.length === 0)
123
- return []
124
-
125
- return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
126
- }
127
-
128
- // A line per option, `-n, --times <value> how many`, aligned on the visible text.
129
- export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
130
- const rows = Object.entries(options).map(([name, option]) => {
131
- const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
132
- const named = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
133
- const placeholder = option.optional === undefined ? named : `[${named}]`
134
- const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
135
-
136
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
137
- })
138
- const width = Math.max(0, ...rows.map(row => row.width))
139
-
140
- return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
141
- }
142
-
143
76
  // The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
144
77
  type Slots<Names extends readonly string[]> = Names extends readonly [infer First extends string, ...infer Others extends readonly string[]]
145
78
  ? First extends `${string}...` | `${string}...]` ? string[] : First extends `[${string}` ? [string?, ...Slots<Others>] : [string, ...Slots<Others>]
@@ -169,12 +102,14 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
169
102
  examples?: Example[]
170
103
  // What the command is reached for instead of, shown as `Instead of:` in the help and listed by `cli list`: 'sed -n N,Mp, grep -n chains and whole-file reads'.
171
104
  replaces?: string
105
+ // What each exit code means, shown as an `Exits:` block in the help and listed by `cli list`: `[[0, 'clean'], [1, 'a finding'], [2, 'a usage error']]`.
106
+ exits?: ExitRow[]
172
107
  }
173
108
 
174
109
  export type Example = string | [line: string, note: string]
175
110
 
176
111
  // The parts of the settings the help and the description show.
177
- interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
112
+ export interface Described { examples?: Example[] | undefined, replaces?: string | undefined, exits?: ExitRow[] | undefined }
178
113
 
179
114
  // The output flags every command takes, before its own help.
180
115
  export const OUTPUT_OPTIONS = {
@@ -219,7 +154,7 @@ export function parse<const T extends Options, const Names extends readonly stri
219
154
  let parsed
220
155
 
221
156
  try {
222
- parsed = parseArgs({ args: joinDashValues(withBare(own, all), all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
157
+ parsed = parseArgs({ args: joinDashValues(withBare(withAliases(own, all), all), all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
223
158
  }
224
159
  catch (error) {
225
160
  const message = parseError(error)
@@ -232,7 +167,7 @@ export function parse<const T extends Options, const Names extends readonly stri
232
167
  applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
233
168
 
234
169
  if (values.help === true) {
235
- console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, listing, undefined, settings))
170
+ console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings, listing)) : helpText(usage, listing, undefined, settings))
236
171
 
237
172
  throw new HelpShown()
238
173
  }
@@ -296,6 +231,24 @@ function listed(options: Options, allowNegative: boolean): Options {
296
231
  }))
297
232
  }
298
233
 
234
+ // The arguments with an option typed under one of its `aliases` renamed to its own name, up to a `--`: `--limit 5` becomes `--rows 5`.
235
+ function withAliases(args: string[], options: Options): string[] {
236
+ const named = new Map<string, string>(Object.entries(options).flatMap(([name, { aliases = [] }]) => aliases.map((alias): [string, string] => [`--${alias}`, `--${name}`])))
237
+
238
+ if (named.size === 0)
239
+ return args
240
+
241
+ const separator = args.indexOf('--')
242
+ const own = separator === -1 ? args : args.slice(0, separator)
243
+
244
+ return [...own.map((arg) => {
245
+ const at = arg.indexOf('=')
246
+ const current = named.get(at === -1 ? arg : arg.slice(0, at))
247
+
248
+ return current === undefined ? arg : `${current}${at === -1 ? '' : arg.slice(at)}`
249
+ }), ...separator === -1 ? [] : args.slice(separator)]
250
+ }
251
+
299
252
  // The arguments with an optional-value option typed bare given its fallback, ahead of the next argument: `--out` becomes `--out 1` unless a value follows it.
300
253
  function withBare(args: string[], options: Options): string[] {
301
254
  const bare = new Map<string, NonNullable<Options[string]['optional']>>(Object.entries(options).flatMap(([name, { short, optional }]) => optional === undefined ? [] : [[`--${name}`, optional] as const, ...short === undefined ? [] : [[`-${short}`, optional] as const]]))
@@ -419,63 +372,5 @@ function nearestOption(message: string, options: Options, allowNegative: boolean
419
372
  return meant === undefined ? undefined : `did you mean ${known.find(flag => flag.replace(DASHES, '') === meant)}?`
420
373
  }
421
374
 
422
- const WHOLE = /^\d+$/
423
-
424
- // A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
425
- export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
426
- if (value === undefined)
427
- return fallback
428
-
429
- const number = Number(value)
430
-
431
- if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
432
- badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
433
-
434
- return number
435
- }
436
-
437
- const DURATION = /^(\d+(?:\.\d+)?)(ms|[smhd])?$/
438
- const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
439
-
440
- // A duration such as `500ms`, `90s`, `5m`, `1h` or `7d` in milliseconds, a bare number counting in `unit`.
441
- export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' | 'h' = 's'): number {
442
- if (value === undefined)
443
- return fallback
444
-
445
- const match = DURATION.exec(value.trim())
446
- const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
447
-
448
- if (ms < 1)
449
- badUsage(`--${name} takes a duration such as 30s, 5m, 1h or 7d, not ${value}`)
450
-
451
- return ms
452
- }
453
-
454
- // 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.
455
- export function isStdinPiped(): boolean {
456
- const stat = fstatSync(0)
457
-
458
- return stat.isFIFO() || stat.isFile()
459
- }
460
-
461
- export interface InputSettings {
462
- // What to say when stdin is a terminal, as nothing is piped.
463
- noStdin?: string
464
- }
465
-
466
- // Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
467
- export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
468
- if (path === '-') {
469
- if (process.stdin.isTTY)
470
- fail(noStdin)
471
-
472
- return Bun.stdin.text()
473
- }
474
-
475
- const file = Bun.file(path)
476
-
477
- if (!await file.exists())
478
- fail(`no file ${path}`)
479
-
480
- return file.text()
481
- }
375
+ export * from './help'
376
+ export * from './input'
package/src/ci.ts CHANGED
@@ -21,6 +21,8 @@ export interface CiWait<T> {
21
21
  // How long queued work may sit while nothing moves.
22
22
  stalledMs: number
23
23
  pollMs?: number
24
+ // Leaves the live region and the retry warnings out, so only what the caller prints around the wait shows.
25
+ quiet?: boolean
24
26
  // The message the wait fails with once nothing covered the commit for `noneMs`.
25
27
  noneMessage: string
26
28
  // What covers the commit now, or undefined; a refusal such as a commit not on the branch is thrown as an Exit.
@@ -43,13 +45,17 @@ const POLL = 15_000
43
45
 
44
46
  // Waits until what covers a commit has finished, polling `covering`: a poll failing for a network reason is retried, one failing for good (a missing binary, a 401, 403 or 404) or a refusal ends the wait.
45
47
  export async function waitForCi<T>(wait: CiWait<T>): Promise<T> {
46
- const { tool, short, giveUpMs, noneMs, stalledMs, pollMs = POLL } = wait
48
+ const { tool, short, giveUpMs, noneMs, stalledMs, pollMs = POLL, quiet = false } = wait
47
49
  const started = Date.now()
48
50
  let seen = started
49
51
  let moving = started
50
52
  let followed = ''
51
53
  let found: T | undefined
52
- const region = wait.lines === undefined ? undefined : liveRegion(spinner => wait.lines?.(spinner, found, started) ?? [])
54
+ const region = wait.lines === undefined || quiet ? undefined : liveRegion(spinner => wait.lines?.(spinner, found, started) ?? [])
55
+ const retrying = (error: unknown) => {
56
+ if (!quiet)
57
+ console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`)
58
+ }
53
59
  const suffix = () => found === undefined ? '' : `: ${wait.links(found)}`
54
60
 
55
61
  // A failure to read the stall is retried on the next poll, and leaves the stall unknown.
@@ -61,7 +67,7 @@ export async function waitForCi<T>(wait: CiWait<T>): Promise<T> {
61
67
  if (error instanceof Exit || isPermanentFailure(error))
62
68
  throw error
63
69
 
64
- console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`)
70
+ retrying(error)
65
71
 
66
72
  return 'unknown'
67
73
  }
@@ -86,7 +92,7 @@ export async function waitForCi<T>(wait: CiWait<T>): Promise<T> {
86
92
  if (isPermanentFailure(error))
87
93
  fail(error instanceof Error ? error.message : String(error))
88
94
 
89
- console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`)
95
+ retrying(error)
90
96
 
91
97
  const latest = found === undefined ? undefined : await wait.settle(found)
92
98
 
package/src/cli.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { Candidate, Example, Options } from './args'
2
2
  import type { Colors } from './style'
3
+ import { readFileSync, realpathSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
3
5
  import process from 'node:process'
4
6
  import { applyOutput, Completing, completion, description, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, replacesRows, UsageError, usageLine, wantsHelpJson } from './args'
5
7
  import { paint, takesControlCodes } from './style'
6
8
  import { closest } from './text'
9
+ import { noteUsageError, recordUsage, usageEntry } from './usage'
7
10
 
8
11
  // A command's one-line summary, or a function reading it (a lazily loaded module) that only a listing of the command awaits, never a run.
9
12
  export type Summary = string | (() => Promise<string>)
@@ -27,7 +30,8 @@ export type Command = {
27
30
 
28
31
  export interface Cli {
29
32
  name: string
30
- version: string
33
+ // The package's own by default, read from the nearest package.json above the entry file.
34
+ version?: string
31
35
  // One line after `<name> <version>: ` in the help.
32
36
  about: string
33
37
  // The commands, or a function giving them all lazily.
@@ -42,6 +46,8 @@ export interface Cli {
42
46
  runBare?: boolean
43
47
  // Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
44
48
  redact?: (text: string) => string
49
+ // Set to false for a bin run on every tool call, which would fill the usage log (`guard hook`).
50
+ usage?: false
45
51
  // Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
46
52
  examples?: Example[]
47
53
  // What the CLI is reached for instead of, shown as `Instead of:` in `<cli> --help`.
@@ -214,6 +220,9 @@ function reportThrown(prefix: string, error: unknown, redact: (text: string) =>
214
220
  const named = error instanceof Error && !(error instanceof Exit) && error.name !== 'Error' ? `${error.name}: ${error.message}` : undefined
215
221
  const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined
216
222
 
223
+ if (error instanceof UsageError)
224
+ noteUsageError(error.message)
225
+
217
226
  const report = json ? printJsonError : (message: string, hint?: string, causeText?: string) => printError(prefix, message, hint, causeText)
218
227
 
219
228
  report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
@@ -422,19 +431,71 @@ async function printCandidates(found: Promise<string[]>): Promise<number> {
422
431
  return 0
423
432
  }
424
433
 
434
+ // The version of the nearest package.json above the entry file (symlinks resolved) that holds one, `0.0.0` when none does.
435
+ export function packageVersion(entry = Bun.main): string {
436
+ for (let dir = dirname(realpathSync(entry)); ; dir = dirname(dir)) {
437
+ try {
438
+ const { version } = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: unknown }
439
+
440
+ if (typeof version === 'string')
441
+ return version
442
+ }
443
+ catch {}
444
+
445
+ if (dirname(dir) === dir)
446
+ return '0.0.0'
447
+ }
448
+ }
449
+
425
450
  // Runs the command argv names and returns the exit code: 0 for help and version, 2 for an unknown command.
426
451
  export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<number> {
452
+ const version = cli.version ?? packageVersion()
453
+
427
454
  if (argv[0] === COMPLETE)
428
455
  return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
429
456
 
430
- return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, runBare: cli.runBare === true, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
457
+ const code = await dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, runBare: cli.runBare === true, examples: cli.examples, replaces: cli.replaces }, argv, { version, redact: cli.redact })
458
+
459
+ if (cli.usage !== false) {
460
+ try {
461
+ recordUsage(usageEntry(cli.name, commandPath(scopeOf(cli.commands, cli.extraCommands, cli.aliases), argv), argv, code))
462
+ }
463
+ catch {}
464
+ }
465
+
466
+ return code
467
+ }
468
+
469
+ // The command words the arguments start with, a group's subcommand included; lazy commands are not loaded for it.
470
+ function commandPath(scope: Pick<Level, 'commands' | 'aliases'>, argv: readonly string[]): string | undefined {
471
+ const path: string[] = []
472
+ let level = scope
473
+
474
+ for (const word of argv.filter(each => !each.startsWith('-'))) {
475
+ const name = level.aliases !== undefined && Object.hasOwn(level.aliases, word) ? (level.aliases[word] ?? word) : word
476
+ const command: Level['commands'][string] | undefined = Object.hasOwn(level.commands, name) ? level.commands[name] : undefined
477
+
478
+ if (command === undefined)
479
+ break
480
+
481
+ path.push(word)
482
+
483
+ if (!('commands' in command))
484
+ break
485
+
486
+ level = scopeOf(command.commands, command.extraCommands, command.aliases)
487
+ }
488
+
489
+ return path.length === 0 ? undefined : path.join(' ')
431
490
  }
432
491
 
433
492
  export interface Main {
434
493
  name: string
435
- version: string
494
+ // The package's own by default, read from the nearest package.json above the entry file.
495
+ version?: string
436
496
  run: (args: string[]) => Promise<number>
437
497
  redact?: (text: string) => string
498
+ usage?: false
438
499
  }
439
500
 
440
501
  // Parses a module's own arguments through runMain's handling, for a script running at its top level: the parsed values come back, and the process ends itself on --help, --version, `__complete` or an error, with the exit code runMain gives.
@@ -454,20 +515,31 @@ export async function mainArgs<T>(main: Omit<Main, 'run'>, parseArgv: (args: str
454
515
  }
455
516
 
456
517
  // 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.
457
- export async function runMain({ name, version, run, redact }: Main, argv = process.argv.slice(2)): Promise<number> {
518
+ export async function runMain({ name, version, run, redact, usage }: Main, argv = process.argv.slice(2)): Promise<number> {
458
519
  if (argv[0] === COMPLETE)
459
520
  return printCandidates(leafCandidates(run, argv.slice(1)))
460
521
 
461
522
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
462
- console.log(version)
523
+ console.log(version ?? packageVersion())
463
524
 
464
525
  return 0
465
526
  }
466
527
 
528
+ let code: number
529
+
467
530
  try {
468
- return await run(argv)
531
+ code = await run(argv)
469
532
  }
470
533
  catch (error) {
471
- return reportThrown(name, error, redact, wantsJson(argv))
534
+ code = reportThrown(name, error, redact, wantsJson(argv))
535
+ }
536
+
537
+ if (usage !== false) {
538
+ try {
539
+ recordUsage(usageEntry(name, undefined, argv, code))
540
+ }
541
+ catch {}
472
542
  }
543
+
544
+ return code
473
545
  }
package/src/compose.ts ADDED
@@ -0,0 +1,43 @@
1
+ export interface ComposeOptions {
2
+ // Compose's own options, given before the subcommand: `['-f', 'compose.yaml']`, `['-p', project]`, `['--project-directory', dir]`.
3
+ flags?: string[]
4
+ cwd?: string
5
+ // Variables set over the process's environment for compose.
6
+ env?: Record<string, string | undefined>
7
+ }
8
+
9
+ export interface CommandOutput {
10
+ code: number
11
+ stdout: string
12
+ stderr: string
13
+ }
14
+
15
+ // Runs a command to its end with no stdin and returns both streams apart, whatever its exit code; it throws when the command cannot start.
16
+ export async function captureOutput(argv: string[], { cwd, env }: Pick<ComposeOptions, 'cwd' | 'env'> = {}): Promise<CommandOutput> {
17
+ const run = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, env: { ...process.env, ...env }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
18
+ const [stdout, stderr, code] = await Promise.all([run.stdout.text(), run.stderr.text(), run.exited])
19
+
20
+ return { code, stdout, stderr }
21
+ }
22
+
23
+ // Runs `docker compose <flags> <args>` to its end: `captureOutput` with compose in front.
24
+ export async function compose(args: string[], { flags = [], ...options }: ComposeOptions = {}): Promise<CommandOutput> {
25
+ return captureOutput(['docker', 'compose', ...flags, ...args], options)
26
+ }
27
+
28
+ // Why a command failed, in its own words: the last line it wrote to stderr, else `exit <code>`.
29
+ export function composeError({ code, stderr }: CommandOutput): string {
30
+ const last = stderr.trim().split('\n').at(-1) ?? ''
31
+
32
+ return last === '' ? `exit ${code}` : last
33
+ }
34
+
35
+ // The objects of `docker compose ps --format json`, which prints one per line since Compose 2.21 and an array before.
36
+ export function jsonLines<T>(text: string): T[] {
37
+ const trimmed = text.trim()
38
+
39
+ if (trimmed === '')
40
+ return []
41
+
42
+ return trimmed.startsWith('[') ? JSON.parse(trimmed) as T[] : trimmed.split('\n').map(line => JSON.parse(line) as T)
43
+ }