@kaynooo/cli-kit 0.9.0 → 0.11.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
@@ -33,15 +33,20 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
33
33
  ## API
34
34
 
35
35
  - `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
+ - `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
+ - `git(args, timeoutMs?)`, `gitOk`, `resolveCommit`, `fetchBranch`, `defaultBranch` and `leaveWorktree` are the git helpers the forge CLIs share, over `runTool`.
38
+ - `waitForCi({ tool, short, giveUpMs, noneMs, stalledMs, covering, finished, key, announce, links, settle, stall, lines })` is the wait loop of `ft ci` and `gl ci`: it polls `covering` until what it returns is `finished`, retries a poll that failed for a network reason, fails at once on a refusal (`Exit`) or a permanent failure, and gives up on a timeout, on nothing covering the commit for `noneMs`, or on a `stall` that is certain or lasts `stalledMs`.
36
39
  - `parse(args, usage, options)` wraps `node:util` `parseArgs` with a generated `--help` (`usage` given with or without its `usage:`), which prints the help and ends the command with exit 0 (it throws `HelpShown`, which `runCli` and `runMain` take), so the command needs no check after it. A string option takes a next argument starting with a dash as its value (`--from "- item"`, `-n -3`) unless that argument names one of the command's options, which parseArgs alone refuses as ambiguous. `{ positionals: ['<key>', '[file]'] }` names the positionals the command takes: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more; one missing (`missing <key>`) or one too many (`unexpected argument x`) is a usage error, and `positionals` comes back typed as `[string, string?]`. `fail(message, code = 2, hint?)` ends a command, the hint printed under the error; `badUsage(message)` ends it with a `UsageError`, which `parse`, `wholeNumber` and `duration` throw too and which gets the hint `run <cli> <command> --help for its options`; `wholeNumber` reads a numeric option.
37
40
  - `table`, `truncate`, `formatSeconds`, `formatAge` and `upTo` format output.
38
41
  - `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`.
39
42
  - `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"`), its options listed in `<cli> --help` under the commands, and a command may be a group, `{ summary, commands, aliases? }`, dispatched with its own help and typo hints (`ft plan add`).
43
+ - 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.
40
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.
41
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.
42
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.
43
- - `runCommand(argv, { cwd, env, stdin })` runs a command and returns `{ code, text, output, chunks }`, the output of both streams (in arrival order, tagged by stream in `chunks`); it throws when the command cannot start. `exitCodeOf(code, signal)` is the exit code a shell reports.
47
+ - `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.
44
48
  - `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
+ - `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.
45
50
  - `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.
46
51
  - 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.
47
52
  - `--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.
@@ -52,6 +57,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
52
57
  - `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
53
58
  - `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`).
54
59
  - `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
+ - `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.
55
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.
56
62
  - `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.
57
63
  - `@kaynooo/cli-kit/prompt` asks on the terminal, on stderr:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-kit",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.11.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
@@ -3,8 +3,9 @@ import process from 'node:process'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { upTo } from './format'
5
5
  import { paint } from './style'
6
+ import { closest } from './text'
6
7
 
7
- export type Options = Record<string, ParseArgsOptionDescriptor & { description: string }>
8
+ export type Options = Record<string, ParseArgsOptionDescriptor & { description?: string }>
8
9
 
9
10
  // The `--json` flag every CLI words alike: `print JSON`, then what it holds when given.
10
11
  export function jsonOption(holds?: string) {
@@ -24,8 +25,8 @@ export function fail(message: string, code = 2, hint?: string): never {
24
25
 
25
26
  // An Exit for arguments the command cannot take, which runCli points at the command's --help.
26
27
  export class UsageError extends Exit {
27
- constructor(message: string) {
28
- super(message, 2)
28
+ constructor(message: string, hint?: string) {
29
+ super(message, 2, hint)
29
30
  }
30
31
  }
31
32
 
@@ -51,6 +52,7 @@ export const completion = { active: false }
51
52
  const OPTIONAL = /\[[^\]]+\]/g
52
53
  const USAGE_PREFIX = /^usage:\s*/i
53
54
  const FULL_STOP = /\.$/
55
+ const DASHES = /^-+/
54
56
 
55
57
  // `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
56
58
  export function usageLine(usage: string, colors = paint(process.stdout)): string {
@@ -108,11 +110,11 @@ export function optionRows(options: Options, colors = paint(process.stdout)): st
108
110
  const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
109
111
  const value = option.type === 'string' ? ` ${colors.dim('<value>')}` : ''
110
112
 
111
- return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? ' <value>'.length : 0), description: option.description }
113
+ return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? ' <value>'.length : 0), description: option.description ?? '' }
112
114
  })
113
115
  const width = Math.max(0, ...rows.map(row => row.width))
114
116
 
115
- return rows.map(row => ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
117
+ return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
116
118
  }
117
119
 
118
120
  // The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
@@ -184,7 +186,9 @@ export function parse<const T extends Options, const Names extends readonly stri
184
186
  parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
185
187
  }
186
188
  catch (error) {
187
- badUsage(parseError(error))
189
+ const message = parseError(error)
190
+
191
+ throw new UsageError(message, nearestOption(message, all, settings.allowNegative === true))
188
192
  }
189
193
 
190
194
  const values = parsed.values as { help?: boolean, color?: unknown, plain?: unknown }
@@ -248,14 +252,41 @@ function parseError(error: unknown): string {
248
252
  return `${message.charAt(0).toLowerCase()}${message.slice(1)}`
249
253
  }
250
254
 
255
+ const UNKNOWN_OPTION = /^unknown option '(-[^']+)'/
256
+
257
+ // `did you mean --no-check?` for an unknown option within reach of a known one (shorts and `--no-<flag>` included), nothing when none is, so runCli keeps its `--help` hint.
258
+ function nearestOption(message: string, options: Options, allowNegative: boolean): string | undefined {
259
+ const typed = UNKNOWN_OPTION.exec(message)?.[1]
260
+
261
+ if (typed === undefined)
262
+ return undefined
263
+
264
+ const word = typed.replace(DASHES, '')
265
+
266
+ if (word.length < 2 && !typed.startsWith('--'))
267
+ return undefined
268
+
269
+ const known = Object.entries(options).flatMap(([name, option]) => [
270
+ ...option.short === undefined ? [] : [`-${option.short}`],
271
+ `--${name}`,
272
+ ...allowNegative && option.type === 'boolean' ? [`--no-${name}`] : [],
273
+ ])
274
+ const meant = closest(word, known.map(flag => flag.replace(DASHES, '')))
275
+
276
+ return meant === undefined ? undefined : `did you mean ${known.find(flag => flag.replace(DASHES, '') === meant)}?`
277
+ }
278
+
279
+ const WHOLE = /^\d+$/
280
+
281
+ // A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
251
282
  export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
252
283
  if (value === undefined)
253
284
  return fallback
254
285
 
255
286
  const number = Number(value)
256
287
 
257
- if (!Number.isInteger(number) || number < min)
258
- badUsage(`--${name} takes a whole number from ${min}, not ${value}`)
288
+ if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
289
+ badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
259
290
 
260
291
  return number
261
292
  }
package/src/ci.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { Exit, fail } from './args'
2
+ import { formatDuration } from './format'
3
+ import { liveRegion } from './progress'
4
+ import { cwdGone, isPermanentFailure } from './tool'
5
+
6
+ export interface CiStall {
7
+ // The message the wait fails with.
8
+ message: string
9
+ // Fails at once instead of after `stalledMs`.
10
+ certain?: boolean
11
+ }
12
+
13
+ export interface CiWait<T> {
14
+ // The command's name, as its warnings print it (`ft ci`).
15
+ tool: string
16
+ // The abbreviated sha the wait is for.
17
+ short: string
18
+ giveUpMs: number
19
+ // How long the wait goes on with nothing covering the commit.
20
+ noneMs: number
21
+ // How long queued work may sit while nothing moves.
22
+ stalledMs: number
23
+ pollMs?: number
24
+ // The message the wait fails with once nothing covered the commit for `noneMs`.
25
+ noneMessage: string
26
+ // What covers the commit now, or undefined; a refusal such as a commit not on the branch is thrown as an Exit.
27
+ covering: () => Promise<T | undefined>
28
+ finished: (found: T) => boolean
29
+ // Identifies what is followed, so `announce` runs again when it changes.
30
+ key: (found: T) => string
31
+ announce: (found: T) => void
32
+ // The links to what is followed, for the messages of a wait that ends unfinished.
33
+ links: (found: T) => string
34
+ // What a covering that failed leaves of the followed value once it can be read as over, or undefined to go on polling.
35
+ settle: (found: T) => Promise<T | undefined>
36
+ // Why the unfinished work is not moving, or undefined while it moves.
37
+ stall?: (found: T) => Promise<CiStall | undefined>
38
+ // The lines of a live region under the wait, on a terminal.
39
+ lines?: (spinner: string, found: T | undefined, startedAt: number) => string[]
40
+ }
41
+
42
+ const POLL = 15_000
43
+
44
+ // 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
+ export async function waitForCi<T>(wait: CiWait<T>): Promise<T> {
46
+ const { tool, short, giveUpMs, noneMs, stalledMs, pollMs = POLL } = wait
47
+ const started = Date.now()
48
+ let seen = started
49
+ let moving = started
50
+ let followed = ''
51
+ let found: T | undefined
52
+ const region = wait.lines === undefined ? undefined : liveRegion(spinner => wait.lines?.(spinner, found, started) ?? [])
53
+ const suffix = () => found === undefined ? '' : `: ${wait.links(found)}`
54
+
55
+ // A failure to read the stall is retried on the next poll, and leaves the stall unknown.
56
+ async function stallOf(current: T): Promise<CiStall | undefined | 'unknown'> {
57
+ try {
58
+ return await wait.stall?.(current)
59
+ }
60
+ catch (error) {
61
+ if (error instanceof Exit || isPermanentFailure(error))
62
+ throw error
63
+
64
+ console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`)
65
+
66
+ return 'unknown'
67
+ }
68
+ }
69
+
70
+ try {
71
+ for (;;) {
72
+ if (Date.now() - started > giveUpMs)
73
+ fail(`CI has not finished on ${short} in ${formatDuration(giveUpMs)}${suffix()}`)
74
+
75
+ // The forge or origin missing one poll leaves the wait to the next, unless what is followed is over by then.
76
+ try {
77
+ found = await wait.covering()
78
+ }
79
+ catch (error) {
80
+ if (error instanceof Exit)
81
+ throw error
82
+
83
+ if (cwdGone())
84
+ fail(`the working directory was removed while waiting for CI on ${short}${suffix()}`)
85
+
86
+ if (isPermanentFailure(error))
87
+ fail(error instanceof Error ? error.message : String(error))
88
+
89
+ console.warn(`${tool}: ${error instanceof Error ? error.message : String(error)}, polling again`)
90
+
91
+ const latest = found === undefined ? undefined : await wait.settle(found)
92
+
93
+ if (latest !== undefined)
94
+ return latest
95
+
96
+ await Bun.sleep(pollMs)
97
+
98
+ continue
99
+ }
100
+
101
+ if (found !== undefined) {
102
+ seen = Date.now()
103
+
104
+ const key = wait.key(found)
105
+
106
+ if (key !== followed) {
107
+ followed = key
108
+ moving = Date.now()
109
+ wait.announce(found)
110
+ }
111
+
112
+ if (wait.finished(found))
113
+ return found
114
+
115
+ const stall = await stallOf(found)
116
+
117
+ if (stall === undefined)
118
+ moving = Date.now()
119
+ else if (stall !== 'unknown' && (stall.certain === true || Date.now() - moving > stalledMs))
120
+ fail(stall.message)
121
+ }
122
+ else if (Date.now() - seen > noneMs) {
123
+ fail(wait.noneMessage)
124
+ }
125
+
126
+ await Bun.sleep(pollMs)
127
+ }
128
+ }
129
+ finally {
130
+ region?.stop()
131
+ }
132
+ }
package/src/cli.ts CHANGED
@@ -3,16 +3,24 @@ import type { Colors } from './style'
3
3
  import process from 'node:process'
4
4
  import { applyOutput, Completing, completion, description, exampleRows, Exit, HelpShown, optionRows, OUTPUT_OPTIONS, replacesRows, UsageError, usageLine, wantsHelpJson } from './args'
5
5
  import { paint, takesControlCodes } from './style'
6
- import { distance } from './text'
6
+ import { closest } from './text'
7
+
8
+ // 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
+ export type Summary = string | (() => Promise<string>)
10
+
11
+ export type Commands = Record<string, Command>
12
+
13
+ // Commands known only once something is read, called with the words typed so far from the CLI's name on, and only when a word is not a command the level already has, or for a help or `__complete`.
14
+ export type LazyCommands = (words: string[]) => Promise<Commands>
7
15
 
8
16
  export type Command = {
9
- summary: string
17
+ summary: Summary
10
18
  // The heading the command is listed under in the help, after those with none.
11
19
  group?: string
12
20
  } & (
13
21
  | { run: (args: string[]) => Promise<number> }
14
- // A group of subcommands dispatched the same way, with its own help.
15
- | { commands: Record<string, Command>, aliases?: Record<string, string>, examples?: Example[] }
22
+ // A group of subcommands dispatched the same way, with its own help; `commands` may be a function for a group made of lazy commands alone.
23
+ | { commands: Commands | LazyCommands, extraCommands?: LazyCommands, aliases?: Record<string, string>, examples?: Example[] }
16
24
  )
17
25
 
18
26
  export interface Cli {
@@ -20,7 +28,10 @@ export interface Cli {
20
28
  version: string
21
29
  // One line after `<name> <version>: ` in the help.
22
30
  about: string
23
- commands: Record<string, Command>
31
+ // The commands, or a function giving them all lazily.
32
+ commands: Commands | LazyCommands
33
+ // Lazy commands added to `commands`, which win a name they share.
34
+ extraCommands?: LazyCommands
24
35
  // Names sessions reach for that mean an existing command.
25
36
  aliases?: Record<string, string>
26
37
  // The command that takes the arguments when the first one names no command, so `db "select 1"` runs `db query "select 1"`; `--help` lists its options under the commands.
@@ -40,15 +51,16 @@ const LEVEL_OPTIONS = {
40
51
  const TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: 'boolean', short: 'v', description: 'print the version' } } as const satisfies Options
41
52
 
42
53
  // The rows under `Commands:`, then a heading per group in the order first met, names aligned across them all.
43
- function commandRows(commands: Record<string, Command>, colors: Colors): string[] {
54
+ async function commandRows(commands: Commands, colors: Colors): Promise<string[]> {
44
55
  const width = Math.max(...Object.keys(commands).map(name => name.length))
45
56
  const groups = new Map<string, string[]>([['Commands:', []]])
57
+ const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)))
46
58
 
47
- for (const [name, { summary, group }] of Object.entries(commands)) {
59
+ for (const [index, [name, { group }]] of Object.entries(commands).entries()) {
48
60
  const heading = group === undefined ? 'Commands:' : `${group}:`
49
61
  const rows = groups.get(heading) ?? []
50
62
 
51
- rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`)
63
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summaries[index]!)}`)
52
64
  groups.set(heading, rows)
53
65
  }
54
66
 
@@ -75,34 +87,51 @@ function takeOutputFlags(argv: string[]): string[] {
75
87
  return argv.slice(index)
76
88
  }
77
89
 
78
- // A command the name starts, else one within two edits (one for a name under four letters).
79
- export function closest(name: string, names: string[]): string | undefined {
80
- const started = names.find(known => known.startsWith(name))
81
-
82
- if (started !== undefined)
83
- return started
84
-
85
- const [best] = names.map(known => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far)
86
-
87
- return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined
90
+ // What a level lists and dispatches: the commands it has, and the functions giving more once called.
91
+ interface Scope {
92
+ commands: Commands
93
+ lazy: LazyCommands[]
94
+ aliases: Record<string, string> | undefined
88
95
  }
89
96
 
90
- interface Level {
97
+ interface Level extends Scope {
91
98
  // The words typed to reach this level, `ft plan`.
92
99
  path: string[]
93
- header: (colors: Colors) => string
94
- summary: string
95
- commands: Record<string, Command>
96
- aliases: Record<string, string> | undefined
100
+ header: (colors: Colors, summary: string) => string
101
+ summary: Summary
97
102
  defaultCommand: string | undefined
98
103
  examples: Example[] | undefined
99
104
  replaces: string | undefined
100
105
  }
101
106
 
107
+ async function resolveSummary(summary: Summary): Promise<string> {
108
+ return typeof summary === 'string' ? summary : summary()
109
+ }
110
+
111
+ function scopeOf(commands: Commands | LazyCommands, extra: LazyCommands | undefined, aliases: Record<string, string> | undefined): Scope {
112
+ return { commands: typeof commands === 'function' ? {} : commands, lazy: [...typeof commands === 'function' ? [commands] : [], ...extra === undefined ? [] : [extra]], aliases }
113
+ }
114
+
115
+ // The scope with its lazy commands called with the words typed so far and merged in, the ones it already has winning a shared name.
116
+ async function expand<T extends Scope>(scope: T, words: string[]): Promise<T> {
117
+ if (scope.lazy.length === 0)
118
+ return scope
119
+
120
+ const commands = { ...scope.commands }
121
+
122
+ for (const added of await Promise.all(scope.lazy.map(async lazy => lazy(words)))) {
123
+ for (const [name, command] of Object.entries(added))
124
+ commands[name] ??= command
125
+ }
126
+
127
+ return { ...scope, commands, lazy: [] }
128
+ }
129
+
102
130
  // The level's help; a default command's options follow the commands, as they apply with no command named.
103
- async function help({ path, header, summary, commands, defaultCommand, examples, replaces }: Level, stream: { isTTY?: boolean }): Promise<string> {
131
+ async function help({ path, header, summary: lazySummary, commands, defaultCommand, examples, replaces }: Level, stream: { isTTY?: boolean }): Promise<string> {
104
132
  const colors = paint(stream)
105
133
  const prefix = path.join(' ')
134
+ const summary = await resolveSummary(lazySummary)
106
135
 
107
136
  if (wantsHelpJson())
108
137
  return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }))
@@ -113,12 +142,12 @@ async function help({ path, header, summary, commands, defaultCommand, examples,
113
142
  const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS))
114
143
 
115
144
  return [
116
- header(colors),
145
+ header(colors, summary),
117
146
  '',
118
147
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan('<command>') : `[${colors.cyan('<command>')}]`} [options]`, colors),
119
148
  ...replacesRows(replaces, colors),
120
149
  '',
121
- ...commandRows(commands, colors),
150
+ ...await commandRows(commands, colors),
122
151
  ...own.length === 0 ? [] : ['', `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim('the command run when none is named')}`, ...optionRows(Object.fromEntries(own), colors)],
123
152
  '',
124
153
  colors.bold('Options:'),
@@ -194,13 +223,13 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
194
223
  const [name, ...args] = argv
195
224
 
196
225
  if (name === undefined && level.path.length > 1) {
197
- console.error(await help(level, process.stderr))
226
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stderr))
198
227
 
199
228
  return 2
200
229
  }
201
230
 
202
231
  if (name === undefined || name === 'help' || name === '--help' || name === '-h') {
203
- console.log(await help(level, process.stdout))
232
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stdout))
204
233
 
205
234
  return 0
206
235
  }
@@ -214,6 +243,11 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
214
243
  let command = level.commands[level.aliases?.[name] ?? name]
215
244
  let rest = args
216
245
 
246
+ if (command === undefined && level.lazy.length > 0) {
247
+ level = await expand(level, [...level.path.slice(1), ...argv])
248
+ command = level.commands[level.aliases?.[name] ?? name]
249
+ }
250
+
217
251
  if (command === undefined && level.defaultCommand !== undefined) {
218
252
  command = level.commands[level.defaultCommand]
219
253
  rest = argv
@@ -230,7 +264,7 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
230
264
  if ('commands' in command) {
231
265
  const { summary } = command
232
266
 
233
- return dispatch({ path: [...level.path, name], header: colors => `${colors.bold(`${prefix} ${name}`)} ${summary}`, summary, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact })
267
+ return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact })
234
268
  }
235
269
 
236
270
  try {
@@ -269,16 +303,23 @@ async function leafCandidates(run: (args: string[]) => Promise<number>, words: s
269
303
  if (!(words.at(-1) ?? '').startsWith('-'))
270
304
  return [FILES]
271
305
 
272
- return Object.entries(await optionsOf(run)).map(([name, option]) => `--${name}\t${option.description}`)
306
+ return Object.entries(await optionsOf(run)).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name}\t${option.description}`)
273
307
  }
274
308
 
275
309
  // The candidates for the last word, `value\tdescription` per line, after walking the subcommands the earlier words name.
276
- async function candidates(commands: Record<string, Command>, aliases: Record<string, string> | undefined, defaultCommand: string | undefined, words: string[]): Promise<string[]> {
310
+ async function candidates(scope: Scope, defaultCommand: string | undefined, words: string[], before: string[] = []): Promise<string[]> {
277
311
  const [word = '', ...rest] = words
278
- const command = commands[aliases?.[word] ?? word]
312
+ const listing = rest.length === 0 && !word.startsWith('-')
313
+ let { commands, aliases } = scope
314
+ let command = commands[aliases?.[word] ?? word]
315
+
316
+ if (listing || command === undefined) {
317
+ ({ commands, aliases } = await expand(scope, [...before, ...words]))
318
+ command = commands[aliases?.[word] ?? word]
319
+ }
279
320
 
280
- if (rest.length === 0 && !word.startsWith('-'))
281
- return Object.entries(commands).map(([name, { summary }]) => `${name}\t${summary}`)
321
+ if (listing)
322
+ return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
282
323
 
283
324
  if (command === undefined) {
284
325
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
@@ -286,7 +327,7 @@ async function candidates(commands: Record<string, Command>, aliases: Record<str
286
327
  return fallback !== undefined && 'run' in fallback ? leafCandidates(fallback.run, words) : []
287
328
  }
288
329
 
289
- return 'commands' in command ? candidates(command.commands, command.aliases, undefined, rest) : leafCandidates(command.run, rest)
330
+ return 'commands' in command ? candidates(scopeOf(command.commands, command.extraCommands, command.aliases), undefined, rest, [...before, word]) : leafCandidates(command.run, rest)
290
331
  }
291
332
 
292
333
  async function printCandidates(found: Promise<string[]>): Promise<number> {
@@ -299,9 +340,9 @@ async function printCandidates(found: Promise<string[]>): Promise<number> {
299
340
  // Runs the command argv names and returns the exit code: 0 for help and version, 2 for an unknown command.
300
341
  export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<number> {
301
342
  if (argv[0] === COMPLETE)
302
- return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)))
343
+ return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
303
344
 
304
- return dispatch({ path: [cli.name], header: colors => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, summary: cli.about, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
345
+ 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, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
305
346
  }
306
347
 
307
348
  export interface Main {
package/src/config.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
2
3
  import { dirname, join, resolve } from 'node:path'
3
4
  import process from 'node:process'
4
5
  import { fail } from './args'
5
6
 
6
- // The nearest of `names` in `from` or a folder above it, the first name winning within one folder.
7
- export function findUp(names: string | string[], from = process.cwd()): string | undefined {
7
+ // The nearest of `names` in `from` or a folder above it, the first name winning within one folder; the walk ends after a folder `stop` accepts.
8
+ export function findUp(names: string | string[], from = process.cwd(), stop: (dir: string) => boolean = () => false): string | undefined {
8
9
  const wanted = typeof names === 'string' ? [names] : names
9
10
 
10
11
  for (let dir = resolve(from); ; dir = dirname(dir)) {
@@ -13,11 +14,16 @@ export function findUp(names: string | string[], from = process.cwd()): string |
13
14
  if (found !== undefined)
14
15
  return found
15
16
 
16
- if (dirname(dir) === dir)
17
+ if (stop(dir) || dirname(dir) === dir)
17
18
  return undefined
18
19
  }
19
20
  }
20
21
 
22
+ // A config is looked for up to the repository root or the home folder, never in a folder above them where a stranger's file could sit.
23
+ function endsWalk(dir: string): boolean {
24
+ return dir === resolve(homedir()) || existsSync(join(dir, '.git'))
25
+ }
26
+
21
27
  export interface ConfigSettings {
22
28
  // Where the search starts, the current folder by default.
23
29
  from?: string
@@ -33,9 +39,9 @@ export interface LoadedConfig {
33
39
  config: unknown
34
40
  }
35
41
 
36
- // Imports the nearest config named `names` (`.ts`, `.js` or `.json`), or the one `path` names; undefined when none is found. A named file missing, one failing to import or one without a default export ends the command.
42
+ // Imports the nearest config named `names` (`.ts`, `.js` or `.json`) up to the git root or the home folder, or the one `path` names; undefined when none is found. A named file missing, one failing to import or one without a default export ends the command.
37
43
  export async function loadConfig(names: string | string[], { from = process.cwd(), path }: ConfigSettings = {}): Promise<LoadedConfig | undefined> {
38
- const file = path === undefined ? findUp(names, from) : resolve(from, path)
44
+ const file = path === undefined ? findUp(names, from, endsWalk) : resolve(from, path)
39
45
 
40
46
  if (file === undefined)
41
47
  return undefined
package/src/git.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { basename, dirname } from 'node:path'
2
+ import process from 'node:process'
3
+ import { fail } from './args'
4
+ import { cwdGone, runTool } from './tool'
5
+
6
+ const ORIGIN_PREFIX = /^origin\//
7
+
8
+ // How long a git call that reaches the network waits for origin.
9
+ export const GIT_NETWORK_MS = 60_000
10
+
11
+ // Runs git and returns its trimmed output; throws with git's own message on a failure, or when git cannot start or outlives `timeoutMs`.
12
+ export async function git(args: string[], timeoutMs?: number): Promise<string> {
13
+ const result = await runTool(`git ${args.join(' ')}`, ['git', ...args], { timeoutMs })
14
+
15
+ if (result.code !== 0)
16
+ throw new Error(`git ${args.join(' ')}: ${result.stderr.trim() || `exit ${result.code}`}${cwdGone() ? ' (the working directory was removed)' : ''}`)
17
+
18
+ return result.stdout.trim()
19
+ }
20
+
21
+ export async function gitOk(args: string[]): Promise<boolean> {
22
+ return (await runTool(`git ${args.join(' ')}`, ['git', ...args])).code === 0
23
+ }
24
+
25
+ export async function resolveCommit(ref: string): Promise<string> {
26
+ const sha = (await runTool('git rev-parse', ['git', 'rev-parse', '--verify', '-q', '--end-of-options', `${ref}^{commit}`])).stdout.trim()
27
+
28
+ if (sha === '')
29
+ fail(`no commit ${ref}`)
30
+
31
+ return sha
32
+ }
33
+
34
+ // Fetches one branch of origin; a name that is no branch, such as `--upload-pack=<command>`, is refused before git reads it, and the refspec is spelled out so it can never be an option.
35
+ export async function fetchBranch(branch: string): Promise<void> {
36
+ if (branch.startsWith('-') || !await gitOk(['check-ref-format', '--branch', branch]))
37
+ fail(`${branch} is not a branch name`)
38
+
39
+ await git(['fetch', '-q', 'origin', `refs/heads/${branch}`], GIT_NETWORK_MS)
40
+ }
41
+
42
+ // The branch origin's HEAD names, which is the repository's default branch once cloned or `git remote set-head origin -a` ran.
43
+ export async function defaultBranch(): Promise<string> {
44
+ const head = (await runTool('git symbolic-ref', ['git', 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'])).stdout.trim()
45
+
46
+ return head === '' ? 'main' : head.replace(ORIGIN_PREFIX, '')
47
+ }
48
+
49
+ // A linked worktree can be deleted while a wait runs in it, so the wait moves to the main checkout, which outlives it.
50
+ export async function leaveWorktree(): Promise<void> {
51
+ const common = await git(['rev-parse', '--path-format=absolute', '--git-common-dir'])
52
+
53
+ if (basename(common) === '.git')
54
+ process.chdir(dirname(common))
55
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  export * from './args'
2
2
  export * from './chart'
3
+ export * from './ci'
3
4
  export * from './cli'
4
5
  export * from './config'
5
6
  export * from './format'
7
+ export * from './git'
6
8
  export * from './limit'
9
+ export * from './logs'
7
10
  export * from './process'
11
+ export * from './secrets'
8
12
  export * from './style'
9
13
  export * from './text'
14
+ export * from './tool'