@kaynooo/cli-kit 0.20.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`.
@@ -43,6 +67,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
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.
45
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.
46
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__`.
47
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.
48
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.
@@ -55,6 +80,9 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
55
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.
56
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.
57
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.
58
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.
59
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).
60
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.20.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,4 +1,5 @@
1
1
  import type { ParseArgsOptionDescriptor } from 'node:util'
2
+ import type { ExitRow } from './help'
2
3
  import process from 'node:process'
3
4
  import { parseArgs } from 'node:util'
4
5
  import { upTo } from './format'
@@ -19,6 +20,8 @@ interface OptionExtras {
19
20
  conflicts?: readonly string[]
20
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.
21
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[]
22
25
  }
23
26
 
24
27
  export type Options = Record<string, ParseArgsOptionDescriptor & OptionExtras>
@@ -99,12 +102,14 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
99
102
  examples?: Example[]
100
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'.
101
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[]
102
107
  }
103
108
 
104
109
  export type Example = string | [line: string, note: string]
105
110
 
106
111
  // The parts of the settings the help and the description show.
107
- export interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
112
+ export interface Described { examples?: Example[] | undefined, replaces?: string | undefined, exits?: ExitRow[] | undefined }
108
113
 
109
114
  // The output flags every command takes, before its own help.
110
115
  export const OUTPUT_OPTIONS = {
@@ -149,7 +154,7 @@ export function parse<const T extends Options, const Names extends readonly stri
149
154
  let parsed
150
155
 
151
156
  try {
152
- 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 })
153
158
  }
154
159
  catch (error) {
155
160
  const message = parseError(error)
@@ -162,7 +167,7 @@ export function parse<const T extends Options, const Names extends readonly stri
162
167
  applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
163
168
 
164
169
  if (values.help === true) {
165
- 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))
166
171
 
167
172
  throw new HelpShown()
168
173
  }
@@ -226,6 +231,24 @@ function listed(options: Options, allowNegative: boolean): Options {
226
231
  }))
227
232
  }
228
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
+
229
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.
230
253
  function withBare(args: string[], options: Options): string[] {
231
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]]))
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,5 +1,7 @@
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'
@@ -28,7 +30,8 @@ export type Command = {
28
30
 
29
31
  export interface Cli {
30
32
  name: string
31
- version: string
33
+ // The package's own by default, read from the nearest package.json above the entry file.
34
+ version?: string
32
35
  // One line after `<name> <version>: ` in the help.
33
36
  about: string
34
37
  // The commands, or a function giving them all lazily.
@@ -428,12 +431,30 @@ async function printCandidates(found: Promise<string[]>): Promise<number> {
428
431
  return 0
429
432
  }
430
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
+
431
450
  // Runs the command argv names and returns the exit code: 0 for help and version, 2 for an unknown command.
432
451
  export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<number> {
452
+ const version = cli.version ?? packageVersion()
453
+
433
454
  if (argv[0] === COMPLETE)
434
455
  return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
435
456
 
436
- const code = await 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 })
437
458
 
438
459
  if (cli.usage !== false) {
439
460
  try {
@@ -470,7 +491,8 @@ function commandPath(scope: Pick<Level, 'commands' | 'aliases'>, argv: readonly
470
491
 
471
492
  export interface Main {
472
493
  name: string
473
- version: string
494
+ // The package's own by default, read from the nearest package.json above the entry file.
495
+ version?: string
474
496
  run: (args: string[]) => Promise<number>
475
497
  redact?: (text: string) => string
476
498
  usage?: false
@@ -498,7 +520,7 @@ export async function runMain({ name, version, run, redact, usage }: Main, argv
498
520
  return printCandidates(leafCandidates(run, argv.slice(1)))
499
521
 
500
522
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) {
501
- console.log(version)
523
+ console.log(version ?? packageVersion())
502
524
 
503
525
  return 0
504
526
  }
@@ -512,8 +534,12 @@ export async function runMain({ name, version, run, redact, usage }: Main, argv
512
534
  code = reportThrown(name, error, redact, wantsJson(argv))
513
535
  }
514
536
 
515
- if (usage !== false)
516
- recordUsage(usageEntry(name, undefined, argv, code))
537
+ if (usage !== false) {
538
+ try {
539
+ recordUsage(usageEntry(name, undefined, argv, code))
540
+ }
541
+ catch {}
542
+ }
517
543
 
518
544
  return code
519
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
+ }
package/src/fzf.ts ADDED
@@ -0,0 +1,186 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import process from 'node:process'
5
+
6
+ export interface Row {
7
+ hidden: string[]
8
+ cells: string[]
9
+ }
10
+
11
+ export interface FzfOptions {
12
+ prompt: string
13
+ header?: string
14
+ // Keys that pick the highlighted row too, telling which one was pressed.
15
+ expect?: string[]
16
+ // Called with the hidden fields as `{1}`, `{2}`...
17
+ preview?: string
18
+ // How many leading cells are searched, so a word of a description does not outrank the name it was typed for.
19
+ searched: number
20
+ // Whole height by default, for a list of a few candidates asked about under the command.
21
+ height?: string
22
+ bind?: string[]
23
+ // How many leading fields are hidden, taken from the first row by default, which a list that starts empty and reloads has none of.
24
+ hidden?: number
25
+ // The query comes back too, and an expected key pressed with no row matching still picks, with no hidden fields.
26
+ query?: boolean
27
+ }
28
+
29
+ export interface Picked {
30
+ key: string
31
+ hidden: string[]
32
+ query: string
33
+ }
34
+
35
+ // A row as term lists it with `--list` in place of the fzf window, in the pane's folder: what tmux_pick types back for Enter and for the picker's other keys.
36
+ export interface Listed {
37
+ label: string
38
+ hint: string
39
+ // The words typed after the picker's name to find the same choice again; for a row asking for a text, the ones before it.
40
+ typed: string
41
+ // Set for a row asking for a text first, typed after its words: ctrl-n's new branch.
42
+ asks?: string
43
+ // The picker's arguments printing the row's preview, its `--show` as fzf calls it.
44
+ preview?: string[]
45
+ // ctrl-e leaves the picker's name and these words on the prompt rather than the command they run.
46
+ editsTyped?: boolean
47
+ actions?: ListedAction[]
48
+ }
49
+
50
+ export interface ListedAction {
51
+ label: string
52
+ // fzf's name for the key doing it there.
53
+ key: string
54
+ typed?: string
55
+ // The picker run with these arguments, then listed again: ctrl-r's fetch.
56
+ reload?: string[]
57
+ }
58
+
59
+ export interface Run {
60
+ id: string
61
+ args: string[]
62
+ }
63
+
64
+ // A word of the command: fixed, an argument given by the user, or the name of one to prompt for; the last two are what a run is remembered with.
65
+ export type Arg = string | { given: string } | { ask: string }
66
+
67
+ const REMEMBERED = 20
68
+
69
+ // Every cell but the last padded to its column, one space short of the two between columns since a tab renders as one, see `--tabstop`.
70
+ // Widths are measured without the color codes.
71
+ export function table(rows: Row[]): string[] {
72
+ const widths = rows[0]?.cells.map((_, index) => Math.max(...rows.map(row => Bun.stringWidth(row.cells[index]!)))) ?? []
73
+
74
+ return rows.map(row => [
75
+ ...row.hidden,
76
+ ...row.cells.map((cell, index) => (index === row.cells.length - 1 ? cell.trimEnd() : `${cell}${' '.repeat(widths[index]! + 1 - Bun.stringWidth(cell))}`)),
77
+ ].join('\t'))
78
+ }
79
+
80
+ export function fzf(rows: Row[], options: FzfOptions): Picked | undefined {
81
+ const hidden = options.hidden ?? rows[0]?.hidden.length ?? 0
82
+ const proc = Bun.spawnSync([
83
+ 'fzf',
84
+ '--prompt',
85
+ options.prompt,
86
+ ...options.header === undefined ? [] : ['--header', options.header],
87
+ ...(options.expect ?? []).length > 0 ? ['--expect', (options.expect ?? []).join(',')] : [],
88
+ '--ansi',
89
+ '--reverse',
90
+ '--height',
91
+ options.height ?? '100%',
92
+ '--delimiter',
93
+ '\t',
94
+ '--with-nth',
95
+ `${hidden + 1}..`,
96
+ '--nth',
97
+ `1..${options.searched}`,
98
+ '--tabstop',
99
+ '1',
100
+ // The shortest word holding the match wins a tie, `install` before `install-hooks`, where the default length would count the description too.
101
+ '--tiebreak',
102
+ 'chunk,index',
103
+ ...options.preview === undefined ? [] : ['--preview', options.preview, '--preview-window', 'down,40%'],
104
+ ...(options.bind ?? []).flatMap(bind => ['--bind', bind]),
105
+ ...options.query === true ? ['--print-query'] : [],
106
+ ], {
107
+ stdin: new TextEncoder().encode(table(rows).join('\n')),
108
+ stdout: 'pipe',
109
+ stderr: 'inherit',
110
+ })
111
+
112
+ return picked(proc.stdout.toString(), hidden, options)
113
+ }
114
+
115
+ // What fzf printed: the query when asked for, the key when keys are expected, then the line picked.
116
+ export function picked(output: string, hidden: number, options: Pick<FzfOptions, 'expect' | 'query'>): Picked | undefined {
117
+ const lines = output.split('\n')
118
+ const query = options.query === true ? lines.shift()! : ''
119
+ const [key, line] = (options.expect ?? []).length > 0 ? [lines[0]!, lines[1]] : ['', lines[0]]
120
+
121
+ if (line !== undefined && line !== '')
122
+ return { key, hidden: line.split('\t').slice(0, hidden), query }
123
+
124
+ return options.query === true && key !== '' ? { key, hidden: [], query } : undefined
125
+ }
126
+
127
+ // A few candidates, as tall as they need under the command with the preview below them.
128
+ export function heightFor(count: number): string {
129
+ return String(Math.max(15, Math.ceil((count + 4) / 0.6)))
130
+ }
131
+
132
+ function historyFile(name: string): string {
133
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), name, 'history.json')
134
+ }
135
+
136
+ // Keyed by folder, a broken or missing file reading as nothing run yet.
137
+ function history(name: string): Record<string, Run[]> {
138
+ try {
139
+ const file = historyFile(name)
140
+
141
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf-8')) as Record<string, Run[]> : {}
142
+ }
143
+ catch {
144
+ return {}
145
+ }
146
+ }
147
+
148
+ export function runs(name: string): Run[] {
149
+ return history(name)[process.cwd()] ?? []
150
+ }
151
+
152
+ export function record(name: string, run: Run): void {
153
+ const all = history(name)
154
+ const file = historyFile(name)
155
+
156
+ all[process.cwd()] = [run, ...(all[process.cwd()] ?? []).filter(earlier => earlier.id !== run.id)].slice(0, REMEMBERED)
157
+ mkdirSync(dirname(file), { recursive: true })
158
+ writeFileSync(file, JSON.stringify(all))
159
+ }
160
+
161
+ // The ones run last come first, most recent at the top, the rest keeping their order.
162
+ export function byRecency<T>(items: T[], previous: Run[], idOf: (item: T) => string): T[] {
163
+ const rank = new Map(previous.map((run, index) => [run.id, index]))
164
+ const recent = items.filter(item => rank.has(idOf(item))).sort((a, b) => rank.get(idOf(a))! - rank.get(idOf(b))!)
165
+
166
+ return [...recent, ...items.filter(item => !rank.has(idOf(item)))]
167
+ }
168
+
169
+ function word(arg: Arg): string {
170
+ if (typeof arg === 'string')
171
+ return `=${arg}`
172
+
173
+ return 'given' in arg ? `+${arg.given}` : `?${arg.ask}`
174
+ }
175
+
176
+ // The lines `__pick_run` and tmux_pick read: `run`, or `edit` to leave the command on the prompt; the words typed after the picker's name to find the same choice again; the id the run is called back with once the prompts are answered, or nothing; then one line per word of the command, `=fixed`, `+given` or `?name` to prompt for.
177
+ export function emit(mode: 'run' | 'edit', typed: string, recorded: string, command: Arg[]): void {
178
+ const words = command.map(word)
179
+
180
+ console.log([mode, typed, recorded, ...words].join('\n'))
181
+ }
182
+
183
+ // What `--list` prints, term reading it back.
184
+ export function list(rows: Listed[]): void {
185
+ console.log(JSON.stringify(rows))
186
+ }
package/src/help.ts CHANGED
@@ -12,8 +12,21 @@ export function usageLine(usage: string, colors = paint(process.stdout)): string
12
12
  return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
13
13
  }
14
14
 
15
- export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces }: Described = {}): string {
16
- return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
15
+ export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces, exits = [] }: Described = {}): string {
16
+ return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exitRows(exits, colors), ...exampleRows(examples, colors)].join('\n')
17
+ }
18
+
19
+ // An exit code and what it means.
20
+ export type ExitRow = [code: number | string, meaning: string]
21
+
22
+ // An `Exits:` block, after a blank line, a line per code with the codes aligned; nothing without one.
23
+ export function exitRows(exits: ExitRow[], colors = paint(process.stdout)): string[] {
24
+ if (exits.length === 0)
25
+ return []
26
+
27
+ const width = Math.max(...exits.map(([code]) => String(code).length))
28
+
29
+ return ['', colors.bold('Exits:'), ...exits.map(([code, meaning]) => ` ${colors.cyan(String(code).padEnd(width))} ${meaning}`)]
17
30
  }
18
31
 
19
32
  // An `Instead of:` line, after a blank line; nothing without one.
@@ -27,6 +40,9 @@ export interface Description {
27
40
  summary: string
28
41
  replaces?: string
29
42
  examples: { line: string, note?: string }[]
43
+ exits?: { code: number | string, meaning: string }[]
44
+ // The flags of a command with no subcommands, for a test comparing them with what a README documents.
45
+ options?: { name: string, short?: string, description?: string }[]
30
46
  }
31
47
 
32
48
  export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
@@ -34,7 +50,7 @@ export function wantsHelpJson(env: Record<string, string | undefined> = process.
34
50
  }
35
51
 
36
52
  // 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.
37
- export function description(usage: string, { examples = [], replaces }: Described = {}): Description {
53
+ export function description(usage: string, { examples = [], replaces, exits }: Described = {}, options?: Options): Description {
38
54
  const lines = usage.replace(USAGE_PREFIX, '').split('\n')
39
55
  const first = lines[0]!
40
56
  const colon = first.indexOf(': ')
@@ -46,6 +62,8 @@ export function description(usage: string, { examples = [], replaces }: Describe
46
62
  summary: colon === -1 ? fallback : first.slice(colon + 2),
47
63
  ...replaces === undefined ? {} : { replaces },
48
64
  examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
65
+ ...exits === undefined ? {} : { exits: exits.map(([code, meaning]) => ({ code, meaning })) },
66
+ ...options === undefined ? {} : { options: Object.entries(options).map(([name, { short, description }]) => ({ name, ...short === undefined ? {} : { short }, ...description === undefined ? {} : { description } })) },
49
67
  }
50
68
  }
51
69
 
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
+ export * from './advisory'
1
2
  export * from './args'
2
3
  export * from './chart'
3
4
  export * from './ci'
4
5
  export * from './cli'
6
+ export * from './compose'
5
7
  export * from './config'
6
8
  export * from './format'
7
9
  export * from './git'
@@ -10,7 +12,9 @@ export * from './logs'
10
12
  export * from './process'
11
13
  export * from './secrets'
12
14
  export * from './sessions'
15
+ export * from './size'
13
16
  export * from './style'
14
17
  export * from './text'
15
18
  export * from './tool'
16
19
  export * from './usage'
20
+ export * from './user-config'
package/src/shell.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // `text` is the piece as written, `command` the same less its heredoc bodies and comments, which are data and never commands; `separator` is what ends it (`|`, `&&`, `;`, a newline...), empty for the last; `expanded` holds the bodies of its unquoted heredocs, where the shell still runs `$(...)`, and `heredocs` those of all its heredocs.
2
2
  export interface Piece { text: string, start: number, end: number, command: string, separator: string, expanded: string, heredocs: string[] }
3
- export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean }
3
+ export interface Invocation { program: string, args: string[], assignments: string[], isWrapped: boolean, strings: string[] }
4
4
 
5
5
  const SEPARATORS = new Set(['\n', ';', '|'])
6
6
  const PLAIN_BREAKERS = new Set(['<', '>', '(', ')', '&', '|'])
@@ -30,18 +30,30 @@ const OCTAL = /^[0-7]{1,3}/
30
30
  const KEYWORDS = new Set(['then', 'do', 'else', 'if', 'elif', 'while', 'until', '!', '{', '}'])
31
31
  const MAX_DEPTH = 8
32
32
  const MAX_BRACES = 256
33
+
33
34
  // Programs reading a command string with `-c`.
34
- const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh'])
35
- const COMMAND_STRING_PROGRAMS = new Set([...SHELLS, 'su', 'runuser'])
35
+ export const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'fish', 'csh', 'tcsh', 'nu', 'xonsh', 'elvish', 'pwsh', 'powershell'])
36
+ // Programs that take their command string past other operands, so every word is scanned for the option.
37
+ const SCANNED_PROGRAMS = new Set(['su', 'runuser', 'flock'])
38
+ const COMMAND_STRING_PROGRAMS = new Set([...SHELLS, ...SCANNED_PROGRAMS])
39
+ // PowerShell's `-Command`, which it accepts abbreviated to any prefix.
40
+ const POWERSHELL_COMMAND = /^-c(?:o(?:m(?:m(?:a(?:nd?)?)?)?)?)?$/i
41
+ const POWERSHELL = new Set(['pwsh', 'powershell'])
42
+ const FUNCTION_DEFINITION = /^[\w.:-]+\(\)\{?$/
43
+ const FUNCTION_NAME = /^[\w.:-]+$/
36
44
  const SHELL_VALUE_OPTIONS = new Set(['-o', '-O', '--rcfile', '--init-file'])
45
+ const FISH_VALUE_OPTIONS = new Set(['-C', '--init-command'])
46
+ // A `let` builtin, whose `<<` is a shift.
47
+ const LET_PIECE = /^\s*let\s/
37
48
  const PRINTERS = new Set(['echo', 'printf'])
38
49
  const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
39
50
 
40
51
  // Programs that run the command following them, which is the one that counts, with the options taking a value and the operands before that command.
41
- interface WrapperSpec { short: string, long: readonly string[], operands: number }
52
+ interface WrapperSpec { short: string, long: readonly string[], operands: number, runsString: boolean }
42
53
 
43
- function wrapper(short = '', long: readonly string[] = [], operands = 0): WrapperSpec {
44
- return { short, long, operands }
54
+ // `runsString` for a wrapper that hands its command to a shell, so a quoted `'git stash'` is a command line.
55
+ function wrapper(short = '', long: readonly string[] = [], operands = 0, runsString = false): WrapperSpec {
56
+ return { short, long, operands, runsString }
45
57
  }
46
58
 
47
59
  const WRAPPERS = new Map<string, WrapperSpec>([
@@ -60,6 +72,11 @@ const WRAPPERS = new Map<string, WrapperSpec>([
60
72
  ['ionice', wrapper('cnpPu', ['--class', '--classdata', '--pid', '--pgid', '--uid'])],
61
73
  ['stdbuf', wrapper('ioe', ['--input', '--output', '--error'])],
62
74
  ['chroot', wrapper('', ['--userspec', '--groups'], 1)],
75
+ ['watch', wrapper('n', ['--interval'], 0, true)],
76
+ ['flock', wrapper('wE', ['--timeout', '--conflict-exit-code'], 1)],
77
+ ['parallel', wrapper('jnNSaIPlLdTsC', ['--jobs', '--max-args', '--max-replace-args', '--sshlogin', '--arg-file', '--delimiter', '--colsep'], 0, true)],
78
+ ['unbuffer', wrapper()],
79
+ ['systemd-run', wrapper('puEMH', ['--property', '--unit', '--description', '--slice', '--setenv', '--machine', '--host', '--uid', '--gid', '--working-directory', '--nice', '--service-type'])],
63
80
  ['xargs', wrapper('IndsEaPL', ['--max-args', '--max-procs', '--max-chars', '--delimiter', '--arg-file', '--eof', '--max-lines', '--process-slot-var'])],
64
81
  ])
65
82
 
@@ -162,6 +179,15 @@ export function splitCommand(command: string): Piece[] {
162
179
  continue
163
180
  }
164
181
 
182
+ // The legacy arithmetic `$[...]` is read whole, like `$((...))`.
183
+ if (char === '$' && command[i + 1] === '[') {
184
+ const close = command.indexOf(']', i)
185
+
186
+ i = close === -1 ? command.length : close
187
+
188
+ continue
189
+ }
190
+
165
191
  // A backquoted body is read whole, as its own separators split nothing of the command around it.
166
192
  if (char === '`') {
167
193
  i = closingBackquote(command, i)
@@ -169,6 +195,13 @@ export function splitCommand(command: string): Piece[] {
169
195
  continue
170
196
  }
171
197
 
198
+ // Arithmetic is read whole: its `<<` shifts, it opens no heredoc.
199
+ if (command.startsWith('((', char === '$' ? i + 1 : i) && (char === '$' || startsComment(command, i))) {
200
+ i = closingParen(command, char === '$' ? i + 1 : i)
201
+
202
+ continue
203
+ }
204
+
172
205
  if (char === '#' && startsComment(command, i)) {
173
206
  const newline = command.indexOf('\n', i)
174
207
  const end = newline === -1 ? command.length : newline
@@ -179,7 +212,7 @@ export function splitCommand(command: string): Piece[] {
179
212
  continue
180
213
  }
181
214
 
182
- const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' ? HEREDOC.exec(command.slice(i)) : null
215
+ const heredoc = char === '<' && command[i + 1] === '<' && command[i + 2] !== '<' && !LET_PIECE.test(command.slice(start, i)) ? HEREDOC.exec(command.slice(i)) : null
183
216
 
184
217
  if (heredoc) {
185
218
  heredocs.push({ word: heredoc[2] ?? heredoc[3] ?? heredoc[4] ?? '', stripsTabs: heredoc[1] === '-', isQuoted: heredoc[4] === undefined })
@@ -545,13 +578,29 @@ function skipOptions(tokens: string[], spec: WrapperSpec): void {
545
578
  export function parseCommand(text: string): Invocation {
546
579
  const tokens = tokenize(text)
547
580
  const assignments: string[] = []
581
+ const strings: string[] = []
548
582
  let isWrapped = false
549
583
 
550
584
  for (;;) {
551
585
  const first = tokens[0] ?? ''
552
586
  const spec = WRAPPERS.get(first.split('/').pop() ?? '')
553
587
 
554
- if (OPENERS.test(first)) {
588
+ if (first === 'function' && tokens.length > 1) {
589
+ isWrapped = true
590
+ tokens.splice(0, 2)
591
+
592
+ if (tokens[0] === '()')
593
+ tokens.shift()
594
+ }
595
+ else if (FUNCTION_DEFINITION.test(first)) {
596
+ isWrapped = true
597
+ tokens.shift()
598
+ }
599
+ else if (tokens[1] === '()' && FUNCTION_NAME.test(first)) {
600
+ isWrapped = true
601
+ tokens.splice(0, 2)
602
+ }
603
+ else if (OPENERS.test(first)) {
555
604
  isWrapped = true
556
605
  const rest = first.replace(OPENERS, '')
557
606
 
@@ -580,6 +629,9 @@ export function parseCommand(text: string): Invocation {
580
629
  isWrapped = true
581
630
  tokens.shift()
582
631
  skipOptions(tokens, spec)
632
+
633
+ if (spec.runsString && WHITESPACE.test(tokens[0] ?? ''))
634
+ strings.push(tokens[0] ?? '')
583
635
  }
584
636
  else {
585
637
  break
@@ -596,7 +648,7 @@ export function parseCommand(text: string): Invocation {
596
648
 
597
649
  const [program = '', ...args] = tokens
598
650
 
599
- return { program: program.split('/').pop() ?? '', args, assignments, isWrapped }
651
+ return { program: program.split('/').pop() ?? '', args, assignments, isWrapped, strings }
600
652
  }
601
653
 
602
654
  // 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.
@@ -618,7 +670,8 @@ function commandStrings(tokens: readonly string[]): string[] {
618
670
  if (!COMMAND_STRING_PROGRAMS.has(name))
619
671
  continue
620
672
 
621
- const isUser = name === 'su' || name === 'runuser'
673
+ const isScanned = SCANNED_PROGRAMS.has(name)
674
+ const isPowershell = POWERSHELL.has(name)
622
675
  let isValue = false
623
676
 
624
677
  for (let j = i + 1; j < tokens.length; j++) {
@@ -630,25 +683,27 @@ function commandStrings(tokens: readonly string[]): string[] {
630
683
  continue
631
684
  }
632
685
 
633
- if (SHELL_VALUE_OPTIONS.has(option)) {
686
+ if (SHELL_VALUE_OPTIONS.has(option) || (name === 'fish' && FISH_VALUE_OPTIONS.has(option))) {
634
687
  isValue = true
635
688
 
636
689
  continue
637
690
  }
638
691
 
639
- if (option.startsWith('--command=')) {
640
- found.push(option.slice('--command='.length))
692
+ if (option.startsWith('--command=') || option.startsWith('--commands=')) {
693
+ found.push(option.slice(option.indexOf('=') + 1))
641
694
 
642
695
  break
643
696
  }
644
697
 
645
- if (option === '--command' || (!option.startsWith('--') && option.startsWith('-') && option.slice(1).includes('c'))) {
646
- found.push(tokens[j + 1] ?? '')
698
+ const isCommandOption = isPowershell ? POWERSHELL_COMMAND.test(option) : !option.startsWith('--') && option.startsWith('-') && option.slice(1).includes('c')
699
+
700
+ if (option === '--command' || option === '--commands' || isCommandOption) {
701
+ found.push(isPowershell ? tokens.slice(j + 1).join(' ') : (tokens[j + 1] ?? ''))
647
702
 
648
703
  break
649
704
  }
650
705
 
651
- if (!option.startsWith('-') && !isUser)
706
+ if (!option.startsWith('-') && !isScanned)
652
707
  break
653
708
  }
654
709
  }
@@ -695,8 +750,8 @@ function findExecs(args: readonly string[]): string[] {
695
750
  // The commands a piece makes a shell run besides its own: `sh -c` strings, `eval`, `find -exec`, and what is fed to a shell reading its input, heredocs, here-strings and an `echo` piped in.
696
751
  function nestedCommands(piece: Piece, previous: Piece | undefined): string[] {
697
752
  const tokens = tokenize(piece.command)
698
- const { program, args } = parseCommand(piece.command)
699
- const found = commandStrings(tokens)
753
+ const { program, args, strings } = parseCommand(piece.command)
754
+ const found = [...commandStrings(tokens), ...strings]
700
755
 
701
756
  if (program === 'eval')
702
757
  found.push(args.join(' '))
package/src/size.ts ADDED
@@ -0,0 +1,14 @@
1
+ const SIZE = /^(\d+(?:\.\d+)?)\s*([kmgt]?)i?b?$/i
2
+ const POWERS: Record<string, number> = { '': 0, 'k': 1, 'm': 2, 'g': 3, 't': 4 }
3
+
4
+ // A size such as `500M`, `1.5G` or `800kb` in bytes, counting in powers of 1024; a bare number counts `bareUnit` bytes (1 for bytes, 1024 ** 2 for megabytes). Undefined when it is none.
5
+ export function parseSize(text: string, bareUnit = 1): number | undefined {
6
+ const match = SIZE.exec(text.trim())
7
+
8
+ if (match === null)
9
+ return undefined
10
+
11
+ const suffix = match[2]!.toLowerCase()
12
+
13
+ return Math.round(Number(match[1]) * (suffix === '' ? bareUnit : 1024 ** POWERS[suffix]!))
14
+ }
package/src/tool.ts CHANGED
@@ -61,11 +61,14 @@ export async function runTool(label: string, argv: string[], { timeoutMs, cwd, o
61
61
  if (ran.timedOut)
62
62
  throw new Error(`${label}: no answer in ${formatDuration(timeoutMs ?? 0)}`)
63
63
 
64
- const decoder = new TextDecoder()
64
+ const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }
65
65
  const streams = { stdout: '', stderr: '' }
66
66
 
67
67
  for (const { stream, data } of ran.chunks)
68
- streams[stream] += decoder.decode(data, { stream: true })
68
+ streams[stream] += decoders[stream].decode(data, { stream: true })
69
+
70
+ streams.stdout += decoders.stdout.decode()
71
+ streams.stderr += decoders.stderr.decode()
69
72
 
70
73
  return { code: ran.code, ...streams }
71
74
  }
@@ -0,0 +1,34 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import process from 'node:process'
5
+
6
+ export interface UserConfigLocation {
7
+ // The home folder; `$XDG_CONFIG_HOME` is then ignored unless `xdg` is given too.
8
+ home?: string | undefined
9
+ // The config base folder, `$XDG_CONFIG_HOME` or `<home>/.config` by default.
10
+ xdg?: string | undefined
11
+ }
12
+
13
+ export function configBase(where: UserConfigLocation = {}): string {
14
+ const xdg = where.xdg ?? (where.home === undefined ? process.env.XDG_CONFIG_HOME : undefined)
15
+
16
+ return xdg !== undefined && xdg !== '' ? xdg : join(where.home ?? homedir(), '.config')
17
+ }
18
+
19
+ // The one config file of a bin, `~/.config/kaynooo-cli/<bin>.json`: where it is written.
20
+ export function userConfigPath(bin: string, where: UserConfigLocation = {}): string {
21
+ return join(configBase(where), 'kaynooo-cli', `${bin}.json`)
22
+ }
23
+
24
+ // Where to read a bin's config: the new file, else the first of `legacy` (paths under the config base, such as `['ft', 'config.json']`) that exists, else the new path. A legacy file is never moved or deleted.
25
+ export function userConfigReadPath(bin: string, legacy: string[][] = [], where: UserConfigLocation = {}): string {
26
+ const current = userConfigPath(bin, where)
27
+
28
+ if (existsSync(current))
29
+ return current
30
+
31
+ const base = configBase(where)
32
+
33
+ return legacy.map(parts => join(base, ...parts)).find(path => existsSync(path)) ?? current
34
+ }