@kaynooo/cli-kit 0.18.0 → 0.20.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 +2 -1
- package/package.json +1 -1
- package/src/args.ts +6 -134
- package/src/cli.ts +50 -4
- package/src/help.ts +73 -0
- package/src/index.ts +1 -0
- package/src/input.ts +64 -0
- package/src/limit.ts +1 -1
- package/src/shell.ts +7 -0
- package/src/usage.ts +129 -0
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
42
42
|
- `runMain({ name, version, run })` runs a CLI that is a single command: `--version` or `-v` alone prints the version, and an `Exit` or any error prints `error: <name>: <message>` and becomes the exit code. `await` it into `process.exitCode`.
|
|
43
43
|
- `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
44
|
- 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
|
+
- `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.
|
|
45
46
|
- `<bin> __complete <words>... <word>` is answered by `runCli` and `runMain` for shell completion: the subcommands with their summaries, a leaf command's options (read by running it until its `parse` call throws them, so every command parses its arguments first), or `__files__` where a path fits. `parse`'s `candidates: { '<project>': async () => ['alpha', ['beta', 'its description']] }` (by the positional's name as `positionals` writes it) gives the values of a positional, and `candidates: () => [...]` on a string option those of its value, besides `choices`; the function runs only while completing, and a command that gives none still answers `__files__`.
|
|
46
47
|
- `parse` options also take `optional: { bare: '1', accepts: /^\d+$/ }` on a string option (`--out [n]`: bare it reads `bare`, a next word is its value unless it starts with a dash or fails `accepts`, and then stays a positional), `conflicts: ['pdf']` (both given is a usage error naming them) and, with `allowNegative`, `negative: 'keep the cached copy'` on a boolean option, which the help and `__complete` then list as `--no-<name>` with that description, the positive flag only when it has a `description` of its own.
|
|
47
48
|
- `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.
|
|
@@ -56,7 +57,7 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
56
57
|
- `--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.
|
|
57
58
|
- `replaces` on the CLI or in `parse`'s settings (`'sed -n N,Mp, grep -n chains'`) adds an `Instead of:` line under the usage. With `KIT_HELP=json` in the environment, `--help` prints `{synopsis, summary, replaces?, examples}` instead, the usage's first line split at its first `: `, which is how `cli list` reads every bin.
|
|
58
59
|
- `runHint(command, effect?)` words the hint of a refusal that a command resolves, ``run `ft take 12 --take-stale` to take it over``, so every CLI writes it alike: `fail(message, 1, runHint('ft take 12 --take-stale', 'take it over'))`. `printHint(hint)` prints the `hint:` line under a refusal a command prints itself (one issue of several refused).
|
|
59
|
-
- `limitOptions(fallback)` is the `--limit <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.
|
|
60
|
+
- `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.
|
|
60
61
|
- `jsonOption(holds?)` is the `--json` flag, worded `print JSON` or `print JSON: <holds>` alike in every CLI, and `printJson(value)` prints the value indented on stdout, bare, with no envelope. Under `--json` runMain and runCli print a thrown error as `{"error", "hint"?, "cause"?}` on stderr instead of the `error:` line, its exit code unchanged; `printJsonError(message, hint?, cause?)` prints one for a command reporting a failure itself.
|
|
61
62
|
- `stripAnsi(text)` removes colour and cursor codes, and `distance(a, b)` is the edit distance between two names.
|
|
62
63
|
- `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`).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-kit",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.20.0",
|
|
5
5
|
"description": "What every @kaynooo CLI shares: argument parsing with --help, a command dispatcher with typo hints and shell completion, and output formatting.",
|
|
6
6
|
"author": "Kaynooo <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|
package/src/args.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ParseArgsOptionDescriptor } from 'node:util'
|
|
2
|
-
import { fstatSync } from 'node:fs'
|
|
3
2
|
import process from 'node:process'
|
|
4
3
|
import { parseArgs } from 'node:util'
|
|
5
4
|
import { upTo } from './format'
|
|
6
|
-
import {
|
|
5
|
+
import { description, helpText, wantsHelpJson } from './help'
|
|
7
6
|
import { closest } from './text'
|
|
8
7
|
|
|
8
|
+
const DASHES = /^-+/
|
|
9
|
+
|
|
9
10
|
interface OptionExtras {
|
|
10
11
|
description?: string
|
|
11
12
|
// A string option taking `choices` accepts only those values: another is a usage error naming them, the help shows them as the value and `__complete` offers them.
|
|
@@ -69,77 +70,6 @@ export type Candidates = () => Candidate[] | Promise<Candidate[]>
|
|
|
69
70
|
|
|
70
71
|
export const completion = { active: false }
|
|
71
72
|
|
|
72
|
-
// Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
|
|
73
|
-
const OPTIONAL = /\[[^\]]+\]/g
|
|
74
|
-
const USAGE_PREFIX = /^usage:\s*/i
|
|
75
|
-
const FULL_STOP = /\.$/
|
|
76
|
-
const DASHES = /^-+/
|
|
77
|
-
|
|
78
|
-
// `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
|
|
79
|
-
export function usageLine(usage: string, colors = paint(process.stdout)): string {
|
|
80
|
-
return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function helpText(usage: string, options: Options, colors = paint(process.stdout), { examples = [], replaces }: Described = {}): string {
|
|
84
|
-
return [usageLine(usage, colors), ...replacesRows(replaces, colors), '', colors.bold('Options:'), ...optionRows(options, colors), ...exampleRows(examples, colors)].join('\n')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// An `Instead of:` line, after a blank line; nothing without one.
|
|
88
|
-
export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
|
|
89
|
-
return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// What a bin's --help prints under KIT_HELP=json, for `cli list` to read without parsing the text.
|
|
93
|
-
export interface Description {
|
|
94
|
-
synopsis: string
|
|
95
|
-
summary: string
|
|
96
|
-
replaces?: string
|
|
97
|
-
examples: { line: string, note?: string }[]
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
|
|
101
|
-
return env.KIT_HELP === 'json'
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// The synopsis and the summary a usage's first line splits into at its first `: `, else the first line after the synopses as the summary, with the examples spelled out.
|
|
105
|
-
export function description(usage: string, { examples = [], replaces }: Described = {}): Description {
|
|
106
|
-
const lines = usage.replace(USAGE_PREFIX, '').split('\n')
|
|
107
|
-
const first = lines[0]!
|
|
108
|
-
const colon = first.indexOf(': ')
|
|
109
|
-
const prose = lines[lines.indexOf('') + 1] ?? ''
|
|
110
|
-
const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
synopsis: colon === -1 ? first : first.slice(0, colon),
|
|
114
|
-
summary: colon === -1 ? fallback : first.slice(colon + 2),
|
|
115
|
-
...replaces === undefined ? {} : { replaces },
|
|
116
|
-
examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// An `Examples:` section, after a blank line, with a line per example; nothing without one.
|
|
121
|
-
export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
|
|
122
|
-
if (examples.length === 0)
|
|
123
|
-
return []
|
|
124
|
-
|
|
125
|
-
return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// A line per option, `-n, --times <value> how many`, aligned on the visible text.
|
|
129
|
-
export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
|
|
130
|
-
const rows = Object.entries(options).map(([name, option]) => {
|
|
131
|
-
const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
|
|
132
|
-
const named = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
|
|
133
|
-
const placeholder = option.optional === undefined ? named : `[${named}]`
|
|
134
|
-
const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
|
|
135
|
-
|
|
136
|
-
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
|
|
137
|
-
})
|
|
138
|
-
const width = Math.max(0, ...rows.map(row => row.width))
|
|
139
|
-
|
|
140
|
-
return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
73
|
// The positionals typed by their names: `<key>` or `KEY` a string, `[file]` an optional one, a name ending in `...` the rest.
|
|
144
74
|
type Slots<Names extends readonly string[]> = Names extends readonly [infer First extends string, ...infer Others extends readonly string[]]
|
|
145
75
|
? First extends `${string}...` | `${string}...]` ? string[] : First extends `[${string}` ? [string?, ...Slots<Others>] : [string, ...Slots<Others>]
|
|
@@ -174,7 +104,7 @@ export interface ParseSettings<Names extends readonly string[] = string[]> {
|
|
|
174
104
|
export type Example = string | [line: string, note: string]
|
|
175
105
|
|
|
176
106
|
// The parts of the settings the help and the description show.
|
|
177
|
-
interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
|
|
107
|
+
export interface Described { examples?: Example[] | undefined, replaces?: string | undefined }
|
|
178
108
|
|
|
179
109
|
// The output flags every command takes, before its own help.
|
|
180
110
|
export const OUTPUT_OPTIONS = {
|
|
@@ -419,63 +349,5 @@ function nearestOption(message: string, options: Options, allowNegative: boolean
|
|
|
419
349
|
return meant === undefined ? undefined : `did you mean ${known.find(flag => flag.replace(DASHES, '') === meant)}?`
|
|
420
350
|
}
|
|
421
351
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
|
|
425
|
-
export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
|
|
426
|
-
if (value === undefined)
|
|
427
|
-
return fallback
|
|
428
|
-
|
|
429
|
-
const number = Number(value)
|
|
430
|
-
|
|
431
|
-
if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
|
|
432
|
-
badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
|
|
433
|
-
|
|
434
|
-
return number
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const DURATION = /^(\d+(?:\.\d+)?)(ms|[smhd])?$/
|
|
438
|
-
const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
|
|
439
|
-
|
|
440
|
-
// A duration such as `500ms`, `90s`, `5m`, `1h` or `7d` in milliseconds, a bare number counting in `unit`.
|
|
441
|
-
export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' | 'h' = 's'): number {
|
|
442
|
-
if (value === undefined)
|
|
443
|
-
return fallback
|
|
444
|
-
|
|
445
|
-
const match = DURATION.exec(value.trim())
|
|
446
|
-
const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
|
|
447
|
-
|
|
448
|
-
if (ms < 1)
|
|
449
|
-
badUsage(`--${name} takes a duration such as 30s, 5m, 1h or 7d, not ${value}`)
|
|
450
|
-
|
|
451
|
-
return ms
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Whether stdin is a pipe or a file to read, rather than a terminal or /dev/null that would leave a command waiting or reading nothing.
|
|
455
|
-
export function isStdinPiped(): boolean {
|
|
456
|
-
const stat = fstatSync(0)
|
|
457
|
-
|
|
458
|
-
return stat.isFIFO() || stat.isFile()
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export interface InputSettings {
|
|
462
|
-
// What to say when stdin is a terminal, as nothing is piped.
|
|
463
|
-
noStdin?: string
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
|
|
467
|
-
export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
|
|
468
|
-
if (path === '-') {
|
|
469
|
-
if (process.stdin.isTTY)
|
|
470
|
-
fail(noStdin)
|
|
471
|
-
|
|
472
|
-
return Bun.stdin.text()
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const file = Bun.file(path)
|
|
476
|
-
|
|
477
|
-
if (!await file.exists())
|
|
478
|
-
fail(`no file ${path}`)
|
|
479
|
-
|
|
480
|
-
return file.text()
|
|
481
|
-
}
|
|
352
|
+
export * from './help'
|
|
353
|
+
export * from './input'
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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
6
|
import { closest } from './text'
|
|
7
|
+
import { noteUsageError, recordUsage, usageEntry } from './usage'
|
|
7
8
|
|
|
8
9
|
// 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
10
|
export type Summary = string | (() => Promise<string>)
|
|
@@ -42,6 +43,8 @@ export interface Cli {
|
|
|
42
43
|
runBare?: boolean
|
|
43
44
|
// Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
|
|
44
45
|
redact?: (text: string) => string
|
|
46
|
+
// Set to false for a bin run on every tool call, which would fill the usage log (`guard hook`).
|
|
47
|
+
usage?: false
|
|
45
48
|
// Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
|
|
46
49
|
examples?: Example[]
|
|
47
50
|
// What the CLI is reached for instead of, shown as `Instead of:` in `<cli> --help`.
|
|
@@ -214,6 +217,9 @@ function reportThrown(prefix: string, error: unknown, redact: (text: string) =>
|
|
|
214
217
|
const named = error instanceof Error && !(error instanceof Exit) && error.name !== 'Error' ? `${error.name}: ${error.message}` : undefined
|
|
215
218
|
const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined
|
|
216
219
|
|
|
220
|
+
if (error instanceof UsageError)
|
|
221
|
+
noteUsageError(error.message)
|
|
222
|
+
|
|
217
223
|
const report = json ? printJsonError : (message: string, hint?: string, causeText?: string) => printError(prefix, message, hint, causeText)
|
|
218
224
|
|
|
219
225
|
report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause)
|
|
@@ -427,7 +433,39 @@ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<nu
|
|
|
427
433
|
if (argv[0] === COMPLETE)
|
|
428
434
|
return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
|
|
429
435
|
|
|
430
|
-
|
|
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 })
|
|
437
|
+
|
|
438
|
+
if (cli.usage !== false) {
|
|
439
|
+
try {
|
|
440
|
+
recordUsage(usageEntry(cli.name, commandPath(scopeOf(cli.commands, cli.extraCommands, cli.aliases), argv), argv, code))
|
|
441
|
+
}
|
|
442
|
+
catch {}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return code
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The command words the arguments start with, a group's subcommand included; lazy commands are not loaded for it.
|
|
449
|
+
function commandPath(scope: Pick<Level, 'commands' | 'aliases'>, argv: readonly string[]): string | undefined {
|
|
450
|
+
const path: string[] = []
|
|
451
|
+
let level = scope
|
|
452
|
+
|
|
453
|
+
for (const word of argv.filter(each => !each.startsWith('-'))) {
|
|
454
|
+
const name = level.aliases !== undefined && Object.hasOwn(level.aliases, word) ? (level.aliases[word] ?? word) : word
|
|
455
|
+
const command: Level['commands'][string] | undefined = Object.hasOwn(level.commands, name) ? level.commands[name] : undefined
|
|
456
|
+
|
|
457
|
+
if (command === undefined)
|
|
458
|
+
break
|
|
459
|
+
|
|
460
|
+
path.push(word)
|
|
461
|
+
|
|
462
|
+
if (!('commands' in command))
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
level = scopeOf(command.commands, command.extraCommands, command.aliases)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return path.length === 0 ? undefined : path.join(' ')
|
|
431
469
|
}
|
|
432
470
|
|
|
433
471
|
export interface Main {
|
|
@@ -435,6 +473,7 @@ export interface Main {
|
|
|
435
473
|
version: string
|
|
436
474
|
run: (args: string[]) => Promise<number>
|
|
437
475
|
redact?: (text: string) => string
|
|
476
|
+
usage?: false
|
|
438
477
|
}
|
|
439
478
|
|
|
440
479
|
// Parses a module's own arguments through runMain's handling, for a script running at its top level: the parsed values come back, and the process ends itself on --help, --version, `__complete` or an error, with the exit code runMain gives.
|
|
@@ -454,7 +493,7 @@ export async function mainArgs<T>(main: Omit<Main, 'run'>, parseArgv: (args: str
|
|
|
454
493
|
}
|
|
455
494
|
|
|
456
495
|
// Runs a CLI that is a single command: `--version` or `-v` alone prints the version, an `Exit` or any error prints as `error: <name>: <message>`. Returns the exit code.
|
|
457
|
-
export async function runMain({ name, version, run, redact }: Main, argv = process.argv.slice(2)): Promise<number> {
|
|
496
|
+
export async function runMain({ name, version, run, redact, usage }: Main, argv = process.argv.slice(2)): Promise<number> {
|
|
458
497
|
if (argv[0] === COMPLETE)
|
|
459
498
|
return printCandidates(leafCandidates(run, argv.slice(1)))
|
|
460
499
|
|
|
@@ -464,10 +503,17 @@ export async function runMain({ name, version, run, redact }: Main, argv = proce
|
|
|
464
503
|
return 0
|
|
465
504
|
}
|
|
466
505
|
|
|
506
|
+
let code: number
|
|
507
|
+
|
|
467
508
|
try {
|
|
468
|
-
|
|
509
|
+
code = await run(argv)
|
|
469
510
|
}
|
|
470
511
|
catch (error) {
|
|
471
|
-
|
|
512
|
+
code = reportThrown(name, error, redact, wantsJson(argv))
|
|
472
513
|
}
|
|
514
|
+
|
|
515
|
+
if (usage !== false)
|
|
516
|
+
recordUsage(usageEntry(name, undefined, argv, code))
|
|
517
|
+
|
|
518
|
+
return code
|
|
473
519
|
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Described, Example, Options } from './args'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { paint } from './style'
|
|
4
|
+
|
|
5
|
+
// Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
|
|
6
|
+
const OPTIONAL = /\[[^\]]+\]/g
|
|
7
|
+
const USAGE_PREFIX = /^usage:\s*/i
|
|
8
|
+
const FULL_STOP = /\.$/
|
|
9
|
+
|
|
10
|
+
// `Usage: <usage>`, the usage given with or without its own `usage:`, optional parts dimmed.
|
|
11
|
+
export function usageLine(usage: string, colors = paint(process.stdout)): string {
|
|
12
|
+
return `${colors.bold('Usage:')} ${usage.replace(USAGE_PREFIX, '').replaceAll(OPTIONAL, part => colors.dim(part))}`
|
|
13
|
+
}
|
|
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')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// An `Instead of:` line, after a blank line; nothing without one.
|
|
20
|
+
export function replacesRows(replaces: string | undefined, colors = paint(process.stdout)): string[] {
|
|
21
|
+
return replaces === undefined ? [] : ['', `${colors.bold('Instead of:')} ${replaces}`]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// What a bin's --help prints under KIT_HELP=json, for `cli list` to read without parsing the text.
|
|
25
|
+
export interface Description {
|
|
26
|
+
synopsis: string
|
|
27
|
+
summary: string
|
|
28
|
+
replaces?: string
|
|
29
|
+
examples: { line: string, note?: string }[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function wantsHelpJson(env: Record<string, string | undefined> = process.env): boolean {
|
|
33
|
+
return env.KIT_HELP === 'json'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 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 {
|
|
38
|
+
const lines = usage.replace(USAGE_PREFIX, '').split('\n')
|
|
39
|
+
const first = lines[0]!
|
|
40
|
+
const colon = first.indexOf(': ')
|
|
41
|
+
const prose = lines[lines.indexOf('') + 1] ?? ''
|
|
42
|
+
const fallback = lines.includes('') ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, '') : ''
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
synopsis: colon === -1 ? first : first.slice(0, colon),
|
|
46
|
+
summary: colon === -1 ? fallback : first.slice(colon + 2),
|
|
47
|
+
...replaces === undefined ? {} : { replaces },
|
|
48
|
+
examples: examples.map(example => typeof example === 'string' ? { line: example } : { line: example[0], note: example[1] }),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// An `Examples:` section, after a blank line, with a line per example; nothing without one.
|
|
53
|
+
export function exampleRows(examples: Example[], colors = paint(process.stdout)): string[] {
|
|
54
|
+
if (examples.length === 0)
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
return ['', colors.bold('Examples:'), ...examples.map(example => typeof example === 'string' ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A line per option, `-n, --times <value> how many`, aligned on the visible text.
|
|
61
|
+
export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
|
|
62
|
+
const rows = Object.entries(options).map(([name, option]) => {
|
|
63
|
+
const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
|
|
64
|
+
const named = option.choices === undefined ? '<value>' : `<${option.choices.join('|')}>`
|
|
65
|
+
const placeholder = option.optional === undefined ? named : `[${named}]`
|
|
66
|
+
const value = option.type === 'string' ? ` ${colors.dim(placeholder)}` : ''
|
|
67
|
+
|
|
68
|
+
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
|
|
69
|
+
})
|
|
70
|
+
const width = Math.max(0, ...rows.map(row => row.width))
|
|
71
|
+
|
|
72
|
+
return rows.map(row => row.description === '' ? ` ${row.flag}` : ` ${row.flag}${' '.repeat(width - row.width)} ${colors.dim(row.description)}`)
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
package/src/input.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { fstatSync } from 'node:fs'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { badUsage, fail } from './args'
|
|
4
|
+
|
|
5
|
+
const WHOLE = /^\d+$/
|
|
6
|
+
|
|
7
|
+
// A name written `<run id>` is a positional and is printed as is, any other is an option printed `--name`.
|
|
8
|
+
export function wholeNumber(value: string | undefined, name: string, fallback: number, min = 1): number {
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return fallback
|
|
11
|
+
|
|
12
|
+
const number = Number(value)
|
|
13
|
+
|
|
14
|
+
if (!WHOLE.test(value) || !Number.isSafeInteger(number) || number < min)
|
|
15
|
+
badUsage(`${name.startsWith('<') ? name : `--${name}`} takes a whole number from ${min}, not ${value}`)
|
|
16
|
+
|
|
17
|
+
return number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DURATION = /^(\d+(?:\.\d+)?)(ms|[smhd])?$/
|
|
21
|
+
const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
|
|
22
|
+
|
|
23
|
+
// A duration such as `500ms`, `90s`, `5m`, `1h` or `7d` in milliseconds, a bare number counting in `unit`.
|
|
24
|
+
export function duration(value: string | undefined, name: string, fallback: number, unit: 's' | 'm' | 'h' = 's'): number {
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
return fallback
|
|
27
|
+
|
|
28
|
+
const match = DURATION.exec(value.trim())
|
|
29
|
+
const ms = match === null ? 0 : Number(match[1]) * UNIT_MS[(match[2] ?? unit) as keyof typeof UNIT_MS]
|
|
30
|
+
|
|
31
|
+
if (ms < 1)
|
|
32
|
+
badUsage(`--${name} takes a duration such as 30s, 5m, 1h or 7d, not ${value}`)
|
|
33
|
+
|
|
34
|
+
return ms
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Whether stdin is a pipe or a file to read, rather than a terminal or /dev/null that would leave a command waiting or reading nothing.
|
|
38
|
+
export function isStdinPiped(): boolean {
|
|
39
|
+
const stat = fstatSync(0)
|
|
40
|
+
|
|
41
|
+
return stat.isFIFO() || stat.isFile()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface InputSettings {
|
|
45
|
+
// What to say when stdin is a terminal, as nothing is piped.
|
|
46
|
+
noStdin?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Text from a file, or from stdin when the path is `-`. A missing file or a stdin left on the terminal ends the command.
|
|
50
|
+
export async function readInput(path: string, { noStdin = 'nothing is piped on stdin' }: InputSettings = {}): Promise<string> {
|
|
51
|
+
if (path === '-') {
|
|
52
|
+
if (process.stdin.isTTY)
|
|
53
|
+
fail(noStdin)
|
|
54
|
+
|
|
55
|
+
return Bun.stdin.text()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const file = Bun.file(path)
|
|
59
|
+
|
|
60
|
+
if (!await file.exists())
|
|
61
|
+
fail(`no file ${path}`)
|
|
62
|
+
|
|
63
|
+
return file.text()
|
|
64
|
+
}
|
package/src/limit.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { badUsage, wholeNumber } from './args'
|
|
|
3
3
|
// The `--limit` and `--full` flags every listing words alike, `fallback` being the rows shown without either.
|
|
4
4
|
export function limitOptions(fallback: number) {
|
|
5
5
|
return {
|
|
6
|
-
limit: { type: 'string', description: `show at most this many rows, the rest counted (${fallback})` },
|
|
6
|
+
limit: { type: 'string', short: 'n', description: `show at most this many rows, the rest counted (${fallback})` },
|
|
7
7
|
full: { type: 'boolean', description: 'show every row' },
|
|
8
8
|
} as const
|
|
9
9
|
}
|
package/src/shell.ts
CHANGED
|
@@ -162,6 +162,13 @@ export function splitCommand(command: string): Piece[] {
|
|
|
162
162
|
continue
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// A backquoted body is read whole, as its own separators split nothing of the command around it.
|
|
166
|
+
if (char === '`') {
|
|
167
|
+
i = closingBackquote(command, i)
|
|
168
|
+
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
165
172
|
if (char === '#' && startsComment(command, i)) {
|
|
166
173
|
const newline = command.indexOf('\n', i)
|
|
167
174
|
const end = newline === -1 ? command.length : newline
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { CLI_FOLDER } from './logs'
|
|
6
|
+
|
|
7
|
+
// One run of a bin: which command it ran, the names of the flags it saw (never a value or an argument), how it exited and, on a usage error, the option it did not know.
|
|
8
|
+
export interface UsageEntry {
|
|
9
|
+
at: string
|
|
10
|
+
bin: string
|
|
11
|
+
command?: string
|
|
12
|
+
flags: string[]
|
|
13
|
+
code: number
|
|
14
|
+
unknown?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UNKNOWN_OPTION = /unknown option '(--[a-z][a-z0-9-]*)[=']/
|
|
18
|
+
// A long name, or a single letter: anything else is an argument that happens to start with a dash, such as `-secret`.
|
|
19
|
+
const FLAG_NAME = /^(?:--[a-z][a-z0-9-]*|-[a-zA-Z])$/
|
|
20
|
+
const LONGEST_NAME = 40
|
|
21
|
+
|
|
22
|
+
// Set when a run reported a usage error, so the entry of that run can name the option.
|
|
23
|
+
let usageError: string | undefined
|
|
24
|
+
|
|
25
|
+
export function noteUsageError(message: string): void {
|
|
26
|
+
usageError = message
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// XDG_STATE_HOME when it is an absolute path, else ~/.local/state, as the spec says an empty or relative value is ignored.
|
|
30
|
+
export function stateHome(env: Record<string, string | undefined> = process.env): string {
|
|
31
|
+
const dir = env.XDG_STATE_HOME
|
|
32
|
+
|
|
33
|
+
return dir !== undefined && isAbsolute(dir) ? dir : join(homedir(), '.local', 'state')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function usagePath(env: Record<string, string | undefined> = process.env): string {
|
|
37
|
+
return join(stateHome(env), CLI_FOLDER, 'usage.jsonl')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether runs are recorded: never under `bun test`, and not once KAYNOOO_CLI_NO_USAGE is set to anything but 0 or an empty value.
|
|
41
|
+
export function usageRecorded(env: Record<string, string | undefined> = process.env): boolean {
|
|
42
|
+
const off = env.KAYNOOO_CLI_NO_USAGE
|
|
43
|
+
|
|
44
|
+
return env.NODE_ENV !== 'test' && (off === undefined || off === '' || off === '0')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The flags among the arguments by name, `--limit=5` as `--limit`, stopping at `--`.
|
|
48
|
+
export function flagNames(argv: readonly string[]): string[] {
|
|
49
|
+
const end = argv.indexOf('--')
|
|
50
|
+
const names = (end === -1 ? argv : argv.slice(0, end)).filter(word => word.startsWith('-') && word !== '-').map(word => word.includes('=') ? word.slice(0, word.indexOf('=')) : word)
|
|
51
|
+
|
|
52
|
+
return [...new Set(names)].filter(name => name.length <= LONGEST_NAME && FLAG_NAME.test(name))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The entry for a run that exited with `code`, with the unknown option a usage error named.
|
|
56
|
+
export function usageEntry(bin: string, command: string | undefined, argv: readonly string[], code: number, now = new Date()): UsageEntry {
|
|
57
|
+
const unknown = code === 2 && usageError !== undefined ? UNKNOWN_OPTION.exec(usageError)?.[1] : undefined
|
|
58
|
+
|
|
59
|
+
usageError = undefined
|
|
60
|
+
|
|
61
|
+
return { at: now.toISOString(), bin, ...(command === undefined ? {} : { command }), flags: flagNames(argv), code, ...(unknown === undefined ? {} : { unknown }) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Appends the run to the usage log, best effort: a log that cannot be written never changes what a bin prints or exits with.
|
|
65
|
+
export function recordUsage(entry: UsageEntry, env: Record<string, string | undefined> = process.env): void {
|
|
66
|
+
if (!usageRecorded(env))
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const path = usagePath(env)
|
|
71
|
+
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
73
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600 })
|
|
74
|
+
}
|
|
75
|
+
catch {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readUsage(path = usagePath(), since = 0): Promise<UsageEntry[]> {
|
|
79
|
+
const file = Bun.file(path)
|
|
80
|
+
|
|
81
|
+
if (!await file.exists())
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
return (await file.text()).split('\n').flatMap((line) => {
|
|
85
|
+
try {
|
|
86
|
+
const entry = JSON.parse(line) as UsageEntry
|
|
87
|
+
|
|
88
|
+
return Date.parse(entry.at) >= since ? [entry] : []
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return []
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UsageSummary {
|
|
97
|
+
runs: number
|
|
98
|
+
bins: { bin: string, runs: number, failed: number, usageErrors: number }[]
|
|
99
|
+
usageErrors: { bin: string, command?: string, unknown: string, count: number }[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Runs per bin, and the usage errors by the option that was not known, most common first.
|
|
103
|
+
export function summarizeUsage(entries: readonly UsageEntry[]): UsageSummary {
|
|
104
|
+
const bins = new Map<string, { runs: number, failed: number, usageErrors: number }>()
|
|
105
|
+
const errors = new Map<string, { bin: string, command?: string, unknown: string, count: number }>()
|
|
106
|
+
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const bin = bins.get(entry.bin) ?? { runs: 0, failed: 0, usageErrors: 0 }
|
|
109
|
+
|
|
110
|
+
bin.runs++
|
|
111
|
+
bin.failed += entry.code === 0 ? 0 : 1
|
|
112
|
+
bin.usageErrors += entry.code === 2 ? 1 : 0
|
|
113
|
+
bins.set(entry.bin, bin)
|
|
114
|
+
|
|
115
|
+
if (entry.code === 2 && entry.unknown !== undefined) {
|
|
116
|
+
const key = `${entry.bin} ${entry.command ?? ''} ${entry.unknown}`
|
|
117
|
+
const error = errors.get(key) ?? { bin: entry.bin, ...(entry.command === undefined ? {} : { command: entry.command }), unknown: entry.unknown, count: 0 }
|
|
118
|
+
|
|
119
|
+
error.count++
|
|
120
|
+
errors.set(key, error)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
runs: entries.length,
|
|
126
|
+
bins: [...bins].map(([bin, counts]) => ({ bin, ...counts })).toSorted((a, b) => b.runs - a.runs),
|
|
127
|
+
usageErrors: [...errors.values()].toSorted((a, b) => b.count - a.count),
|
|
128
|
+
}
|
|
129
|
+
}
|