@kaynooo/cli-kit 0.15.2 → 0.17.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 +9 -5
- package/package.json +1 -1
- package/src/args.ts +153 -11
- package/src/cli.ts +109 -16
- package/src/prompt.ts +61 -6
package/README.md
CHANGED
|
@@ -36,12 +36,15 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
36
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
37
|
- `git(args, timeoutMs?)`, `gitOk`, `resolveCommit`, `fetchBranch`, `defaultBranch` and `leaveWorktree` are the git helpers the forge CLIs share, over `runTool`.
|
|
38
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`.
|
|
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.
|
|
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. A string option may take `choices: ['patch', 'minor', 'major']`: a value outside them is a usage error naming the allowed ones, `--help` shows them as the option's value (`--bump <patch|minor|major>`) and `__complete` offers them after the flag; `oneOf(value, '<bump>', choices)` checks a positional against a set the same way and returns it typed as their union, as `parse` types the option's value (`'patch' | 'minor' | 'major' | undefined`, an array of them with `multiple`).
|
|
40
40
|
- `table`, `truncate`, `formatSeconds`, `formatAge` and `upTo` format output.
|
|
41
|
+
- `mainArgs({ name, version }, argv => parse(argv, USAGE, OPTIONS))` is `runMain` for a module running at its top level: the parsed values come back, and the process ends itself on `--help`, `--version`, `__complete` or a usage error with the exit code `runMain` gives.
|
|
41
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`.
|
|
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
|
+
- `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.
|
|
43
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.
|
|
44
|
-
- `<bin> __complete <words>... <word>` is answered by `runCli` and `runMain` for shell completion: the subcommands with their summaries, a leaf command's options (read by running it until its `parse` call throws them, so every command parses its arguments first), or `__files__` where a path fits.
|
|
45
|
+
- `<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
|
+
- `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
|
+
- `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.
|
|
45
48
|
- `parse(args, usage, options, { rest: true })` stops at the first `--` and returns what follows untouched as `rest` (`out -n 200 -- <cmd>`), `undefined` without a `--`; `{ allowNegative: true }` takes `--no-<flag>` as `false` for a boolean flag.
|
|
46
49
|
- `readInput(path, { noStdin })` reads a file, or stdin when the path is `-`; a missing file or a stdin left on the terminal ends the command with a message.
|
|
47
50
|
- `isStdinPiped()` is true when stdin is a pipe or a file, false on a terminal or `/dev/null`, so a command can read what is piped in without waiting on a terminal.
|
|
@@ -62,9 +65,10 @@ On a terminal, help has bold `Usage:`, `Commands:` and `Options:` headers, comma
|
|
|
62
65
|
- `capture(fn)` from `@kaynooo/cli-kit/test` runs a command in-process and returns `{ result, out, err }`, what it printed through `console` and `process.stdout`/`stderr`, so a test needs no spy. `tempDirs()`, called at the top of a test file, answers a `temp(prefix?)` making fresh folders under the system temp folder, all removed once the file's tests are done.
|
|
63
66
|
- `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.
|
|
64
67
|
- `@kaynooo/cli-kit/prompt` asks on the terminal, on stderr:
|
|
65
|
-
- `select(question, choices, { initial })` picks one `{ value, label, hint }`: typing filters the choices (every word typed in the label or hint, case ignored, Backspace editing), the arrows move and Enter picks.
|
|
66
|
-
- `checklist(question, choices, { initial, toggle })` checks several the same way, Space toggling the choice under the cursor; `toggle(checked, value)` returns what is checked after it, for choices that pull others along, and flips the one choice by default.
|
|
68
|
+
- `select(question, choices, { initial, default })` picks one `{ value, label, hint }`, the cursor starting on the index `initial` or on the choice holding the value `default`: typing filters the choices (every word typed in the label or hint, case ignored, Backspace editing), the arrows move and Enter picks.
|
|
69
|
+
- `checklist(question, choices, { initial, min, toggle })` checks several the same way (Enter on fewer than `min` shows `pick at least N` under the list instead of answering), Space toggling the choice under the cursor; `toggle(checked, value)` returns what is checked after it, for choices that pull others along, and flips the one choice by default.
|
|
67
70
|
- `confirm(question, { default })` answers y or n, Enter taking the default; `input(question, { default, mask, validate })` takes a line, `mask` drawing it as `*`, `validate(value)` returning a message shown under it until the answer passes.
|
|
71
|
+
- `editor(question, { default, extension })` hands a temp file to `VISUAL` or `EDITOR` (`vi` without either), run through the shell so `code --wait` works, and answers the text it was left with; an editor exiting nonzero (`:cq`) cancels.
|
|
68
72
|
- Each answers `undefined` once Escape or Ctrl+C cancels and leaves `✓ <question> <answer>` or `✗ <question> cancelled`; a list longer than the terminal (10 rows at most) scrolls with the cursor, a dimmed `… n more` above and below.
|
|
69
73
|
- Off a terminal, a prompt fails at once with exit 2, `cannot ask "<question>" off a terminal`, with `hint` under it when given (`{ hint: 'pass --yes' }`); none reads a piped line. Each takes a `terminal: { stdin, stderr }`, the process's by default, for a test to drive.
|
|
70
74
|
- `@kaynooo/cli-kit/progress` draws `progressLine(stream?, env?)` on stderr, one line redrawn in place while a task runs (`⠹ prerendering ████████░░░░░░░░░░░░ 8/20 routes 4.2s`, the bar cyan on a dimmed track), fed `update({ step, done?, total?, unit? })` and ended with `stop()`, `done(text)` leaving `✓ <text> <time>` or `failed(text)` leaving `✗ <text>`. It draws only where `takesControlCodes`; elsewhere `update` and `stop` do nothing and `done` and `failed` print their plain line. Anything printed meanwhile clears it first, and an interrupt clears it before the process ends.
|
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.17.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
|
@@ -6,7 +6,21 @@ import { upTo } from './format'
|
|
|
6
6
|
import { paint } from './style'
|
|
7
7
|
import { closest } from './text'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
interface OptionExtras {
|
|
10
|
+
description?: string
|
|
11
|
+
// 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.
|
|
12
|
+
choices?: readonly string[]
|
|
13
|
+
// The values a string option can take, from a function, offered by `__complete` when `choices` does not give them.
|
|
14
|
+
candidates?: Candidates
|
|
15
|
+
// A string option whose value may be left out (`--out [n]`): bare, it reads `bare`. A next word is its value unless it starts with a dash or, given `accepts`, does not match it, and then stays a positional.
|
|
16
|
+
optional?: { bare: string, accepts?: RegExp }
|
|
17
|
+
// Options it cannot be given with, a usage error naming both when they are.
|
|
18
|
+
conflicts?: readonly string[]
|
|
19
|
+
// With `allowNegative`, a boolean option's `--no-<name>` as the help and `__complete` list it, with this description; the positive flag is then left out of both unless it has a `description` of its own, for a flag that defaults on.
|
|
20
|
+
negative?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Options = Record<string, ParseArgsOptionDescriptor & OptionExtras>
|
|
10
24
|
|
|
11
25
|
// The `--json` flag every CLI words alike: `print JSON`, then what it holds when given.
|
|
12
26
|
export function jsonOption(holds?: string) {
|
|
@@ -44,9 +58,15 @@ export class HelpShown extends Exit {
|
|
|
44
58
|
|
|
45
59
|
// Thrown by parse while a bin answers `__complete`, so the command reached hands over its options before doing anything else.
|
|
46
60
|
export class Completing {
|
|
47
|
-
constructor(readonly options: Options) {}
|
|
61
|
+
constructor(readonly options: Options, readonly positionals: readonly string[] = [], readonly candidates: Record<string, Candidates | undefined> = {}) {}
|
|
48
62
|
}
|
|
49
63
|
|
|
64
|
+
// A value `__complete` offers, with an optional description shown beside it.
|
|
65
|
+
export type Candidate = string | [value: string, description: string]
|
|
66
|
+
|
|
67
|
+
// The values a positional or an option can take, read only while a shell completes it.
|
|
68
|
+
export type Candidates = () => Candidate[] | Promise<Candidate[]>
|
|
69
|
+
|
|
50
70
|
export const completion = { active: false }
|
|
51
71
|
|
|
52
72
|
// Optional parts of a usage line, `[options]` or `[tag]`, dimmed in the help.
|
|
@@ -109,9 +129,11 @@ export function exampleRows(examples: Example[], colors = paint(process.stdout))
|
|
|
109
129
|
export function optionRows(options: Options, colors = paint(process.stdout)): string[] {
|
|
110
130
|
const rows = Object.entries(options).map(([name, option]) => {
|
|
111
131
|
const short = option.short === undefined ? ' ' : `${colors.cyan(`-${option.short}`)}, `
|
|
112
|
-
const
|
|
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)}` : ''
|
|
113
135
|
|
|
114
|
-
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ?
|
|
136
|
+
return { flag: `${short}${colors.cyan(`--${name}`)}${value}`, width: `-x, --${name}`.length + (option.type === 'string' ? placeholder.length + 1 : 0), description: option.description ?? '' }
|
|
115
137
|
})
|
|
116
138
|
const width = Math.max(0, ...rows.map(row => row.width))
|
|
117
139
|
|
|
@@ -123,13 +145,26 @@ type Slots<Names extends readonly string[]> = Names extends readonly [infer Firs
|
|
|
123
145
|
? First extends `${string}...` | `${string}...]` ? string[] : First extends `[${string}` ? [string?, ...Slots<Others>] : [string, ...Slots<Others>]
|
|
124
146
|
: Names extends readonly [] ? [] : string[]
|
|
125
147
|
|
|
148
|
+
// The parsed values with an option that has `choices` typed as their union, `undefined` kept when the option may be absent.
|
|
149
|
+
type Chosen<T extends Options, Values> = {
|
|
150
|
+
[Name in keyof Values]: Name extends keyof T
|
|
151
|
+
? T[Name] extends { choices: infer Choices extends readonly string[] }
|
|
152
|
+
? Extract<Values[Name], undefined> | (NonNullable<Values[Name]> extends unknown[] ? Choices[number][] : Choices[number])
|
|
153
|
+
: Values[Name]
|
|
154
|
+
: Values[Name]
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
export interface ParseSettings<Names extends readonly string[] = string[]> {
|
|
127
158
|
// Stops at the first `--` and hands what follows back untouched as `rest`, for a command that runs another (`out -n 200 -- <cmd>`).
|
|
128
159
|
rest?: boolean
|
|
160
|
+
// With `positionals`, passes everything after the last declared one back untouched as `rest`, dashes included and without a `--`: `athome compose <project> exec -T php bash`. Options typed before it parse as usual, and it replaces `rest`.
|
|
161
|
+
trailing?: boolean
|
|
129
162
|
// Takes `--no-<flag>` as `false` for a boolean flag, as parseArgs' own `allowNegative` does.
|
|
130
163
|
allowNegative?: boolean
|
|
131
164
|
// The positionals the command takes, by name as its usage writes them: `<key>` or `KEY` required, `[file]` optional, a last one ending in `...` taking any more. One missing or one too many is a usage error, and the positionals come back typed by these names.
|
|
132
165
|
positionals?: Names
|
|
166
|
+
// The values `__complete` offers for a positional, by its name as `positionals` writes it: `{ '<project>': async () => names }`. A positional without one completes as a path.
|
|
167
|
+
candidates?: Partial<Record<Names[number], Candidates>>
|
|
133
168
|
// Command lines shown under `Examples:` in the help, each with an optional dimmed note: `['ft ci HEAD~1', 'wait for the commit before the last']`.
|
|
134
169
|
examples?: Example[]
|
|
135
170
|
// 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'.
|
|
@@ -174,17 +209,17 @@ export function parse<const T extends Options, const Names extends readonly stri
|
|
|
174
209
|
const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options))) as Omit<typeof OUTPUT_OPTIONS, keyof T>
|
|
175
210
|
const all = { ...options, ...output, help: { type: 'boolean', short: 'h', description: 'show this help' } } as const
|
|
176
211
|
|
|
212
|
+
const listing = listed(all, settings.allowNegative === true)
|
|
213
|
+
|
|
177
214
|
if (completion.active)
|
|
178
|
-
throw new Completing(
|
|
215
|
+
throw new Completing(listing, settings.positionals, settings.candidates)
|
|
179
216
|
|
|
180
|
-
const
|
|
181
|
-
const own = separator === -1 ? args : args.slice(0, separator)
|
|
182
|
-
const rest = separator === -1 ? undefined : args.slice(separator + 1)
|
|
217
|
+
const { own, rest } = settings.trailing === true ? splitTrailing(args, all, settings.positionals?.length ?? 0) : splitRest(args, settings.rest === true)
|
|
183
218
|
|
|
184
219
|
let parsed
|
|
185
220
|
|
|
186
221
|
try {
|
|
187
|
-
parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
|
|
222
|
+
parsed = parseArgs({ args: joinDashValues(withBare(own, all), all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true })
|
|
188
223
|
}
|
|
189
224
|
catch (error) {
|
|
190
225
|
const message = parseError(error)
|
|
@@ -197,15 +232,122 @@ export function parse<const T extends Options, const Names extends readonly stri
|
|
|
197
232
|
applyOutput('color' in output ? values.color as string | undefined : undefined, 'plain' in output ? values.plain as boolean | undefined : undefined)
|
|
198
233
|
|
|
199
234
|
if (values.help === true) {
|
|
200
|
-
console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage,
|
|
235
|
+
console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, listing, undefined, settings))
|
|
201
236
|
|
|
202
237
|
throw new HelpShown()
|
|
203
238
|
}
|
|
204
239
|
|
|
240
|
+
checkChoices(parsed.values, all)
|
|
241
|
+
checkConflicts(parsed.values, all)
|
|
242
|
+
|
|
205
243
|
if (settings.positionals !== undefined)
|
|
206
244
|
checkPositionals(parsed.positionals, settings.positionals)
|
|
207
245
|
|
|
208
|
-
return { ...parsed, positionals: parsed.positionals as Slots<Names>, rest }
|
|
246
|
+
return { ...parsed, values: parsed.values as Chosen<T, typeof parsed.values>, positionals: parsed.positionals as Slots<Names>, rest }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The arguments before a `--` and what follows it, when the command passes that through.
|
|
250
|
+
function splitRest(args: string[], passes: boolean): { own: string[], rest: string[] | undefined } {
|
|
251
|
+
const separator = passes ? args.indexOf('--') : -1
|
|
252
|
+
|
|
253
|
+
return { own: separator === -1 ? args : args.slice(0, separator), rest: separator === -1 ? undefined : args.slice(separator + 1) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The arguments up to the last of `count` positionals, options and their values apart, and everything after it.
|
|
257
|
+
function splitTrailing(args: string[], options: Options, count: number): { own: string[], rest: string[] | undefined } {
|
|
258
|
+
const takesValue = (word: string) => !word.includes('=') && Object.entries(options).some(([name, { short, type }]) => type === 'string' && (word === `--${name}` || word === `-${short}`))
|
|
259
|
+
let seen = 0
|
|
260
|
+
|
|
261
|
+
if (count === 0)
|
|
262
|
+
return { own: args, rest: undefined }
|
|
263
|
+
|
|
264
|
+
for (let at = 0; at < args.length; at++) {
|
|
265
|
+
const word = args[at]!
|
|
266
|
+
|
|
267
|
+
if (word === '--')
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
if (word.startsWith('-')) {
|
|
271
|
+
if (takesValue(word))
|
|
272
|
+
at++
|
|
273
|
+
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
seen++
|
|
278
|
+
|
|
279
|
+
if (seen === count)
|
|
280
|
+
return { own: args.slice(0, at + 1), rest: args.slice(at + 1) }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { own: args, rest: undefined }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The options as the help and `__complete` list them: with `allowNegative`, `--no-<name>` for an option with a `negative` description, in place of the positive flag when that has none of its own.
|
|
287
|
+
function listed(options: Options, allowNegative: boolean): Options {
|
|
288
|
+
if (!allowNegative)
|
|
289
|
+
return options
|
|
290
|
+
|
|
291
|
+
return Object.fromEntries(Object.entries(options).flatMap(([name, option]): [string, Options[string]][] => {
|
|
292
|
+
if (option.type !== 'boolean' || option.negative === undefined)
|
|
293
|
+
return [[name, option]]
|
|
294
|
+
|
|
295
|
+
return [...option.description === undefined ? [] : [[name, option] as [string, Options[string]]], [`no-${name}`, { type: 'boolean', description: option.negative }]]
|
|
296
|
+
}))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The arguments with an optional-value option typed bare given its fallback, ahead of the next argument: `--out` becomes `--out 1` unless a value follows it.
|
|
300
|
+
function withBare(args: string[], options: Options): string[] {
|
|
301
|
+
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]]))
|
|
302
|
+
|
|
303
|
+
if (bare.size === 0)
|
|
304
|
+
return args
|
|
305
|
+
|
|
306
|
+
const separator = args.indexOf('--')
|
|
307
|
+
const own = separator === -1 ? args : args.slice(0, separator)
|
|
308
|
+
|
|
309
|
+
return [...own.flatMap((arg, index) => {
|
|
310
|
+
const optional = bare.get(arg)
|
|
311
|
+
const next = own[index + 1]
|
|
312
|
+
const value = next !== undefined && (optional?.accepts === undefined ? !next.startsWith('-') : optional.accepts.test(next))
|
|
313
|
+
|
|
314
|
+
return optional === undefined || value ? [arg] : [arg, optional.bare]
|
|
315
|
+
}), ...separator === -1 ? [] : args.slice(separator)]
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// A usage error for two options given together that one of them declares in conflict with the other.
|
|
319
|
+
function checkConflicts(values: Record<string, unknown>, options: Options): void {
|
|
320
|
+
const given = (name: string) => values[name] !== undefined && values[name] !== false
|
|
321
|
+
|
|
322
|
+
for (const [name, { conflicts = [] }] of Object.entries(options)) {
|
|
323
|
+
const other = given(name) ? conflicts.find(given) : undefined
|
|
324
|
+
|
|
325
|
+
if (other !== undefined)
|
|
326
|
+
badUsage(`--${name} cannot be used with --${other}`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A usage error for an option given a value outside its `choices`, in parseArgs' own order.
|
|
331
|
+
function checkChoices(values: Record<string, unknown>, options: Options): void {
|
|
332
|
+
for (const [name, option] of Object.entries(options)) {
|
|
333
|
+
if (option.choices === undefined)
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
const given = values[name]
|
|
337
|
+
|
|
338
|
+
for (const value of Array.isArray(given) ? given : [given]) {
|
|
339
|
+
if (typeof value === 'string')
|
|
340
|
+
oneOf(value, name, option.choices)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The value when it is one of `choices`, else a usage error naming them: `--bump takes one of patch, minor, major, not big`. A name written `<bump>` is a positional and is printed as is, any other is an option printed `--name`.
|
|
346
|
+
export function oneOf<const Choices extends readonly string[]>(value: string, name: string, choices: Choices): Choices[number] {
|
|
347
|
+
if (!choices.includes(value))
|
|
348
|
+
badUsage(`${name.startsWith('<') ? name : `--${name}`} takes one of ${choices.join(', ')}, not ${value}`)
|
|
349
|
+
|
|
350
|
+
return value
|
|
209
351
|
}
|
|
210
352
|
|
|
211
353
|
function checkPositionals(given: string[], names: readonly string[]): void {
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Example, Options } from './args'
|
|
1
|
+
import type { Candidate, Example, Options } from './args'
|
|
2
2
|
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'
|
|
@@ -17,6 +17,8 @@ export type Command = {
|
|
|
17
17
|
summary: Summary
|
|
18
18
|
// The heading the command is listed under in the help, after those with none.
|
|
19
19
|
group?: string
|
|
20
|
+
// Runs as usual but is left out of the help, the typo hints and `__complete`, for a command only a script or a shell binding calls.
|
|
21
|
+
hidden?: boolean
|
|
20
22
|
} & (
|
|
21
23
|
| { run: (args: string[]) => Promise<number> }
|
|
22
24
|
// 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.
|
|
@@ -36,6 +38,8 @@ export interface Cli {
|
|
|
36
38
|
aliases?: Record<string, string>
|
|
37
39
|
// 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.
|
|
38
40
|
defaultCommand?: string
|
|
41
|
+
// With `defaultCommand`, a bare `<cli>` runs it instead of printing the help (`account` lists, `mux` attaches).
|
|
42
|
+
runBare?: boolean
|
|
39
43
|
// Rewrites an error's message and cause before they print, for a CLI whose errors may quote what a log must not keep.
|
|
40
44
|
redact?: (text: string) => string
|
|
41
45
|
// Command lines shown under `Examples:` in `<cli> --help`; a command's own go to `parse`.
|
|
@@ -51,8 +55,9 @@ const LEVEL_OPTIONS = {
|
|
|
51
55
|
const TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: 'boolean', short: 'v', description: 'print the version' } } as const satisfies Options
|
|
52
56
|
|
|
53
57
|
// The rows under `Commands:`, then a heading per group in the order first met, names aligned across them all.
|
|
54
|
-
async function commandRows(
|
|
55
|
-
const
|
|
58
|
+
async function commandRows(all: Commands, colors: Colors): Promise<string[]> {
|
|
59
|
+
const commands = shown(all)
|
|
60
|
+
const width = Math.max(0, ...Object.keys(commands).map(name => name.length))
|
|
56
61
|
const groups = new Map<string, string[]>([['Commands:', []]])
|
|
57
62
|
const summaries = await Promise.all(Object.values(commands).map(async ({ summary }) => resolveSummary(summary)))
|
|
58
63
|
|
|
@@ -67,6 +72,11 @@ async function commandRows(commands: Commands, colors: Colors): Promise<string[]
|
|
|
67
72
|
return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [''], colors.bold(heading), ...rows])
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
// The commands that are not hidden.
|
|
76
|
+
function shown(commands: Commands): Commands {
|
|
77
|
+
return Object.fromEntries(Object.entries(commands).filter(([, { hidden }]) => hidden !== true))
|
|
78
|
+
}
|
|
79
|
+
|
|
70
80
|
// The output flags typed before the command name, applied and dropped: `ft --plain runs`.
|
|
71
81
|
function takeOutputFlags(argv: string[]): string[] {
|
|
72
82
|
let index = 0
|
|
@@ -100,6 +110,8 @@ interface Level extends Scope {
|
|
|
100
110
|
header: (colors: Colors, summary: string) => string
|
|
101
111
|
summary: Summary
|
|
102
112
|
defaultCommand: string | undefined
|
|
113
|
+
// Whether a bare run of the level runs its default command instead of printing the help.
|
|
114
|
+
runBare: boolean
|
|
103
115
|
examples: Example[] | undefined
|
|
104
116
|
replaces: string | undefined
|
|
105
117
|
}
|
|
@@ -221,6 +233,16 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
const [name, ...args] = argv
|
|
236
|
+
const fallback = level.defaultCommand === undefined ? undefined : level.commands[level.defaultCommand]
|
|
237
|
+
|
|
238
|
+
if (name === undefined && level.runBare && fallback !== undefined && 'run' in fallback) {
|
|
239
|
+
try {
|
|
240
|
+
return await fallback.run([])
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return reportThrown(`${prefix} ${level.defaultCommand}`, error, redact, false)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
224
246
|
|
|
225
247
|
if (name === undefined && level.path.length > 1) {
|
|
226
248
|
console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process.stderr))
|
|
@@ -242,6 +264,7 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
242
264
|
|
|
243
265
|
let command = level.commands[level.aliases?.[name] ?? name]
|
|
244
266
|
let rest = args
|
|
267
|
+
let ran = name
|
|
245
268
|
|
|
246
269
|
if (command === undefined && level.lazy.length > 0) {
|
|
247
270
|
level = await expand(level, [...level.path.slice(1), ...argv])
|
|
@@ -251,10 +274,11 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
251
274
|
if (command === undefined && level.defaultCommand !== undefined) {
|
|
252
275
|
command = level.commands[level.defaultCommand]
|
|
253
276
|
rest = argv
|
|
277
|
+
ran = level.defaultCommand
|
|
254
278
|
}
|
|
255
279
|
|
|
256
280
|
if (command === undefined) {
|
|
257
|
-
const meant = closest(name, Object.keys(level.commands))
|
|
281
|
+
const meant = closest(name, Object.keys(shown(level.commands)))
|
|
258
282
|
|
|
259
283
|
printError(prefix, `no command ${name}`, meant === undefined ? `run ${prefix} --help for its commands` : `did you mean ${meant}?`)
|
|
260
284
|
|
|
@@ -264,14 +288,14 @@ async function dispatch(level: Level, typed: string[], { version, redact }: { ve
|
|
|
264
288
|
if ('commands' in command) {
|
|
265
289
|
const { summary } = command
|
|
266
290
|
|
|
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 })
|
|
291
|
+
return dispatch({ path: [...level.path, name], header: (colors, text) => `${colors.bold(`${prefix} ${name}`)} ${text}`, summary, ...scopeOf(command.commands, command.extraCommands, command.aliases), defaultCommand: undefined, runBare: false, examples: command.examples, replaces: undefined }, rest, { version, redact })
|
|
268
292
|
}
|
|
269
293
|
|
|
270
294
|
try {
|
|
271
295
|
return await command.run(rest)
|
|
272
296
|
}
|
|
273
297
|
catch (error) {
|
|
274
|
-
return reportThrown(`${prefix} ${
|
|
298
|
+
return reportThrown(`${prefix} ${ran}`, error, redact, wantsJson(rest))
|
|
275
299
|
}
|
|
276
300
|
}
|
|
277
301
|
|
|
@@ -280,8 +304,8 @@ export const COMPLETE = '__complete'
|
|
|
280
304
|
// Printed instead of candidates where a path fits, for the shell to complete files itself.
|
|
281
305
|
export const FILES = '__files__'
|
|
282
306
|
|
|
283
|
-
//
|
|
284
|
-
async function
|
|
307
|
+
// What a command declares to its completion, read by running it until its parse call throws it: every command parses its arguments before anything else.
|
|
308
|
+
async function completingOf(run: (args: string[]) => Promise<number>): Promise<Completing> {
|
|
285
309
|
completion.active = true
|
|
286
310
|
|
|
287
311
|
try {
|
|
@@ -289,21 +313,74 @@ async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Opti
|
|
|
289
313
|
}
|
|
290
314
|
catch (error) {
|
|
291
315
|
if (error instanceof Completing)
|
|
292
|
-
return error
|
|
316
|
+
return error
|
|
293
317
|
}
|
|
294
318
|
finally {
|
|
295
319
|
completion.active = false
|
|
296
320
|
}
|
|
297
321
|
|
|
298
|
-
return {}
|
|
322
|
+
return new Completing({})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function optionsOf(run: (args: string[]) => Promise<number>): Promise<Options> {
|
|
326
|
+
return (await completingOf(run)).options
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function candidateLine(candidate: Candidate): string {
|
|
330
|
+
return typeof candidate === 'string' ? candidate : candidate.join('\t')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The name of the positional the last word is, counting the words before it that are neither options, nor the values of string ones; the last name repeats when it ends in `...`.
|
|
334
|
+
function positionalBeingTyped(before: string[], { options, positionals }: Completing): string | undefined {
|
|
335
|
+
const takesValue = (word: string) => !word.includes('=') && Object.entries(options).some(([name, { short, type }]) => type === 'string' && (word === `--${name}` || word === `-${short}`))
|
|
336
|
+
let index = 0
|
|
337
|
+
let at = 0
|
|
338
|
+
|
|
339
|
+
while (at < before.length) {
|
|
340
|
+
const word = before[at]!
|
|
341
|
+
|
|
342
|
+
if (word === '--') {
|
|
343
|
+
index += before.length - at - 1
|
|
344
|
+
|
|
345
|
+
break
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!word.startsWith('-'))
|
|
349
|
+
index++
|
|
350
|
+
else if (takesValue(word))
|
|
351
|
+
at++
|
|
352
|
+
|
|
353
|
+
at++
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const last = positionals.at(-1)
|
|
357
|
+
|
|
358
|
+
if (index < positionals.length)
|
|
359
|
+
return positionals[index]
|
|
360
|
+
|
|
361
|
+
return last?.replace(']', '').endsWith('...') === true ? last : undefined
|
|
299
362
|
}
|
|
300
363
|
|
|
301
|
-
// A leaf command's candidates:
|
|
364
|
+
// A leaf command's candidates: the values of the string option just typed or of the positional being typed, else paths for a word not starting with a dash; its long options for one that does.
|
|
302
365
|
async function leafCandidates(run: (args: string[]) => Promise<number>, words: string[]): Promise<string[]> {
|
|
303
|
-
|
|
304
|
-
return [FILES]
|
|
366
|
+
const completing = await completingOf(run)
|
|
305
367
|
|
|
306
|
-
|
|
368
|
+
if ((words.at(-1) ?? '').startsWith('-'))
|
|
369
|
+
return Object.entries(completing.options).map(([name, option]) => option.description === undefined ? `--${name}` : `--${name}\t${option.description}`)
|
|
370
|
+
|
|
371
|
+
const flag = words.at(-2)
|
|
372
|
+
const option = flag?.startsWith('-') === true ? Object.entries(completing.options).find(([name, { short }]) => flag === `--${name}` || flag === `-${short}`)?.[1] : undefined
|
|
373
|
+
|
|
374
|
+
if (option?.type === 'string') {
|
|
375
|
+
const values = option.choices === undefined ? await option.candidates?.() : option.choices
|
|
376
|
+
|
|
377
|
+
return values === undefined ? [FILES] : values.map(candidateLine)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const name = positionalBeingTyped(words.slice(0, -1), completing)
|
|
381
|
+
const values = name === undefined ? undefined : await completing.candidates[name]?.()
|
|
382
|
+
|
|
383
|
+
return values === undefined ? [FILES] : values.map(candidateLine)
|
|
307
384
|
}
|
|
308
385
|
|
|
309
386
|
// The candidates for the last word, `value\tdescription` per line, after walking the subcommands the earlier words name.
|
|
@@ -319,7 +396,7 @@ async function candidates(scope: Scope, defaultCommand: string | undefined, word
|
|
|
319
396
|
}
|
|
320
397
|
|
|
321
398
|
if (listing)
|
|
322
|
-
return Promise.all(Object.entries(commands).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
|
|
399
|
+
return Promise.all(Object.entries(shown(commands)).map(async ([name, { summary }]) => `${name}\t${await resolveSummary(summary)}`))
|
|
323
400
|
|
|
324
401
|
if (command === undefined) {
|
|
325
402
|
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand]
|
|
@@ -342,7 +419,7 @@ export async function runCli(cli: Cli, argv = process.argv.slice(2)): Promise<nu
|
|
|
342
419
|
if (argv[0] === COMPLETE)
|
|
343
420
|
return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)))
|
|
344
421
|
|
|
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 })
|
|
422
|
+
return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, runBare: cli.runBare === true, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact })
|
|
346
423
|
}
|
|
347
424
|
|
|
348
425
|
export interface Main {
|
|
@@ -352,6 +429,22 @@ export interface Main {
|
|
|
352
429
|
redact?: (text: string) => string
|
|
353
430
|
}
|
|
354
431
|
|
|
432
|
+
// 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.
|
|
433
|
+
export async function mainArgs<T>(main: Omit<Main, 'run'>, parseArgv: (args: string[]) => T, argv = process.argv.slice(2)): Promise<T> {
|
|
434
|
+
let parsed: { value: T } | undefined
|
|
435
|
+
// eslint-disable-next-line ts/require-await -- commands are async by contract
|
|
436
|
+
const code = await runMain({ ...main, run: async (args) => {
|
|
437
|
+
parsed = { value: parseArgv(args) }
|
|
438
|
+
|
|
439
|
+
return 0
|
|
440
|
+
} }, argv)
|
|
441
|
+
|
|
442
|
+
if (parsed === undefined)
|
|
443
|
+
process.exit(code)
|
|
444
|
+
|
|
445
|
+
return parsed.value
|
|
446
|
+
}
|
|
447
|
+
|
|
355
448
|
// 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.
|
|
356
449
|
export async function runMain({ name, version, run, redact }: Main, argv = process.argv.slice(2)): Promise<number> {
|
|
357
450
|
if (argv[0] === COMPLETE)
|
package/src/prompt.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { Colors } from './style'
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
2
5
|
import process from 'node:process'
|
|
3
6
|
import { fail } from './args'
|
|
4
7
|
import { truncate } from './format'
|
|
@@ -42,14 +45,17 @@ function readKeys(input: string): Key[] {
|
|
|
42
45
|
return (input.match(KEY_SEQUENCES) ?? []).flatMap(sequence => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]))
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
export interface SelectSettings extends PromptSettings {
|
|
48
|
+
export interface SelectSettings<T> extends PromptSettings {
|
|
46
49
|
// The index of the choice the cursor starts on.
|
|
47
50
|
initial?: number
|
|
51
|
+
// The value of the choice the cursor starts on, winning over `initial` when a choice holds it.
|
|
52
|
+
default?: T
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
// One choice among several, typing filtering them, the arrows moving and Enter picking; undefined once Escape or Ctrl+C cancels.
|
|
51
|
-
export async function select<T>(question: string, choices: Choice<T>[], { initial = 0, ...settings }: SelectSettings = {}): Promise<T | undefined> {
|
|
52
|
-
const
|
|
56
|
+
export async function select<T>(question: string, choices: Choice<T>[], { initial = 0, default: fallback, ...settings }: SelectSettings<T> = {}): Promise<T | undefined> {
|
|
57
|
+
const named = choices.findIndex(choice => choice.value === fallback)
|
|
58
|
+
const list = new List(choices, fallback === undefined || named === -1 ? initial : named)
|
|
53
59
|
const picked = await ask(question, settings, (colors, state, rows) => state === 'asking'
|
|
54
60
|
? [asking(colors, question, list.query, 'type to filter, enter picks'), ...list.rows(colors, rows, () => '')]
|
|
55
61
|
: [answer(colors, question, state, list.current()?.label ?? '')], (key) => {
|
|
@@ -69,6 +75,8 @@ export async function select<T>(question: string, choices: Choice<T>[], { initia
|
|
|
69
75
|
|
|
70
76
|
export interface ChecklistSettings<T> extends PromptSettings {
|
|
71
77
|
initial?: ReadonlySet<T>
|
|
78
|
+
// How many choices Enter needs checked: on fewer, `pick at least N` shows under the list until the next key instead of answering.
|
|
79
|
+
min?: number
|
|
72
80
|
// What checking or unchecking `value` leaves checked, which may change other choices along; flips `value` alone by default.
|
|
73
81
|
toggle?: (checked: ReadonlySet<T>, value: T) => ReadonlySet<T>
|
|
74
82
|
}
|
|
@@ -83,15 +91,24 @@ function flip<T>(checked: ReadonlySet<T>, value: T): ReadonlySet<T> {
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
// Several choices, typing filtering them, Space checking or unchecking the one under the cursor and Enter accepting what is checked; undefined once cancelled.
|
|
86
|
-
export async function checklist<T>(question: string, choices: Choice<T>[], { initial = new Set(), toggle = flip, ...settings }: ChecklistSettings<T> = {}): Promise<ReadonlySet<T> | undefined> {
|
|
94
|
+
export async function checklist<T>(question: string, choices: Choice<T>[], { initial = new Set(), min = 0, toggle = flip, ...settings }: ChecklistSettings<T> = {}): Promise<ReadonlySet<T> | undefined> {
|
|
87
95
|
const list = new List(choices, 0)
|
|
88
96
|
let checked = initial
|
|
97
|
+
let invalid: string | undefined
|
|
89
98
|
const accepted = await ask(question, settings, (colors, state, rows) => state === 'asking'
|
|
90
|
-
? [asking(colors, question, list.query, 'type to filter, space toggles, enter accepts'), ...list.rows(colors, rows, choice => checked.has(choice.value) ? `${colors.cyan('◉')} ` : `${colors.dim('◯')} `)]
|
|
99
|
+
? [asking(colors, question, list.query, 'type to filter, space toggles, enter accepts'), ...list.rows(colors, rows, choice => checked.has(choice.value) ? `${colors.cyan('◉')} ` : `${colors.dim('◯')} `), ...invalid === undefined ? [] : [` ${colors.red(invalid)}`]]
|
|
91
100
|
: [answer(colors, question, state, choices.filter(choice => checked.has(choice.value)).map(choice => choice.label).join(', ') || 'none')], (key) => {
|
|
92
|
-
if (key === '
|
|
101
|
+
if (key === 'cancel')
|
|
93
102
|
return key
|
|
94
103
|
|
|
104
|
+
invalid = undefined
|
|
105
|
+
|
|
106
|
+
if (key === 'enter') {
|
|
107
|
+
invalid = checked.size < min ? `pick at least ${min}` : undefined
|
|
108
|
+
|
|
109
|
+
return invalid === undefined ? 'enter' : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
const current = list.current()
|
|
96
113
|
|
|
97
114
|
if (typeof key === 'object' && key.text === ' ') {
|
|
@@ -329,3 +346,41 @@ async function ask(question: string, { hint, terminal = process }: PromptSetting
|
|
|
329
346
|
stdin.pause()
|
|
330
347
|
}
|
|
331
348
|
}
|
|
349
|
+
|
|
350
|
+
export interface EditorSettings extends PromptSettings {
|
|
351
|
+
// The text the file starts with.
|
|
352
|
+
default?: string
|
|
353
|
+
// The file's extension, such as `.md`, so the editor picks its highlighting.
|
|
354
|
+
extension?: string
|
|
355
|
+
// Where VISUAL and EDITOR are read, the process's environment by default.
|
|
356
|
+
env?: Record<string, string | undefined>
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// A text edited in VISUAL or EDITOR (vi without either) on a temp file, the editor taking the terminal until it exits; undefined when it exits nonzero, as `:cq` does.
|
|
360
|
+
export async function editor(question: string, { default: text = '', extension = '.txt', env = process.env, hint, terminal = process }: EditorSettings = {}): Promise<string | undefined> {
|
|
361
|
+
const { stdin, stderr } = terminal
|
|
362
|
+
|
|
363
|
+
if (stdin.isTTY !== true || stderr.isTTY !== true)
|
|
364
|
+
fail(`cannot ask "${question}" off a terminal`, 2, hint)
|
|
365
|
+
|
|
366
|
+
const colors = paint(stderr)
|
|
367
|
+
const command = [env.VISUAL, env.EDITOR].find(value => value !== undefined && value !== '') ?? 'vi'
|
|
368
|
+
const dir = await mkdtemp(join(tmpdir(), 'kit-editor-'))
|
|
369
|
+
const file = join(dir, `edit${extension}`)
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
await writeFile(file, text)
|
|
373
|
+
stderr.write(`${colors.cyan('?')} ${question} ${colors.dim(`(${command})`)}\n`)
|
|
374
|
+
|
|
375
|
+
// Through the shell, so an EDITOR with arguments (`code --wait`) runs as typed.
|
|
376
|
+
const edited = Bun.spawn(['sh', '-c', `${command} "$1"`, 'sh', file], { stdio: ['inherit', 'inherit', 'inherit'], env })
|
|
377
|
+
const done = await edited.exited === 0
|
|
378
|
+
|
|
379
|
+
stderr.write(`${answer(colors, question, done ? 'enter' : 'cancel', 'edited')}\n`)
|
|
380
|
+
|
|
381
|
+
return done ? await readFile(file, 'utf8') : undefined
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
await rm(dir, { recursive: true, force: true })
|
|
385
|
+
}
|
|
386
|
+
}
|