@crustjs/extensions 0.2.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/LICENSE +21 -0
- package/README.md +13 -0
- package/dist/index.d.ts +287 -0
- package/dist/index.js +1857 -0
- package/package.json +72 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1857 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { CrustError, defineCommand, defineExtension, defineExtensionId } from "@crustjs/core";
|
|
4
|
+
import { buildCommandDocumentation, formatDescription, isListed, sectionsFor } from "@crustjs/core/tooling";
|
|
5
|
+
import { stripVTControlCharacters } from "node:util";
|
|
6
|
+
import { bold, cyan, dim, green, padEnd, stringWidth, yellow } from "@crustjs/style";
|
|
7
|
+
//#region src/completion/escape.ts
|
|
8
|
+
/**
|
|
9
|
+
* Per-shell quoting and validation helpers used by the completion
|
|
10
|
+
* templates and by the extension's output-path handling.
|
|
11
|
+
*
|
|
12
|
+
* Why this exists: the completion templates inline a lot of CLI-author
|
|
13
|
+
* provided text — command names, flag names, descriptions, choice values,
|
|
14
|
+
* `binName`, `version` — into emitted shell scripts. Those scripts are
|
|
15
|
+
* routinely installed via `eval "$(mycli completion bash)"` (it is the
|
|
16
|
+
* documented install path), so any unsanitised interpolation is at
|
|
17
|
+
* minimum a foot-gun and at worst arbitrary code execution at install
|
|
18
|
+
* time. Centralising the escaping rules in one module keeps the templates
|
|
19
|
+
* pure rendering code and gives us a single place to test the adversarial
|
|
20
|
+
* inputs.
|
|
21
|
+
*
|
|
22
|
+
* Two complementary strategies are used:
|
|
23
|
+
*
|
|
24
|
+
* 1. **Validate identifiers** — command/flag/alias/bin names are
|
|
25
|
+
* programmer-controlled identifiers in the source CLI definition.
|
|
26
|
+
* They have no business containing whitespace, quotes, semicolons, or
|
|
27
|
+
* control characters. We reject those at spec/render time with a
|
|
28
|
+
* clear error rather than try to escape them through three different
|
|
29
|
+
* shell grammars.
|
|
30
|
+
* 2. **Escape free-form text** — descriptions, choice values, version
|
|
31
|
+
* strings, and the embedded comment headers are user-facing prose.
|
|
32
|
+
* Those go through per-shell escape helpers so the templates can
|
|
33
|
+
* interpolate them safely.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Choice-value shape accepted for `flags[].choices` and `args[].choices`.
|
|
37
|
+
*
|
|
38
|
+
* Looser than {@link IDENT_PATTERN} so legitimate enumerated values like
|
|
39
|
+
* `us-east-1`, `1.0`, `text/plain`, or `node@20` flow through unchanged,
|
|
40
|
+
* but still excludes whitespace, quotes, and shell metacharacters that
|
|
41
|
+
* would force per-shell escaping inside the emitted action lists
|
|
42
|
+
* (`compgen -W`, zsh `(...)` action, fish `-a`).
|
|
43
|
+
*
|
|
44
|
+
* If a CLI legitimately needs choice values containing whitespace or
|
|
45
|
+
* shell-special characters, we'd need a richer per-shell escaping scheme;
|
|
46
|
+
* that's out of scope for v1. We fail fast with a clear message rather
|
|
47
|
+
* than silently mis-quote.
|
|
48
|
+
*/
|
|
49
|
+
const CHOICE_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.+:@/-]*$/;
|
|
50
|
+
function assertSafeChoiceValue(value) {
|
|
51
|
+
if (!CHOICE_VALUE_PATTERN.test(value)) throw new Error(`completion extension: unsupported choice value ${JSON.stringify(value)} — must match /^[A-Za-z0-9_.+:@/-]+$/. Whitespace and shell metacharacters are not supported in v1.`);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Identifier shape accepted for command names, flag names, flag aliases,
|
|
56
|
+
* short flags, and arg names.
|
|
57
|
+
*
|
|
58
|
+
* - First char must be alphanumeric (avoids leading `-` which `complete`
|
|
59
|
+
* would interpret as an option in bash/fish).
|
|
60
|
+
* - Subsequent chars: alphanumeric, `_`, `.`, or `-`.
|
|
61
|
+
* - Single-character names are allowed (covers single-char short flags).
|
|
62
|
+
*
|
|
63
|
+
* This is deliberately conservative. CLI authors who want a command
|
|
64
|
+
* named `foo:bar` or `it's` are out of scope for v1 — every shell would
|
|
65
|
+
* need bespoke escaping for `case` patterns, `compdef`, and fish
|
|
66
|
+
* predicate code.
|
|
67
|
+
*/
|
|
68
|
+
const IDENT_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?$/;
|
|
69
|
+
/** Throw if `name` is not a safe identifier; otherwise return it. */
|
|
70
|
+
function assertSafeIdentifier(name, kind) {
|
|
71
|
+
if (!IDENT_PATTERN.test(name)) throw new Error(`completion extension: invalid ${kind} ${JSON.stringify(name)} — must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/. Whitespace, quotes, and shell metacharacters are not supported.`);
|
|
72
|
+
return name;
|
|
73
|
+
}
|
|
74
|
+
/** Map a validated CLI identifier to a shell function identifier. */
|
|
75
|
+
function toShellIdent(name) {
|
|
76
|
+
return name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate `binName` for use as the program name in generated scripts and
|
|
80
|
+
* as a filesystem basename when `--output-dir` is set.
|
|
81
|
+
*
|
|
82
|
+
* Stricter than {@link assertSafeIdentifier} because `binName` also
|
|
83
|
+
* becomes a filename and a `complete -F`/`compdef` argument that's
|
|
84
|
+
* easier to break than option names.
|
|
85
|
+
*/
|
|
86
|
+
function assertSafeBinName(binName) {
|
|
87
|
+
if (binName.length === 0) throw new Error("completion extension: binName must not be empty");
|
|
88
|
+
if (binName.includes("/") || binName.includes("\\") || binName === ".." || binName === ".") throw new Error(`completion extension: invalid binName ${JSON.stringify(binName)} — path separators and "."/".." are not allowed (used as a filename in --output-dir mode).`);
|
|
89
|
+
return assertSafeIdentifier(binName, "binName");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Strip control characters from `value` so it can be safely embedded in
|
|
93
|
+
* a shell comment line or shell-quoted string without smuggling a
|
|
94
|
+
* newline that would terminate the comment / break the quote nesting.
|
|
95
|
+
*
|
|
96
|
+
* Replaces NUL, CR, LF, vertical-tab, form-feed, and other C0/C1 controls
|
|
97
|
+
* with a single space. We keep horizontal tab as-is (descriptions
|
|
98
|
+
* occasionally use it for alignment).
|
|
99
|
+
*/
|
|
100
|
+
function sanitizeFreeText(value) {
|
|
101
|
+
return value.replace(/[\x00-\x08\x0A-\x1F\x7F]/g, " ");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Wrap `value` as a bash single-quoted shell word.
|
|
105
|
+
*
|
|
106
|
+
* Single quotes have no escape sequence in bash, so embedded single quotes
|
|
107
|
+
* close-and-reopen the quote: `'foo'\''bar'`. This is the canonical
|
|
108
|
+
* `printf %q`-style safe form: the result is always exactly one shell
|
|
109
|
+
* token regardless of the input bytes (after sanitisation).
|
|
110
|
+
*
|
|
111
|
+
* Callers should pass values that have already had control characters
|
|
112
|
+
* scrubbed via {@link sanitizeFreeText} when the value is free-form
|
|
113
|
+
* (description, version, choice value).
|
|
114
|
+
*/
|
|
115
|
+
function bashSingleQuote(value) {
|
|
116
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Escape a single value for inclusion as a literal-matched key in a bash
|
|
120
|
+
* `case` pattern, when the entire pattern is wrapped in double quotes.
|
|
121
|
+
*
|
|
122
|
+
* Inside `"..."` quotes, bash treats `*?[]{}|()` as literals, so the only
|
|
123
|
+
* remaining concern is the double-quote characters in the active set:
|
|
124
|
+
* `\`, `$`, `` ` ``, `"`. We escape those.
|
|
125
|
+
*
|
|
126
|
+
* The result is meant to be placed inside `"..."`; callers add the outer
|
|
127
|
+
* quotes themselves so they can build patterns like
|
|
128
|
+
* `"<path>|<word>"` from multiple escaped pieces.
|
|
129
|
+
*/
|
|
130
|
+
function bashDoubleQuoteInner(value) {
|
|
131
|
+
return value.replace(/[\\$`"]/g, "\\$&");
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Escape free-form text for use inside the `[...]` description bracket of
|
|
135
|
+
* a zsh `_arguments` spec.
|
|
136
|
+
*
|
|
137
|
+
* `_arguments` parses spec strings with `:` as the field separator and
|
|
138
|
+
* `[...]` as the description bracket; backslash escapes both. We also
|
|
139
|
+
* scrub newlines (descriptions are one-liners in completion menus) and
|
|
140
|
+
* single quotes (the spec is wrapped in single quotes by the caller).
|
|
141
|
+
*/
|
|
142
|
+
function zshArgsDescription(value) {
|
|
143
|
+
return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/]/g, "\\]").replace(/:/g, "\\:").replace(/'/g, "'\\''").replace(/[\r\n]+/g, " ");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Escape a value for inclusion as the **name** field of a `_describe`
|
|
147
|
+
* item (the part before the `:` description separator). `_describe`
|
|
148
|
+
* splits each item on the first un-escaped `:`, so embedded colons
|
|
149
|
+
* must be escaped. Backslashes also need escaping because they're the
|
|
150
|
+
* escape character.
|
|
151
|
+
*
|
|
152
|
+
* The result is meant to be placed inside zsh single quotes by the
|
|
153
|
+
* caller (we do NOT include outer quotes); call {@link bashSingleQuote}
|
|
154
|
+
* on the assembled `name:desc` string when emitting.
|
|
155
|
+
*/
|
|
156
|
+
function zshDescribeField(value) {
|
|
157
|
+
return value.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/[\r\n]+/g, " ");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Wrap `value` as a fish single-quoted shell word. Fish single quotes
|
|
161
|
+
* only escape `\\` and `\'`; everything else is literal.
|
|
162
|
+
*/
|
|
163
|
+
function fishSingleQuote(value) {
|
|
164
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/completion/templates/bash.ts
|
|
168
|
+
/**
|
|
169
|
+
* Pure-static bash completion script renderer.
|
|
170
|
+
*
|
|
171
|
+
* Strategy: at generation time we walk the command tree and emit a
|
|
172
|
+
* self-contained bash function that performs the full completion logic
|
|
173
|
+
* locally — no `__complete` subprocess shell-out, no runtime callbacks.
|
|
174
|
+
*
|
|
175
|
+
* The generated script:
|
|
176
|
+
*
|
|
177
|
+
* 1. Defines a Cobra-style fallback init shim
|
|
178
|
+
* (`__<bin>_init_completion`) so the script works even when the
|
|
179
|
+
* `bash-completion` package is not installed (macOS default bash,
|
|
180
|
+
* Alpine, NixOS without the package).
|
|
181
|
+
* 2. Walks `COMP_WORDS` left-to-right, advancing a `cmd_path` through
|
|
182
|
+
* the static command tree. Stops walking at the `--` end-of-options
|
|
183
|
+
* terminator and skips value-taking flag pairs so we don't mistake a
|
|
184
|
+
* flag value for a subcommand.
|
|
185
|
+
* 3. Once the path is resolved, picks completion candidates:
|
|
186
|
+
* - if the user is mid-`--name=value`, splits on `=` and offers the
|
|
187
|
+
* static value list (or files) for that flag,
|
|
188
|
+
* - else if the previous token is a known flag-with-choices, offers
|
|
189
|
+
* the static value list,
|
|
190
|
+
* - else if the previous token is a known free-form value flag,
|
|
191
|
+
* falls back to file completion,
|
|
192
|
+
* - else if the current token starts with `-`, offers the flag set,
|
|
193
|
+
* - else offers the subcommand list and the resolved command's
|
|
194
|
+
* positional choices (or files for free-form positionals).
|
|
195
|
+
* 4. Registers via `complete -F _<bin> <bin>` with the bin name passed
|
|
196
|
+
* through {@link bashSingleQuote}.
|
|
197
|
+
*/
|
|
198
|
+
/**
|
|
199
|
+
* Render the wordlist of subcommand candidates for a single command —
|
|
200
|
+
* each candidate as a bash-quoted shell word so values containing
|
|
201
|
+
* spaces (theoretical: identifier validation rejects them) or shell
|
|
202
|
+
* metacharacters (theoretical: same) survive `compgen -W` splitting.
|
|
203
|
+
*
|
|
204
|
+
* Includes canonical names and any declared aliases.
|
|
205
|
+
*/
|
|
206
|
+
function subcmdWordlist(node) {
|
|
207
|
+
const words = [];
|
|
208
|
+
for (const sub of node.subCommands) {
|
|
209
|
+
words.push(sub.name);
|
|
210
|
+
if (sub.aliases !== void 0) for (const alias of sub.aliases) words.push(alias);
|
|
211
|
+
}
|
|
212
|
+
return words.join(" ");
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Render the wordlist of flag candidates for a single command. Includes
|
|
216
|
+
* long names, short alias, extra long aliases, and `--no-<name>` for
|
|
217
|
+
* boolean flags whose snapshot marks them as negatable.
|
|
218
|
+
*/
|
|
219
|
+
function flagWordlist(node) {
|
|
220
|
+
const words = [];
|
|
221
|
+
for (const flag of node.flags) {
|
|
222
|
+
words.push(`--${flag.name}`);
|
|
223
|
+
if (flag.short !== void 0) words.push(`-${flag.short}`);
|
|
224
|
+
if (flag.aliases !== void 0) for (const alias of flag.aliases) words.push(`--${alias}`);
|
|
225
|
+
if (flag.negatable) {
|
|
226
|
+
words.push(`--no-${flag.name}`);
|
|
227
|
+
if (flag.aliases !== void 0) for (const alias of flag.aliases) words.push(`--no-${alias}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return words.join(" ");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Recursively collect every (parent-path, child-word) edge in the command
|
|
234
|
+
* tree as a `case` branch the dispatch loop can consume. Aliases are
|
|
235
|
+
* surfaced as additional `case` keys that resolve to the same `cmd_path`,
|
|
236
|
+
* matching the router's alias-aware behaviour.
|
|
237
|
+
*/
|
|
238
|
+
function collectPathCases(parentPath, parent, out) {
|
|
239
|
+
for (const sub of parent.subCommands) {
|
|
240
|
+
const newPath = parentPath === "" ? sub.name : `${parentPath}:${sub.name}`;
|
|
241
|
+
const subcmds = subcmdWordlist(sub);
|
|
242
|
+
const flags = flagWordlist(sub);
|
|
243
|
+
const valueFlags = valueFlagWordlist(sub);
|
|
244
|
+
out.push({
|
|
245
|
+
key: `${parentPath}|${sub.name}`,
|
|
246
|
+
cmdPath: newPath,
|
|
247
|
+
subcmds,
|
|
248
|
+
flags,
|
|
249
|
+
valueFlags
|
|
250
|
+
});
|
|
251
|
+
if (sub.aliases !== void 0) for (const alias of sub.aliases) out.push({
|
|
252
|
+
key: `${parentPath}|${alias}`,
|
|
253
|
+
cmdPath: newPath,
|
|
254
|
+
subcmds,
|
|
255
|
+
flags,
|
|
256
|
+
valueFlags
|
|
257
|
+
});
|
|
258
|
+
collectPathCases(newPath, sub, out);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Walk every flag at every depth and emit one {@link ValueTypeCase} per
|
|
263
|
+
* spelling for flags that declared `type: "path"` (file completion) or
|
|
264
|
+
* `type: "url" | "json"` (suppress file fallback).
|
|
265
|
+
*/
|
|
266
|
+
function collectValueTypeCases(cmdPath, node, out) {
|
|
267
|
+
for (const flag of node.flags) {
|
|
268
|
+
if (flag.valueCompletion === void 0) continue;
|
|
269
|
+
const kind = flag.valueCompletion === "files" ? "path" : "suppress";
|
|
270
|
+
for (const spelling of flagSpellings(flag)) out.push({
|
|
271
|
+
key: `${cmdPath}|${spelling}`,
|
|
272
|
+
kind
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
for (const sub of node.subCommands) collectValueTypeCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* For every flag at every command depth that declares `choices`, emit a
|
|
279
|
+
* `case` branch mapping `<path>|<flag-spelling>` → values. Each spelling
|
|
280
|
+
* (long, short, alias) gets its own branch so the lookup is constant-time
|
|
281
|
+
* regardless of how the user wrote the flag.
|
|
282
|
+
*/
|
|
283
|
+
function collectChoiceCases(cmdPath, node, out) {
|
|
284
|
+
for (const flag of node.flags) {
|
|
285
|
+
if (flag.choices === void 0) continue;
|
|
286
|
+
const values = flag.choices.join(" ");
|
|
287
|
+
const spellings = flagSpellings(flag);
|
|
288
|
+
for (const spelling of spellings) out.push({
|
|
289
|
+
key: `${cmdPath}|${spelling}`,
|
|
290
|
+
values
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
for (const sub of node.subCommands) collectChoiceCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Collect per-command positional suppression entries. Each url/json
|
|
297
|
+
* positional contributes one slot index; a variadic url/json positional
|
|
298
|
+
* also sets `variadicFrom` so suppression extends past the declared slot.
|
|
299
|
+
*/
|
|
300
|
+
function collectArgSuppressCases(cmdPath, node, out) {
|
|
301
|
+
const slots = [];
|
|
302
|
+
let variadicFrom;
|
|
303
|
+
node.args.forEach((arg, idx) => {
|
|
304
|
+
if (arg.valueCompletion !== "none") return;
|
|
305
|
+
if (arg.variadic) variadicFrom = idx;
|
|
306
|
+
else slots.push(idx);
|
|
307
|
+
});
|
|
308
|
+
if (slots.length > 0 || variadicFrom !== void 0) out.push({
|
|
309
|
+
cmdPath,
|
|
310
|
+
slots,
|
|
311
|
+
variadicFrom
|
|
312
|
+
});
|
|
313
|
+
for (const sub of node.subCommands) collectArgSuppressCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Collect per-command positional choice entries, recursively. Returns
|
|
317
|
+
* one {@link ArgChoiceEntry} per command that has at least one positional
|
|
318
|
+
* arg with a `choices` list (variadic or otherwise). Commands with no
|
|
319
|
+
* positional choices are omitted so the rendered case-block stays
|
|
320
|
+
* tight.
|
|
321
|
+
*/
|
|
322
|
+
function collectArgChoiceCases(cmdPath, node, out) {
|
|
323
|
+
const bySlot = [];
|
|
324
|
+
let variadicFrom;
|
|
325
|
+
let variadicValues;
|
|
326
|
+
let hasAny = false;
|
|
327
|
+
node.args.forEach((arg, idx) => {
|
|
328
|
+
if (arg.variadic) {
|
|
329
|
+
if (arg.choices !== void 0) {
|
|
330
|
+
variadicFrom = idx;
|
|
331
|
+
variadicValues = arg.choices.join(" ");
|
|
332
|
+
hasAny = true;
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (arg.choices !== void 0) {
|
|
337
|
+
bySlot[idx] = arg.choices.join(" ");
|
|
338
|
+
hasAny = true;
|
|
339
|
+
} else bySlot[idx] = void 0;
|
|
340
|
+
});
|
|
341
|
+
if (hasAny) out.push({
|
|
342
|
+
cmdPath,
|
|
343
|
+
bySlot,
|
|
344
|
+
variadicFrom,
|
|
345
|
+
variadicValues
|
|
346
|
+
});
|
|
347
|
+
for (const sub of node.subCommands) collectArgChoiceCases(cmdPath === "" ? sub.name : `${cmdPath}:${sub.name}`, sub, out);
|
|
348
|
+
}
|
|
349
|
+
function flagSpellings(flag) {
|
|
350
|
+
const out = [`--${flag.name}`];
|
|
351
|
+
if (flag.short !== void 0) out.push(`-${flag.short}`);
|
|
352
|
+
if (flag.aliases !== void 0) for (const alias of flag.aliases) out.push(`--${alias}`);
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Render the wordlist of *value-taking* flag spellings for a single
|
|
357
|
+
* command. Used to drive both flag-value context (after `--target`) and
|
|
358
|
+
* the path walker's "skip the next token" heuristic.
|
|
359
|
+
*/
|
|
360
|
+
function valueFlagWordlist(node) {
|
|
361
|
+
const words = [];
|
|
362
|
+
for (const flag of node.flags) {
|
|
363
|
+
if (!flag.takesValue) continue;
|
|
364
|
+
for (const spelling of flagSpellings(flag)) words.push(spelling);
|
|
365
|
+
}
|
|
366
|
+
return words.join(" ");
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Render a self-contained bash completion script for the given spec.
|
|
370
|
+
*
|
|
371
|
+
* @param spec The walker output describing the command tree.
|
|
372
|
+
* @param binName The user-facing binary name. Validated via
|
|
373
|
+
* {@link assertSafeBinName} upstream.
|
|
374
|
+
* @param version Free-form version string for the header comment;
|
|
375
|
+
* control characters are stripped before emission.
|
|
376
|
+
*/
|
|
377
|
+
function renderBash(spec, binName, version) {
|
|
378
|
+
const ident = toShellIdent(binName);
|
|
379
|
+
const fnName = `_${ident}`;
|
|
380
|
+
const initFn = `__${ident}_init_completion`;
|
|
381
|
+
const rootSubcmds = subcmdWordlist(spec);
|
|
382
|
+
const rootFlags = flagWordlist(spec);
|
|
383
|
+
const rootValueFlags = valueFlagWordlist(spec);
|
|
384
|
+
const pathCases = [];
|
|
385
|
+
collectPathCases("", spec, pathCases);
|
|
386
|
+
const choiceCases = [];
|
|
387
|
+
collectChoiceCases("", spec, choiceCases);
|
|
388
|
+
const valueTypeCases = [];
|
|
389
|
+
collectValueTypeCases("", spec, valueTypeCases);
|
|
390
|
+
const argChoiceEntries = [];
|
|
391
|
+
collectArgChoiceCases("", spec, argChoiceEntries);
|
|
392
|
+
const argSuppressEntries = [];
|
|
393
|
+
collectArgSuppressCases("", spec, argSuppressEntries);
|
|
394
|
+
const lines = [];
|
|
395
|
+
lines.push(`# completion script for ${binName} v${version} — regenerate with: ${binName} completion bash`);
|
|
396
|
+
lines.push("");
|
|
397
|
+
lines.push(`${initFn}() {`);
|
|
398
|
+
lines.push(" COMPREPLY=()");
|
|
399
|
+
lines.push(" cur=\"${COMP_WORDS[COMP_CWORD]}\"");
|
|
400
|
+
lines.push(" if (( COMP_CWORD > 0 )); then prev=\"${COMP_WORDS[COMP_CWORD-1]}\"; else prev=\"\"; fi");
|
|
401
|
+
lines.push(" words=(\"${COMP_WORDS[@]}\")");
|
|
402
|
+
lines.push(" cword=$COMP_CWORD");
|
|
403
|
+
lines.push("}");
|
|
404
|
+
lines.push("");
|
|
405
|
+
lines.push(`__${ident}_prev_is_value_flag() {`);
|
|
406
|
+
lines.push(" local candidate");
|
|
407
|
+
lines.push(" for candidate in $valueFlags; do");
|
|
408
|
+
lines.push(" if [[ \"$candidate\" == \"$prev\" ]]; then return 0; fi");
|
|
409
|
+
lines.push(" done");
|
|
410
|
+
lines.push(" return 1");
|
|
411
|
+
lines.push("}");
|
|
412
|
+
lines.push("");
|
|
413
|
+
lines.push(`${fnName}() {`);
|
|
414
|
+
lines.push(" local cur prev words cword");
|
|
415
|
+
lines.push(" if declare -F _init_completion >/dev/null 2>&1; then");
|
|
416
|
+
lines.push(" _init_completion -n \"=\" || return");
|
|
417
|
+
lines.push(" else");
|
|
418
|
+
lines.push(`\t\t${initFn} || return`);
|
|
419
|
+
lines.push(" fi");
|
|
420
|
+
lines.push("");
|
|
421
|
+
lines.push(" local cmd_path=\"\"");
|
|
422
|
+
lines.push(`\tlocal subcmds="${bashDoubleQuoteInner(rootSubcmds)}"`);
|
|
423
|
+
lines.push(`\tlocal flags="${bashDoubleQuoteInner(rootFlags)}"`);
|
|
424
|
+
lines.push(`\tlocal valueFlags="${bashDoubleQuoteInner(rootValueFlags)}"`);
|
|
425
|
+
lines.push(" local i=1");
|
|
426
|
+
lines.push(" local end_of_options=0");
|
|
427
|
+
lines.push("");
|
|
428
|
+
lines.push(" while (( i < cword )); do");
|
|
429
|
+
lines.push(" local w=\"${words[$i]}\"");
|
|
430
|
+
lines.push(" if [[ \"$w\" == \"--\" ]]; then");
|
|
431
|
+
lines.push(" end_of_options=1");
|
|
432
|
+
lines.push(" ((i++)); break");
|
|
433
|
+
lines.push(" fi");
|
|
434
|
+
lines.push(" if [[ \"$w\" == --*=* ]]; then");
|
|
435
|
+
lines.push(" ((i++)); continue");
|
|
436
|
+
lines.push(" fi");
|
|
437
|
+
lines.push(" if [[ \"$w\" == -* ]]; then");
|
|
438
|
+
lines.push(" local candidate");
|
|
439
|
+
lines.push(" local _was_value_flag=0");
|
|
440
|
+
lines.push(" for candidate in $valueFlags; do");
|
|
441
|
+
lines.push(" if [[ \"$candidate\" == \"$w\" ]]; then _was_value_flag=1; break; fi");
|
|
442
|
+
lines.push(" done");
|
|
443
|
+
lines.push(" if (( _was_value_flag )); then ((i+=2)); else ((i++)); fi");
|
|
444
|
+
lines.push(" continue");
|
|
445
|
+
lines.push(" fi");
|
|
446
|
+
lines.push(" case \"$cmd_path|$w\" in");
|
|
447
|
+
for (const c of pathCases) {
|
|
448
|
+
lines.push(`\t\t\t"${bashDoubleQuoteInner(c.key)}")`);
|
|
449
|
+
lines.push(`\t\t\t\tcmd_path="${bashDoubleQuoteInner(c.cmdPath)}"`);
|
|
450
|
+
lines.push(`\t\t\t\tsubcmds="${bashDoubleQuoteInner(c.subcmds)}"`);
|
|
451
|
+
lines.push(`\t\t\t\tflags="${bashDoubleQuoteInner(c.flags)}"`);
|
|
452
|
+
lines.push(`\t\t\t\tvalueFlags="${bashDoubleQuoteInner(c.valueFlags)}"`);
|
|
453
|
+
lines.push(" ;;");
|
|
454
|
+
}
|
|
455
|
+
lines.push(" *) break ;;");
|
|
456
|
+
lines.push(" esac");
|
|
457
|
+
lines.push(" ((i++))");
|
|
458
|
+
lines.push(" done");
|
|
459
|
+
lines.push("");
|
|
460
|
+
lines.push(" if (( end_of_options )); then");
|
|
461
|
+
lines.push(" return");
|
|
462
|
+
lines.push(" fi");
|
|
463
|
+
lines.push("");
|
|
464
|
+
lines.push(" if [[ \"$cur\" == --*=* ]]; then");
|
|
465
|
+
lines.push(" local _flag=\"${cur%%=*}\"");
|
|
466
|
+
lines.push(" local _value=\"${cur#*=}\"");
|
|
467
|
+
if (choiceCases.length > 0) {
|
|
468
|
+
lines.push(" case \"$cmd_path|$_flag\" in");
|
|
469
|
+
for (const c of choiceCases) {
|
|
470
|
+
lines.push(`\t\t\t"${bashDoubleQuoteInner(c.key)}")`);
|
|
471
|
+
lines.push(`\t\t\t\tCOMPREPLY=( $(compgen -P "\${_flag}=" -W "${bashDoubleQuoteInner(c.values)}" -- "$_value") )`);
|
|
472
|
+
lines.push(" return");
|
|
473
|
+
lines.push(" ;;");
|
|
474
|
+
}
|
|
475
|
+
lines.push(" esac");
|
|
476
|
+
}
|
|
477
|
+
if (valueTypeCases.length > 0) {
|
|
478
|
+
lines.push(" case \"$cmd_path|$_flag\" in");
|
|
479
|
+
for (const c of valueTypeCases) {
|
|
480
|
+
lines.push(`\t\t\t"${bashDoubleQuoteInner(c.key)}")`);
|
|
481
|
+
if (c.kind === "path") lines.push(" COMPREPLY=( $(compgen -P \"${_flag}=\" -f -- \"$_value\") )");
|
|
482
|
+
else lines.push(" compopt +o default 2>/dev/null");
|
|
483
|
+
lines.push(" return");
|
|
484
|
+
lines.push(" ;;");
|
|
485
|
+
}
|
|
486
|
+
lines.push(" esac");
|
|
487
|
+
}
|
|
488
|
+
lines.push(" return");
|
|
489
|
+
lines.push(" fi");
|
|
490
|
+
lines.push("");
|
|
491
|
+
if (choiceCases.length > 0) {
|
|
492
|
+
lines.push(" case \"$cmd_path|$prev\" in");
|
|
493
|
+
for (const c of choiceCases) {
|
|
494
|
+
lines.push(`\t\t"${bashDoubleQuoteInner(c.key)}")`);
|
|
495
|
+
lines.push(`\t\t\tCOMPREPLY=( $(compgen -W "${bashDoubleQuoteInner(c.values)}" -- "$cur") )`);
|
|
496
|
+
lines.push(" return");
|
|
497
|
+
lines.push(" ;;");
|
|
498
|
+
}
|
|
499
|
+
lines.push(" esac");
|
|
500
|
+
lines.push("");
|
|
501
|
+
}
|
|
502
|
+
if (valueTypeCases.length > 0) {
|
|
503
|
+
lines.push(" case \"$cmd_path|$prev\" in");
|
|
504
|
+
for (const c of valueTypeCases) {
|
|
505
|
+
lines.push(`\t\t"${bashDoubleQuoteInner(c.key)}")`);
|
|
506
|
+
if (c.kind === "path") lines.push(" COMPREPLY=( $(compgen -f -- \"$cur\") )");
|
|
507
|
+
else lines.push(" compopt +o default 2>/dev/null");
|
|
508
|
+
lines.push(" return");
|
|
509
|
+
lines.push(" ;;");
|
|
510
|
+
}
|
|
511
|
+
lines.push(" esac");
|
|
512
|
+
lines.push("");
|
|
513
|
+
}
|
|
514
|
+
lines.push(`\tif __${ident}_prev_is_value_flag; then`);
|
|
515
|
+
lines.push(" return");
|
|
516
|
+
lines.push(" fi");
|
|
517
|
+
lines.push("");
|
|
518
|
+
lines.push(" if [[ \"$cur\" == -* ]]; then");
|
|
519
|
+
lines.push(" COMPREPLY=( $(compgen -W \"$flags\" -- \"$cur\") )");
|
|
520
|
+
lines.push(" else");
|
|
521
|
+
if (argChoiceEntries.length > 0 || argSuppressEntries.length > 0) {
|
|
522
|
+
lines.push(" local pos_idx=0");
|
|
523
|
+
lines.push(" local _pidx_j=$i");
|
|
524
|
+
lines.push(" local _pidx_skip_next=0");
|
|
525
|
+
lines.push(" while (( _pidx_j < cword )); do");
|
|
526
|
+
lines.push(" local _pidx_w=\"${words[$_pidx_j]}\"");
|
|
527
|
+
lines.push(" if (( _pidx_skip_next )); then");
|
|
528
|
+
lines.push(" _pidx_skip_next=0");
|
|
529
|
+
lines.push(" ((_pidx_j++)); continue");
|
|
530
|
+
lines.push(" fi");
|
|
531
|
+
lines.push(" if [[ \"$_pidx_w\" == \"--\" ]]; then");
|
|
532
|
+
lines.push(" ((_pidx_j++)); continue");
|
|
533
|
+
lines.push(" fi");
|
|
534
|
+
lines.push(" if [[ \"$_pidx_w\" == --*=* ]]; then");
|
|
535
|
+
lines.push(" ((_pidx_j++)); continue");
|
|
536
|
+
lines.push(" fi");
|
|
537
|
+
lines.push(" if [[ \"$_pidx_w\" == -* ]]; then");
|
|
538
|
+
lines.push(" local _pidx_cand");
|
|
539
|
+
lines.push(" for _pidx_cand in $valueFlags; do");
|
|
540
|
+
lines.push(" if [[ \"$_pidx_cand\" == \"$_pidx_w\" ]]; then _pidx_skip_next=1; break; fi");
|
|
541
|
+
lines.push(" done");
|
|
542
|
+
lines.push(" ((_pidx_j++)); continue");
|
|
543
|
+
lines.push(" fi");
|
|
544
|
+
lines.push(" pos_idx=$((pos_idx + 1))");
|
|
545
|
+
lines.push(" ((_pidx_j++))");
|
|
546
|
+
lines.push(" done");
|
|
547
|
+
lines.push(" local pos_choices=\"\"");
|
|
548
|
+
lines.push(" case \"$cmd_path\" in");
|
|
549
|
+
for (const entry of argChoiceEntries) {
|
|
550
|
+
lines.push(`\t\t\t"${bashDoubleQuoteInner(entry.cmdPath)}")`);
|
|
551
|
+
lines.push(" case \"$pos_idx\" in");
|
|
552
|
+
entry.bySlot.forEach((values, idx) => {
|
|
553
|
+
if (values === void 0) return;
|
|
554
|
+
lines.push(`\t\t\t\t\t${idx})`);
|
|
555
|
+
lines.push(`\t\t\t\t\t\tpos_choices="${bashDoubleQuoteInner(values)}"`);
|
|
556
|
+
lines.push(" ;;");
|
|
557
|
+
});
|
|
558
|
+
if (entry.variadicFrom !== void 0 && entry.variadicValues !== void 0) {
|
|
559
|
+
lines.push(" *)");
|
|
560
|
+
lines.push(`\t\t\t\t\t\tif (( pos_idx >= ${entry.variadicFrom} )); then pos_choices="${bashDoubleQuoteInner(entry.variadicValues)}"; fi`);
|
|
561
|
+
lines.push(" ;;");
|
|
562
|
+
}
|
|
563
|
+
lines.push(" esac");
|
|
564
|
+
lines.push(" ;;");
|
|
565
|
+
}
|
|
566
|
+
lines.push(" esac");
|
|
567
|
+
if (argSuppressEntries.length > 0) {
|
|
568
|
+
lines.push(" case \"$cmd_path\" in");
|
|
569
|
+
for (const entry of argSuppressEntries) {
|
|
570
|
+
lines.push(`\t\t\t"${bashDoubleQuoteInner(entry.cmdPath)}")`);
|
|
571
|
+
lines.push(" case \"$pos_idx\" in");
|
|
572
|
+
for (const slot of entry.slots) {
|
|
573
|
+
lines.push(`\t\t\t\t\t${slot})`);
|
|
574
|
+
lines.push(" compopt +o default 2>/dev/null");
|
|
575
|
+
lines.push(" ;;");
|
|
576
|
+
}
|
|
577
|
+
if (entry.variadicFrom !== void 0) {
|
|
578
|
+
lines.push(" *)");
|
|
579
|
+
lines.push(`\t\t\t\t\t\tif (( pos_idx >= ${entry.variadicFrom} )); then compopt +o default 2>/dev/null; fi`);
|
|
580
|
+
lines.push(" ;;");
|
|
581
|
+
}
|
|
582
|
+
lines.push(" esac");
|
|
583
|
+
lines.push(" ;;");
|
|
584
|
+
}
|
|
585
|
+
lines.push(" esac");
|
|
586
|
+
}
|
|
587
|
+
lines.push(" if (( pos_idx == 0 )); then");
|
|
588
|
+
lines.push(" COMPREPLY=( $(compgen -W \"$pos_choices $subcmds\" -- \"$cur\") )");
|
|
589
|
+
lines.push(" else");
|
|
590
|
+
lines.push(" COMPREPLY=( $(compgen -W \"$pos_choices\" -- \"$cur\") )");
|
|
591
|
+
lines.push(" fi");
|
|
592
|
+
} else lines.push(" COMPREPLY=( $(compgen -W \"$subcmds\" -- \"$cur\") )");
|
|
593
|
+
lines.push(" fi");
|
|
594
|
+
lines.push("}");
|
|
595
|
+
lines.push("");
|
|
596
|
+
lines.push(`complete -o default -F ${fnName} ${bashSingleQuote(binName)}`);
|
|
597
|
+
return `${lines.join("\n")}\n`;
|
|
598
|
+
}
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/completion/templates/fish.ts
|
|
601
|
+
/**
|
|
602
|
+
* Pure-static fish completion script renderer.
|
|
603
|
+
*
|
|
604
|
+
* Strategy: emit declarative `complete -c <bin>` rules — one per
|
|
605
|
+
* subcommand candidate, one per flag of every reachable command. Fish
|
|
606
|
+
* accumulates rules into an in-memory table at `source` time and consults
|
|
607
|
+
* them on every TAB; there's no entry-point function, no subprocess, and
|
|
608
|
+
* no shell state to manage.
|
|
609
|
+
*
|
|
610
|
+
* **Subcommand routing.** We emit a single helper per script —
|
|
611
|
+
* `__<ident>_path_at_arg` — that walks `commandline -opc` left-to-right,
|
|
612
|
+
* skips flags and the `--` end-of-options terminator, and verifies that
|
|
613
|
+
* each consumed positional matches the expected canonical-or-alias set
|
|
614
|
+
* for its depth in order. This replaces the stock
|
|
615
|
+
* `__fish_seen_subcommand_from` chain (which is order-insensitive and
|
|
616
|
+
* misroutes when the same name appears at different depths).
|
|
617
|
+
*/
|
|
618
|
+
/** Build the space-joined spelling list for a command (canonical + aliases). */
|
|
619
|
+
function spellingsOf(node) {
|
|
620
|
+
return [node.name, ...node.aliases ?? []].join(" ");
|
|
621
|
+
}
|
|
622
|
+
/** Build the space-joined "block" list — direct children of `node`. */
|
|
623
|
+
function childSpellings(node) {
|
|
624
|
+
const out = [];
|
|
625
|
+
for (const sub of node.subCommands) {
|
|
626
|
+
out.push(sub.name);
|
|
627
|
+
if (sub.aliases !== void 0) out.push(...sub.aliases);
|
|
628
|
+
}
|
|
629
|
+
return out.join(" ");
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Render a single `complete -c <bin> ...` rule line.
|
|
633
|
+
*
|
|
634
|
+
* `binName` was validated upstream via `assertSafeBinName`, but we still
|
|
635
|
+
* single-quote it as defence-in-depth so the line works even if a future
|
|
636
|
+
* caller bypasses validation.
|
|
637
|
+
*/
|
|
638
|
+
function renderRule(binName, parts) {
|
|
639
|
+
const segments = [`complete -c ${fishSingleQuote(binName)}`];
|
|
640
|
+
if (parts.condition !== void 0) segments.push(`-n ${fishSingleQuote(parts.condition)}`);
|
|
641
|
+
if (parts.exclusive) segments.push("-x");
|
|
642
|
+
else if (parts.requireParameter) segments.push("-r");
|
|
643
|
+
else if (parts.noFiles) segments.push("-f");
|
|
644
|
+
if (parts.short !== void 0) segments.push(`-s ${fishSingleQuote(parts.short)}`);
|
|
645
|
+
if (parts.long !== void 0) segments.push(`-l ${fishSingleQuote(parts.long)}`);
|
|
646
|
+
if (parts.arguments !== void 0) segments.push(`-a ${parts.arguments}`);
|
|
647
|
+
if (parts.description !== void 0) segments.push(`-d ${fishSingleQuote(parts.description)}`);
|
|
648
|
+
return segments.join(" ");
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Build the `-n` path and positional-argument predicate.
|
|
652
|
+
*
|
|
653
|
+
* Calls `__<ident>_path_at_arg <spellings...> <pos_spec> <block>` where
|
|
654
|
+
* `pos_spec` is either `<N>` (exact: completion fires when exactly N
|
|
655
|
+
* positionals have been consumed past the path) or `*<N>` (fires when
|
|
656
|
+
* N-or-more positionals have been consumed).
|
|
657
|
+
*/
|
|
658
|
+
function posPredicate(ident, path, leaf, posSpec) {
|
|
659
|
+
const args = [];
|
|
660
|
+
for (const node of path) args.push(fishSingleQuote(spellingsOf(node)));
|
|
661
|
+
args.push(fishSingleQuote(posSpec));
|
|
662
|
+
args.push(fishSingleQuote(childSpellings(leaf)));
|
|
663
|
+
return `__${ident}_path_at_arg ${args.join(" ")}`;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Recursively walk the command tree and emit:
|
|
667
|
+
* 1. one subcommand-listing rule per child of the current node, gated
|
|
668
|
+
* on the path predicate — these surface child names + aliases with
|
|
669
|
+
* descriptions in the completion menu;
|
|
670
|
+
* 2. one rule per flag of the current node;
|
|
671
|
+
* 3. one rule per positional arg that declares choices (only the first
|
|
672
|
+
* slot — fish's `complete -a` model is best at offering a single
|
|
673
|
+
* candidate set; further slots fall through to filename completion
|
|
674
|
+
* via `-r` on the rule).
|
|
675
|
+
*/
|
|
676
|
+
function emitRules(binName, ident, path, current, out) {
|
|
677
|
+
const condition = posPredicate(ident, path, current, "*0");
|
|
678
|
+
for (const sub of current.subCommands) {
|
|
679
|
+
const desc = sub.description ?? "";
|
|
680
|
+
const spellings = [sub.name, ...sub.aliases ?? []];
|
|
681
|
+
for (const spelling of spellings) out.push(renderRule(binName, {
|
|
682
|
+
condition,
|
|
683
|
+
arguments: fishSingleQuote(spelling),
|
|
684
|
+
description: desc,
|
|
685
|
+
noFiles: true
|
|
686
|
+
}));
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Emit choice values as a separate rule per candidate. See the note
|
|
690
|
+
* on {@link renderRule}'s `arguments` handling for why we don't
|
|
691
|
+
* pack them into one space-joined list.
|
|
692
|
+
*/
|
|
693
|
+
const emitChoiceFlag = (rule, choices) => {
|
|
694
|
+
for (const choice of choices) out.push(renderRule(binName, {
|
|
695
|
+
...rule,
|
|
696
|
+
exclusive: true,
|
|
697
|
+
arguments: fishSingleQuote(choice)
|
|
698
|
+
}));
|
|
699
|
+
};
|
|
700
|
+
for (const flag of current.flags) {
|
|
701
|
+
const desc = flag.description ?? "";
|
|
702
|
+
const baseRule = {
|
|
703
|
+
condition,
|
|
704
|
+
long: flag.name,
|
|
705
|
+
description: desc
|
|
706
|
+
};
|
|
707
|
+
if (flag.short !== void 0) baseRule.short = flag.short;
|
|
708
|
+
const emitValueRule = (rule) => {
|
|
709
|
+
if (flag.choices !== void 0 && flag.choices.length > 0) {
|
|
710
|
+
emitChoiceFlag(rule, flag.choices);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (flag.valueCompletion === "files") {
|
|
714
|
+
out.push(renderRule(binName, {
|
|
715
|
+
...rule,
|
|
716
|
+
requireParameter: true,
|
|
717
|
+
arguments: fishSingleQuote("(__fish_complete_path)")
|
|
718
|
+
}));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
out.push(renderRule(binName, {
|
|
722
|
+
...rule,
|
|
723
|
+
requireParameter: true
|
|
724
|
+
}));
|
|
725
|
+
};
|
|
726
|
+
if (flag.takesValue) emitValueRule(baseRule);
|
|
727
|
+
else out.push(renderRule(binName, baseRule));
|
|
728
|
+
if (flag.aliases !== void 0) for (const alias of flag.aliases) {
|
|
729
|
+
const aliasRule = {
|
|
730
|
+
condition,
|
|
731
|
+
long: alias,
|
|
732
|
+
description: desc
|
|
733
|
+
};
|
|
734
|
+
if (flag.takesValue) emitValueRule(aliasRule);
|
|
735
|
+
else out.push(renderRule(binName, aliasRule));
|
|
736
|
+
}
|
|
737
|
+
if (flag.negatable) {
|
|
738
|
+
const negDesc = `disable: ${desc}`.trim();
|
|
739
|
+
out.push(renderRule(binName, {
|
|
740
|
+
condition,
|
|
741
|
+
long: `no-${flag.name}`,
|
|
742
|
+
description: negDesc
|
|
743
|
+
}));
|
|
744
|
+
if (flag.aliases !== void 0) for (const alias of flag.aliases) out.push(renderRule(binName, {
|
|
745
|
+
condition,
|
|
746
|
+
long: `no-${alias}`,
|
|
747
|
+
description: negDesc
|
|
748
|
+
}));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
current.args.forEach((arg, idx) => {
|
|
752
|
+
const posSpec = arg.variadic ? `*${idx}` : String(idx);
|
|
753
|
+
if (arg.choices !== void 0 && arg.choices.length > 0) {
|
|
754
|
+
const posCondition = posPredicate(ident, path, current, posSpec);
|
|
755
|
+
for (const choice of arg.choices) out.push(renderRule(binName, {
|
|
756
|
+
condition: posCondition,
|
|
757
|
+
arguments: fishSingleQuote(choice),
|
|
758
|
+
description: arg.description ?? "",
|
|
759
|
+
noFiles: true
|
|
760
|
+
}));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (arg.valueCompletion === "files") {
|
|
764
|
+
const posCondition = posPredicate(ident, path, current, posSpec);
|
|
765
|
+
out.push(renderRule(binName, {
|
|
766
|
+
condition: posCondition,
|
|
767
|
+
arguments: fishSingleQuote("(__fish_complete_path)"),
|
|
768
|
+
description: arg.description ?? ""
|
|
769
|
+
}));
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
for (const sub of current.subCommands) emitRules(binName, ident, [...path, sub], sub, out);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Emit the per-script `__<ident>_path_at_arg` helper. It takes a
|
|
776
|
+
* `pos_spec` argument before the block list. `pos_spec` is `<N>` (fires
|
|
777
|
+
* when exactly N positionals have
|
|
778
|
+
* been consumed past the path — the cursor is filling slot N) or
|
|
779
|
+
* `*<N>` (variadic; fires when N-or-more positionals have been
|
|
780
|
+
* consumed, used for variadic-with-choices args and path matching).
|
|
781
|
+
*/
|
|
782
|
+
function emitPosHelper(ident) {
|
|
783
|
+
const fn = `__${ident}_path_at_arg`;
|
|
784
|
+
const lines = [];
|
|
785
|
+
lines.push(`function ${fn}`);
|
|
786
|
+
lines.push(" set -l total_argv (count $argv)");
|
|
787
|
+
lines.push(" set -l block (string split \" \" -- $argv[$total_argv])");
|
|
788
|
+
lines.push(" set -l pos_spec $argv[(math $total_argv - 1)]");
|
|
789
|
+
lines.push(" set -l n (math $total_argv - 2)");
|
|
790
|
+
lines.push(" set -l variadic 0");
|
|
791
|
+
lines.push(" set -l target $pos_spec");
|
|
792
|
+
lines.push(" if string match -q -- '\\**' $pos_spec");
|
|
793
|
+
lines.push(" set variadic 1");
|
|
794
|
+
lines.push(" set target (string sub --start 2 -- $pos_spec)");
|
|
795
|
+
lines.push(" end");
|
|
796
|
+
lines.push(" set -l tokens (commandline -opc)");
|
|
797
|
+
lines.push(" set -l total (count $tokens)");
|
|
798
|
+
lines.push(" set -l j 2");
|
|
799
|
+
lines.push(" set -l consumed 0");
|
|
800
|
+
lines.push(" set -l end_of_options 0");
|
|
801
|
+
lines.push(" while test $j -le $total");
|
|
802
|
+
lines.push(" set -l t $tokens[$j]");
|
|
803
|
+
lines.push(" if test \"$t\" = \"--\"");
|
|
804
|
+
lines.push(" set end_of_options 1");
|
|
805
|
+
lines.push(" set j (math $j + 1)");
|
|
806
|
+
lines.push(" continue");
|
|
807
|
+
lines.push(" end");
|
|
808
|
+
lines.push(" if test $end_of_options -eq 0; and string match -q -- '-*' $t");
|
|
809
|
+
lines.push(" set j (math $j + 1)");
|
|
810
|
+
lines.push(" continue");
|
|
811
|
+
lines.push(" end");
|
|
812
|
+
lines.push(" if test $consumed -lt $n");
|
|
813
|
+
lines.push(" set -l alts (string split \" \" -- $argv[(math $consumed + 1)])");
|
|
814
|
+
lines.push(" if not contains -- $t $alts");
|
|
815
|
+
lines.push(" return 1");
|
|
816
|
+
lines.push(" end");
|
|
817
|
+
lines.push(" set consumed (math $consumed + 1)");
|
|
818
|
+
lines.push(" else");
|
|
819
|
+
lines.push(" if contains -- $t $block");
|
|
820
|
+
lines.push(" return 1");
|
|
821
|
+
lines.push(" end");
|
|
822
|
+
lines.push(" set consumed (math $consumed + 1)");
|
|
823
|
+
lines.push(" end");
|
|
824
|
+
lines.push(" set j (math $j + 1)");
|
|
825
|
+
lines.push(" end");
|
|
826
|
+
lines.push(" set -l beyond (math $consumed - $n)");
|
|
827
|
+
lines.push(" if test $variadic -eq 1");
|
|
828
|
+
lines.push(" test $beyond -ge $target");
|
|
829
|
+
lines.push(" else");
|
|
830
|
+
lines.push(" test $beyond -eq $target");
|
|
831
|
+
lines.push(" end");
|
|
832
|
+
lines.push("end");
|
|
833
|
+
return lines;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Render a self-contained fish completion script for the given spec.
|
|
837
|
+
*
|
|
838
|
+
* The script is safe to drop into `~/.config/fish/completions/<bin>.fish`
|
|
839
|
+
* (auto-loaded the first time the user types `<bin>`) AND safe to source
|
|
840
|
+
* inline via `mycli completion fish | source` — both paths just register
|
|
841
|
+
* `complete` rules.
|
|
842
|
+
*
|
|
843
|
+
* @param spec Walker output.
|
|
844
|
+
* @param binName User-facing binary name; validated upstream.
|
|
845
|
+
* @param version Free-form version string for the header comment.
|
|
846
|
+
*/
|
|
847
|
+
function renderFish(spec, binName, version) {
|
|
848
|
+
const ident = toShellIdent(binName);
|
|
849
|
+
const lines = [];
|
|
850
|
+
lines.push(`# completion script for ${binName} v${version} — regenerate with: ${binName} completion fish`);
|
|
851
|
+
lines.push("");
|
|
852
|
+
lines.push(...emitPosHelper(ident));
|
|
853
|
+
lines.push("");
|
|
854
|
+
lines.push(`complete -c ${fishSingleQuote(binName)} -f`);
|
|
855
|
+
lines.push("");
|
|
856
|
+
const rules = [];
|
|
857
|
+
emitRules(binName, ident, [], spec, rules);
|
|
858
|
+
lines.push(...rules);
|
|
859
|
+
return `${lines.join("\n")}\n`;
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
862
|
+
//#region src/completion/templates/zsh.ts
|
|
863
|
+
/**
|
|
864
|
+
* Pure-static zsh completion script renderer.
|
|
865
|
+
*
|
|
866
|
+
* Strategy: emit one `_<bin>_<path>` helper per command in the tree.
|
|
867
|
+
* Helpers for non-leaf commands declare an `_arguments -C` spec with
|
|
868
|
+
* `1: :->cmds` and `*::arg:->args`, then dispatch via `case "$line[1]"`
|
|
869
|
+
* into the child helper — the canonical `->state` routing pattern from
|
|
870
|
+
* `man zshcompsys` (and used by oclif's `ZshCompWithSpaces`).
|
|
871
|
+
*
|
|
872
|
+
* The first line is `#compdef <bin>` (required by zsh's autoload mechanism)
|
|
873
|
+
* and the entry-point function is invoked with `"$@"` at the end so the
|
|
874
|
+
* script works both when dropped into `$fpath` and when sourced directly
|
|
875
|
+
* (e.g. via `eval "$(mycli completion zsh)"`).
|
|
876
|
+
*
|
|
877
|
+
* **Quoting model.** Every spec string is wrapped via {@link bashSingleQuote}
|
|
878
|
+
* so it survives any character (description text, choice values) by going
|
|
879
|
+
* through the standard `'foo'\''bar'` close-and-reopen idiom. The spec
|
|
880
|
+
* **contents** are independently escaped via {@link zshArgsDescription}
|
|
881
|
+
* (for `_arguments` description brackets) and {@link zshDescribeField}
|
|
882
|
+
* (for the colon-separated `_describe` items).
|
|
883
|
+
*/
|
|
884
|
+
function flagSpecs(flag) {
|
|
885
|
+
const desc = flag.description ?? "";
|
|
886
|
+
const descPart = `[${zshArgsDescription(desc)}]`;
|
|
887
|
+
let valueSuffix = "";
|
|
888
|
+
if (flag.takesValue) {
|
|
889
|
+
const valueLabel = flag.name;
|
|
890
|
+
if (flag.choices !== void 0 && flag.choices.length > 0) valueSuffix = `:${valueLabel}:(${flag.choices.join(" ")})`;
|
|
891
|
+
else if (flag.valueCompletion === "files") valueSuffix = `:${valueLabel}:_files`;
|
|
892
|
+
else if (flag.valueCompletion === "none") valueSuffix = `:${valueLabel}: `;
|
|
893
|
+
else if (flag.type === "string") valueSuffix = `:${valueLabel}:_files`;
|
|
894
|
+
else valueSuffix = `:${valueLabel}: `;
|
|
895
|
+
}
|
|
896
|
+
const repeat = flag.multiple === true ? "*" : "";
|
|
897
|
+
const allLong = [flag.name, ...flag.aliases ?? []];
|
|
898
|
+
const allShort = flag.short !== void 0 ? [flag.short] : [];
|
|
899
|
+
const specs = [];
|
|
900
|
+
if (allShort.length === 0 && allLong.length === 1) {
|
|
901
|
+
const eq = flag.takesValue ? "=" : "";
|
|
902
|
+
const body = `${repeat}--${flag.name}${eq}${descPart}${valueSuffix}`;
|
|
903
|
+
specs.push(bashSingleQuote(body));
|
|
904
|
+
} else {
|
|
905
|
+
const mutex = [...allShort.map((s) => `-${s}`), ...allLong.map((l) => `--${l}`)].join(" ");
|
|
906
|
+
const altGroup = [...allShort.map((s) => `-${s}`), ...allLong.map((l) => `--${l}${flag.takesValue ? "=" : ""}`)].join(",");
|
|
907
|
+
const headPrefix = flag.multiple === true ? "" : `(${mutex})`;
|
|
908
|
+
const repeatBrace = flag.multiple === true ? "*" : "";
|
|
909
|
+
const fragments = [
|
|
910
|
+
bashSingleQuote(headPrefix),
|
|
911
|
+
`${repeatBrace}{${altGroup}}`,
|
|
912
|
+
bashSingleQuote(`${descPart}${valueSuffix}`)
|
|
913
|
+
];
|
|
914
|
+
specs.push(fragments.join(""));
|
|
915
|
+
}
|
|
916
|
+
if (flag.negatable) {
|
|
917
|
+
const negDesc = `[${zshArgsDescription(`disable: ${desc}`.trim())}]`;
|
|
918
|
+
const negNames = allLong.map((l) => `--no-${l}`);
|
|
919
|
+
if (negNames.length === 1) specs.push(bashSingleQuote(`${repeat}${negNames[0]}${negDesc}`));
|
|
920
|
+
else {
|
|
921
|
+
const negMutex = negNames.join(" ");
|
|
922
|
+
const negAlt = negNames.join(",");
|
|
923
|
+
const headPrefix = flag.multiple === true ? "" : `(${negMutex})`;
|
|
924
|
+
const repeatBrace = flag.multiple === true ? "*" : "";
|
|
925
|
+
specs.push([
|
|
926
|
+
bashSingleQuote(headPrefix),
|
|
927
|
+
`${repeatBrace}{${negAlt}}`,
|
|
928
|
+
bashSingleQuote(negDesc)
|
|
929
|
+
].join(""));
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return specs;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Render the positional-argument specs for a single command.
|
|
936
|
+
*
|
|
937
|
+
* Uses the same `'<idx>:NAME:<action>'` shape across leaf and non-leaf
|
|
938
|
+
* helpers. Variadic args expand the `<idx>` to `*` and run the action
|
|
939
|
+
* for every remaining word. Branches mirror {@link flagSpecs}:
|
|
940
|
+
* - choices → `(a b c)`
|
|
941
|
+
* - valueCompletion === "files" → `_files`
|
|
942
|
+
* - valueCompletion === "none" → ` ` (noop — url/json are not paths)
|
|
943
|
+
* - free-form string → `_files`
|
|
944
|
+
* - number/bool → ` ` (noop — rare positional case)
|
|
945
|
+
*/
|
|
946
|
+
function renderArgSpecs(node) {
|
|
947
|
+
const specs = [];
|
|
948
|
+
node.args.forEach((arg, idx) => {
|
|
949
|
+
const idxToken = arg.variadic ? "*" : String(idx + 1);
|
|
950
|
+
const label = arg.name;
|
|
951
|
+
let action;
|
|
952
|
+
if (arg.choices !== void 0 && arg.choices.length > 0) action = `(${arg.choices.join(" ")})`;
|
|
953
|
+
else if (arg.valueCompletion === "none") action = " ";
|
|
954
|
+
else if (arg.valueCompletion === "files" || arg.type === "string") action = "_files";
|
|
955
|
+
else action = " ";
|
|
956
|
+
specs.push(bashSingleQuote(`${idxToken}:${label}:${action}`));
|
|
957
|
+
});
|
|
958
|
+
return specs;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Build the function name for the helper that handles a given command
|
|
962
|
+
* path. The root is `_<ident>`; nested children append `_<segment>` for
|
|
963
|
+
* each step.
|
|
964
|
+
*/
|
|
965
|
+
function helperName(rootIdent, path) {
|
|
966
|
+
if (path.length === 0) return `_${rootIdent}`;
|
|
967
|
+
return `_${rootIdent}_${path.map(toShellIdent).join("_")}`;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Render a single command's helper function.
|
|
971
|
+
*
|
|
972
|
+
* - Leaf commands: declare flag specs and any positional arg specs.
|
|
973
|
+
* - Non-leaf commands: add the standard `->state` routing and a `case`
|
|
974
|
+
* over `$line[1]` (the first non-option positional) that dispatches to
|
|
975
|
+
* each child helper. Aliases reuse the same helper as their canonical
|
|
976
|
+
* sibling.
|
|
977
|
+
*
|
|
978
|
+
* Non-leaf commands with positional args are uncommon but supported:
|
|
979
|
+
* the routing emits `'1: :->cmds'` so the first slot is treated as a
|
|
980
|
+
* subcommand, plus any *additional* positional specs (slots 2+) for the
|
|
981
|
+
* declared args. If a CLI uses arg slot 1 AND has subcommands, the
|
|
982
|
+
* subcommand wins for that slot — matches the parser's behaviour where
|
|
983
|
+
* a known subcommand takes precedence over a positional value.
|
|
984
|
+
*/
|
|
985
|
+
function renderHelper(rootIdent, path, node, out) {
|
|
986
|
+
const fnName = helperName(rootIdent, path);
|
|
987
|
+
const flagSpecLines = node.flags.flatMap(flagSpecs);
|
|
988
|
+
const argSpecLines = renderArgSpecs(node);
|
|
989
|
+
const hasChildren = node.subCommands.length > 0;
|
|
990
|
+
out.push(`${fnName}() {`);
|
|
991
|
+
if (hasChildren) {
|
|
992
|
+
out.push(" local context state state_descr line");
|
|
993
|
+
out.push(" typeset -A opt_args");
|
|
994
|
+
out.push("");
|
|
995
|
+
out.push(" _arguments -C \\");
|
|
996
|
+
for (const spec of flagSpecLines) out.push(`\t\t${spec} \\`);
|
|
997
|
+
out.push(" '1: :->cmds' \\");
|
|
998
|
+
out.push(" '*::arg:->args'");
|
|
999
|
+
out.push("");
|
|
1000
|
+
out.push(" case \"$state\" in");
|
|
1001
|
+
out.push(" cmds)");
|
|
1002
|
+
out.push(" local -a subcmds");
|
|
1003
|
+
out.push(" subcmds=(");
|
|
1004
|
+
for (const sub of node.subCommands) {
|
|
1005
|
+
const desc = zshDescribeField(sub.description ?? "");
|
|
1006
|
+
out.push(`\t\t\t\t${bashSingleQuote(`${zshDescribeField(sub.name)}:${desc}`)}`);
|
|
1007
|
+
if (sub.aliases !== void 0) for (const alias of sub.aliases) out.push(`\t\t\t\t${bashSingleQuote(`${zshDescribeField(alias)}:${desc}`)}`);
|
|
1008
|
+
}
|
|
1009
|
+
out.push(" )");
|
|
1010
|
+
out.push(" _describe 'subcommand' subcmds");
|
|
1011
|
+
out.push(" ;;");
|
|
1012
|
+
out.push(" args)");
|
|
1013
|
+
out.push(" case \"$line[1]\" in");
|
|
1014
|
+
for (const sub of node.subCommands) {
|
|
1015
|
+
const childFn = helperName(rootIdent, [...path, sub.name]);
|
|
1016
|
+
const alts = [sub.name, ...sub.aliases ?? []].map(bashSingleQuote).join("|");
|
|
1017
|
+
out.push(`\t\t\t\t${alts})`);
|
|
1018
|
+
out.push(`\t\t\t\t\t${childFn}`);
|
|
1019
|
+
out.push(" ;;");
|
|
1020
|
+
}
|
|
1021
|
+
out.push(" esac");
|
|
1022
|
+
out.push(" ;;");
|
|
1023
|
+
out.push(" esac");
|
|
1024
|
+
} else if (flagSpecLines.length === 0 && argSpecLines.length === 0) out.push(" :");
|
|
1025
|
+
else {
|
|
1026
|
+
out.push(" _arguments \\");
|
|
1027
|
+
const allSpecs = [...flagSpecLines, ...argSpecLines];
|
|
1028
|
+
allSpecs.forEach((spec, idx) => {
|
|
1029
|
+
const trailing = idx === allSpecs.length - 1 ? "" : " \\";
|
|
1030
|
+
out.push(`\t\t${spec}${trailing}`);
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
out.push("}");
|
|
1034
|
+
out.push("");
|
|
1035
|
+
for (const sub of node.subCommands) renderHelper(rootIdent, [...path, sub.name], sub, out);
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Render a self-contained zsh completion script for the given spec.
|
|
1039
|
+
*
|
|
1040
|
+
* The script is safe to drop into `$fpath` as `_<bin>` (autoloaded via the
|
|
1041
|
+
* `#compdef` magic line) AND safe to source inline via
|
|
1042
|
+
* `eval "$(mycli completion zsh)"` — the trailing `_<bin> "$@"` makes the
|
|
1043
|
+
* inline form actually invoke completion when the file is sourced.
|
|
1044
|
+
*
|
|
1045
|
+
* @param spec Walker output.
|
|
1046
|
+
* @param binName User-facing binary name; validated upstream via
|
|
1047
|
+
* {@link assertSafeBinName}.
|
|
1048
|
+
* @param version Free-form version string for the header comment.
|
|
1049
|
+
*/
|
|
1050
|
+
function renderZsh(spec, binName, version) {
|
|
1051
|
+
const ident = toShellIdent(binName);
|
|
1052
|
+
const lines = [];
|
|
1053
|
+
lines.push(`#compdef ${binName}`);
|
|
1054
|
+
lines.push(`# completion script for ${binName} v${version} — regenerate with: ${binName} completion zsh`);
|
|
1055
|
+
lines.push("");
|
|
1056
|
+
const helpers = [];
|
|
1057
|
+
renderHelper(ident, [], spec, helpers);
|
|
1058
|
+
lines.push(...helpers);
|
|
1059
|
+
lines.push(`if [ "$funcstack[1]" = "_${ident}" ]; then`);
|
|
1060
|
+
lines.push(`\t_${ident} "$@"`);
|
|
1061
|
+
lines.push("else");
|
|
1062
|
+
lines.push(`\tcompdef _${ident} ${bashSingleQuote(binName)}`);
|
|
1063
|
+
lines.push("fi");
|
|
1064
|
+
return `${lines.join("\n")}\n`;
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region src/completion/walker.ts
|
|
1068
|
+
/**
|
|
1069
|
+
* Normalise an optional description: strip ANSI, then drop empty results.
|
|
1070
|
+
* Returning `undefined` (rather than `""`) makes templates' presence checks
|
|
1071
|
+
* easy and keeps generated scripts tidy.
|
|
1072
|
+
*/
|
|
1073
|
+
function normaliseDescription(value) {
|
|
1074
|
+
if (value === void 0) return void 0;
|
|
1075
|
+
const stripped = sanitizeFreeText(stripVTControlCharacters(value)).trim();
|
|
1076
|
+
return stripped.length === 0 ? void 0 : stripped;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Project a single documentation flag onto a `CompletionFlag`.
|
|
1080
|
+
*/
|
|
1081
|
+
function walkFlag(def) {
|
|
1082
|
+
assertSafeIdentifier(def.name, "flag name");
|
|
1083
|
+
const aliases = def.aliases.filter((alias) => alias.length > 0);
|
|
1084
|
+
for (const alias of aliases) assertSafeIdentifier(alias, "flag alias");
|
|
1085
|
+
if (def.short !== void 0 && def.short.length > 0) assertSafeIdentifier(def.short, "flag short alias");
|
|
1086
|
+
const description = normaliseDescription(def.description);
|
|
1087
|
+
const common = {
|
|
1088
|
+
name: def.name,
|
|
1089
|
+
...def.short !== void 0 && def.short.length > 0 ? { short: def.short } : {},
|
|
1090
|
+
...aliases.length > 0 ? { aliases } : {},
|
|
1091
|
+
...description === void 0 ? {} : { description },
|
|
1092
|
+
...def.multiple ? { multiple: true } : {},
|
|
1093
|
+
negatable: def.negatable
|
|
1094
|
+
};
|
|
1095
|
+
if (def.type === "boolean") return {
|
|
1096
|
+
...common,
|
|
1097
|
+
type: "boolean",
|
|
1098
|
+
takesValue: false
|
|
1099
|
+
};
|
|
1100
|
+
if (def.type === "number") return {
|
|
1101
|
+
...common,
|
|
1102
|
+
type: "number",
|
|
1103
|
+
takesValue: true
|
|
1104
|
+
};
|
|
1105
|
+
if (def.type === "path") return {
|
|
1106
|
+
...common,
|
|
1107
|
+
type: "string",
|
|
1108
|
+
takesValue: true,
|
|
1109
|
+
valueCompletion: "files"
|
|
1110
|
+
};
|
|
1111
|
+
if (def.type === "url" || def.type === "json") return {
|
|
1112
|
+
...common,
|
|
1113
|
+
type: "string",
|
|
1114
|
+
takesValue: true,
|
|
1115
|
+
valueCompletion: "none"
|
|
1116
|
+
};
|
|
1117
|
+
const choices = def.choices;
|
|
1118
|
+
if (choices !== void 0 && choices.length > 0) return {
|
|
1119
|
+
...common,
|
|
1120
|
+
type: "string",
|
|
1121
|
+
takesValue: true,
|
|
1122
|
+
choices: choices.map(assertSafeChoiceValue)
|
|
1123
|
+
};
|
|
1124
|
+
return {
|
|
1125
|
+
...common,
|
|
1126
|
+
type: "string",
|
|
1127
|
+
takesValue: true
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
/** Project a single documentation argument onto a `CompletionArg`. */
|
|
1131
|
+
function walkArg(def) {
|
|
1132
|
+
assertSafeIdentifier(def.name, "arg name");
|
|
1133
|
+
const description = normaliseDescription(def.description);
|
|
1134
|
+
const common = {
|
|
1135
|
+
name: def.name,
|
|
1136
|
+
required: def.required,
|
|
1137
|
+
variadic: def.variadic,
|
|
1138
|
+
...description === void 0 ? {} : { description }
|
|
1139
|
+
};
|
|
1140
|
+
if (def.type === "number" || def.type === "boolean") return {
|
|
1141
|
+
...common,
|
|
1142
|
+
type: def.type
|
|
1143
|
+
};
|
|
1144
|
+
if (def.type === "path") return {
|
|
1145
|
+
...common,
|
|
1146
|
+
type: "string",
|
|
1147
|
+
valueCompletion: "files"
|
|
1148
|
+
};
|
|
1149
|
+
if (def.type === "url" || def.type === "json") return {
|
|
1150
|
+
...common,
|
|
1151
|
+
type: "string",
|
|
1152
|
+
valueCompletion: "none"
|
|
1153
|
+
};
|
|
1154
|
+
const choices = def.type === "string" ? def.choices : void 0;
|
|
1155
|
+
if (choices !== void 0 && choices.length > 0) return {
|
|
1156
|
+
...common,
|
|
1157
|
+
type: "string",
|
|
1158
|
+
choices: choices.map(assertSafeChoiceValue)
|
|
1159
|
+
};
|
|
1160
|
+
return {
|
|
1161
|
+
...common,
|
|
1162
|
+
type: "string"
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Build a completion command from the shared documentation model.
|
|
1167
|
+
*/
|
|
1168
|
+
function walkCommandNode(node) {
|
|
1169
|
+
assertSafeIdentifier(node.name, "command name");
|
|
1170
|
+
for (const alias of node.aliases) assertSafeIdentifier(alias, "command alias");
|
|
1171
|
+
const flags = node.flags.map(walkFlag);
|
|
1172
|
+
const args = node.args.map(walkArg);
|
|
1173
|
+
const subCommands = node.children.map(walkCommandNode);
|
|
1174
|
+
const result = {
|
|
1175
|
+
name: node.name,
|
|
1176
|
+
flags,
|
|
1177
|
+
args,
|
|
1178
|
+
subCommands
|
|
1179
|
+
};
|
|
1180
|
+
if (node.aliases.length > 0) result.aliases = node.aliases;
|
|
1181
|
+
const description = normaliseDescription(node.description);
|
|
1182
|
+
if (description !== void 0) result.description = description;
|
|
1183
|
+
return result;
|
|
1184
|
+
}
|
|
1185
|
+
//#endregion
|
|
1186
|
+
//#region src/completion/index.ts
|
|
1187
|
+
const COMPLETION = defineExtensionId("crust:completion");
|
|
1188
|
+
const SUPPORTED_SHELLS = [
|
|
1189
|
+
"bash",
|
|
1190
|
+
"zsh",
|
|
1191
|
+
"fish"
|
|
1192
|
+
];
|
|
1193
|
+
/** Filename convention for each shell's drop-in completion file. */
|
|
1194
|
+
function filenameForShell(shell, binName) {
|
|
1195
|
+
switch (shell) {
|
|
1196
|
+
case "bash": return binName;
|
|
1197
|
+
case "zsh": return `_${binName}`;
|
|
1198
|
+
case "fish": return `${binName}.fish`;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
const SHELL_RENDERERS = {
|
|
1202
|
+
bash: renderBash,
|
|
1203
|
+
zsh: renderZsh,
|
|
1204
|
+
fish: renderFish
|
|
1205
|
+
};
|
|
1206
|
+
function prepareRender(root, options) {
|
|
1207
|
+
const binName = assertSafeBinName(options.binName ?? root.meta.name);
|
|
1208
|
+
const version = options.version ?? root.meta.version;
|
|
1209
|
+
if (version === void 0) throw new CrustError("DEFINITION", "The completion extension requires a version in new Crust(name, { version }) or completion({ version })");
|
|
1210
|
+
return {
|
|
1211
|
+
spec: walkCommandNode(buildCommandDocumentation(root)),
|
|
1212
|
+
binName,
|
|
1213
|
+
version: sanitizeFreeText(version)
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
async function writeCompletionFiles(dir, root, options) {
|
|
1217
|
+
const { spec, binName, version } = prepareRender(root, options);
|
|
1218
|
+
await mkdir(dir, { recursive: true });
|
|
1219
|
+
const filenames = [];
|
|
1220
|
+
for (const shell of SUPPORTED_SHELLS) {
|
|
1221
|
+
const filename = filenameForShell(shell, binName);
|
|
1222
|
+
const script = SHELL_RENDERERS[shell](spec, binName, version);
|
|
1223
|
+
await writeFile(join(dir, filename), script, "utf8");
|
|
1224
|
+
filenames.push(filename);
|
|
1225
|
+
}
|
|
1226
|
+
return filenames;
|
|
1227
|
+
}
|
|
1228
|
+
function renderCompletionScript(shell, root, options = {}) {
|
|
1229
|
+
const { spec, binName, version } = prepareRender(root, options);
|
|
1230
|
+
return SHELL_RENDERERS[shell](spec, binName, version);
|
|
1231
|
+
}
|
|
1232
|
+
/** Render a bash completion script from a prepared root Command Snapshot. */
|
|
1233
|
+
function renderBashCompletion(root, options) {
|
|
1234
|
+
return renderCompletionScript("bash", root, options);
|
|
1235
|
+
}
|
|
1236
|
+
/** Render a zsh completion script from a prepared root Command Snapshot. */
|
|
1237
|
+
function renderZshCompletion(root, options) {
|
|
1238
|
+
return renderCompletionScript("zsh", root, options);
|
|
1239
|
+
}
|
|
1240
|
+
/** Render a fish completion script from a prepared root Command Snapshot. */
|
|
1241
|
+
function renderFishCompletion(root, options) {
|
|
1242
|
+
return renderCompletionScript("fish", root, options);
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Build an Extension that contributes a `completion <shell>` command
|
|
1246
|
+
* which emits a tab-completion script for bash, zsh, or fish.
|
|
1247
|
+
*
|
|
1248
|
+
* **Strategy: pure-static.** The action walks the final root snapshot, so
|
|
1249
|
+
* registration order is irrelevant — any commands or recursive flags added
|
|
1250
|
+
* by other Extensions are visible by the time we generate the script. The
|
|
1251
|
+
* walker projects Core's documentation model to a small completion model; per-shell
|
|
1252
|
+
* renderers turn that into a self-contained shell script with no runtime
|
|
1253
|
+
* callbacks.
|
|
1254
|
+
*
|
|
1255
|
+
* **Print vs `--output-dir`.**
|
|
1256
|
+
* - With no `--output-dir`: print the script for the requested `<shell>`
|
|
1257
|
+
* to stdout (the install pattern is
|
|
1258
|
+
* `mycli completion bash > ~/.local/share/...`).
|
|
1259
|
+
* - With `--output-dir <path>`: write **all** supported shells' files
|
|
1260
|
+
* into the directory using the canonical per-shell filename
|
|
1261
|
+
* (`<bin>` for bash, `_<bin>` for zsh, `<bin>.fish` for fish). This
|
|
1262
|
+
* is the artifact-generation path used by Homebrew, Nix, and similar
|
|
1263
|
+
* distribution channels — distributors run it once at packaging time
|
|
1264
|
+
* and the resulting files become drop-ins.
|
|
1265
|
+
*
|
|
1266
|
+
* **Build hook.** `crust build` writes the same three files under
|
|
1267
|
+
* `<outDir>/completions/`; `--package` stages that directory. The binary name
|
|
1268
|
+
* defaults to the snapshot's `meta.name`, unless `options.binName` is set.
|
|
1269
|
+
*/
|
|
1270
|
+
const completion = defineExtension(COMPLETION, (options = {}) => {
|
|
1271
|
+
const subcommandName = options.command ?? "completion";
|
|
1272
|
+
return {
|
|
1273
|
+
commands: [defineCommand(subcommandName, { description: "Generate shell tab-completion scripts" }, (cmd) => cmd.args({
|
|
1274
|
+
name: "shell",
|
|
1275
|
+
type: "string",
|
|
1276
|
+
required: true,
|
|
1277
|
+
description: "Shell to generate completion for",
|
|
1278
|
+
choices: SUPPORTED_SHELLS
|
|
1279
|
+
}).flags({
|
|
1280
|
+
name: "output-dir",
|
|
1281
|
+
type: "string",
|
|
1282
|
+
description: "Write all supported shells' scripts into this directory instead of printing to stdout"
|
|
1283
|
+
}).action(async (context) => {
|
|
1284
|
+
const outputDir = context.flags["output-dir"];
|
|
1285
|
+
if (outputDir === void 0) {
|
|
1286
|
+
context.stdout(renderCompletionScript(context.args.shell, context.rootCommand, options));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
await writeCompletionFiles(resolve(outputDir), context.rootCommand, options);
|
|
1290
|
+
}))],
|
|
1291
|
+
build: async ({ snapshot, outDir }) => {
|
|
1292
|
+
const dir = join(outDir, "completions");
|
|
1293
|
+
await mkdir(outDir, { recursive: true });
|
|
1294
|
+
const stagedDir = await mkdtemp(join(outDir, ".completions-"));
|
|
1295
|
+
try {
|
|
1296
|
+
const filenames = await writeCompletionFiles(stagedDir, snapshot, options);
|
|
1297
|
+
await rm(dir, {
|
|
1298
|
+
recursive: true,
|
|
1299
|
+
force: true
|
|
1300
|
+
});
|
|
1301
|
+
await rename(stagedDir, dir);
|
|
1302
|
+
return filenames.map((filename) => join("completions", filename));
|
|
1303
|
+
} finally {
|
|
1304
|
+
await rm(stagedDir, {
|
|
1305
|
+
recursive: true,
|
|
1306
|
+
force: true
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
});
|
|
1312
|
+
//#endregion
|
|
1313
|
+
//#region src/help.ts
|
|
1314
|
+
const FLAG_COLUMN_WIDTH = 28;
|
|
1315
|
+
const ARG_COLUMN_WIDTH = 18;
|
|
1316
|
+
const COMMAND_COLUMN_WIDTH = 10;
|
|
1317
|
+
const HELP = defineExtensionId("crust:help");
|
|
1318
|
+
function formatArgToken(arg) {
|
|
1319
|
+
return arg.required ? yellow(arg.token) : dim(yellow(arg.token));
|
|
1320
|
+
}
|
|
1321
|
+
function formatUsageSegment(segment) {
|
|
1322
|
+
switch (segment.kind) {
|
|
1323
|
+
case "path":
|
|
1324
|
+
case "custom": return green(segment.text);
|
|
1325
|
+
case "command":
|
|
1326
|
+
case "options": return cyan(segment.text);
|
|
1327
|
+
case "arg": return segment.required ? yellow(segment.text) : dim(yellow(segment.text));
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function formatFlagsSection(flags) {
|
|
1331
|
+
if (flags.length === 0) return [];
|
|
1332
|
+
const lines = [bold(cyan("Options:"))];
|
|
1333
|
+
for (const flag of flags) {
|
|
1334
|
+
const rendered = `${padEnd(cyan(flag.spellings.join(", ")), FLAG_COLUMN_WIDTH, " ")} `;
|
|
1335
|
+
lines.push(` ${rendered}${formatDescription(flag.description, flag.default, flag.choices, dim)}`.trimEnd());
|
|
1336
|
+
}
|
|
1337
|
+
return lines;
|
|
1338
|
+
}
|
|
1339
|
+
function formatArgsSection(command) {
|
|
1340
|
+
if (command.args.length === 0) return [];
|
|
1341
|
+
const lines = [bold(cyan("Arguments:"))];
|
|
1342
|
+
for (const arg of command.args) {
|
|
1343
|
+
const rendered = `${padEnd(formatArgToken(arg), ARG_COLUMN_WIDTH, " ")} `;
|
|
1344
|
+
lines.push(` ${rendered}${formatDescription(arg.description, arg.default, arg.choices, dim)}`.trimEnd());
|
|
1345
|
+
}
|
|
1346
|
+
return lines;
|
|
1347
|
+
}
|
|
1348
|
+
function formatCommandLabel(command) {
|
|
1349
|
+
const name = green(command.name);
|
|
1350
|
+
return command.aliases.length === 0 ? name : `${name} (${command.aliases.join(", ")})`;
|
|
1351
|
+
}
|
|
1352
|
+
function formatCommandsSection(command) {
|
|
1353
|
+
if (command.children.length === 0) return [];
|
|
1354
|
+
const lines = [bold(cyan("Commands:"))];
|
|
1355
|
+
for (const child of command.children) {
|
|
1356
|
+
const rendered = `${padEnd(formatCommandLabel(child), COMMAND_COLUMN_WIDTH, " ")} `;
|
|
1357
|
+
lines.push(` ${rendered}${child.description ?? ""}`.trimEnd());
|
|
1358
|
+
}
|
|
1359
|
+
return lines;
|
|
1360
|
+
}
|
|
1361
|
+
function renderHelp(command, path) {
|
|
1362
|
+
const model = buildCommandDocumentation(command, path);
|
|
1363
|
+
const heading = model.path.join(" ");
|
|
1364
|
+
const lines = [
|
|
1365
|
+
model.description ? `${bold(heading)} - ${dim(model.description)}` : bold(heading),
|
|
1366
|
+
"",
|
|
1367
|
+
bold(cyan("Usage:")),
|
|
1368
|
+
` ${model.usageSegments.map(formatUsageSegment).join(" ")}`
|
|
1369
|
+
];
|
|
1370
|
+
for (const section of [
|
|
1371
|
+
formatCommandsSection(model),
|
|
1372
|
+
formatArgsSection(model),
|
|
1373
|
+
formatFlagsSection(model.flags)
|
|
1374
|
+
]) if (section.length > 0) lines.push("", ...section);
|
|
1375
|
+
for (const section of sectionsFor(model.sections, HELP)) lines.push("", bold(cyan(`${section.title}:`)), ...section.body.split("\n").map((l) => ` ${l}`));
|
|
1376
|
+
return lines.join("\n");
|
|
1377
|
+
}
|
|
1378
|
+
const helpFlags = [{
|
|
1379
|
+
name: "help",
|
|
1380
|
+
type: "boolean",
|
|
1381
|
+
short: "h",
|
|
1382
|
+
noNegate: true,
|
|
1383
|
+
description: "Show help"
|
|
1384
|
+
}];
|
|
1385
|
+
const help = defineExtension(HELP, () => ({
|
|
1386
|
+
flags: helpFlags,
|
|
1387
|
+
hooks: { preRun(context) {
|
|
1388
|
+
if (context.flags.help !== true && context.command.hasAction) return;
|
|
1389
|
+
context.stdout(renderHelp(context.command, context.commandPath));
|
|
1390
|
+
return context.finish();
|
|
1391
|
+
} }
|
|
1392
|
+
}));
|
|
1393
|
+
//#endregion
|
|
1394
|
+
//#region src/did-you-mean.ts
|
|
1395
|
+
const DID_YOU_MEAN = defineExtensionId("crust:did-you-mean");
|
|
1396
|
+
function levenshtein(a, b) {
|
|
1397
|
+
const aLen = a.length;
|
|
1398
|
+
const bLen = b.length;
|
|
1399
|
+
if (aLen === 0) return bLen;
|
|
1400
|
+
if (bLen === 0) return aLen;
|
|
1401
|
+
const row = Uint32Array.from({ length: bLen + 1 }, (_, i) => i);
|
|
1402
|
+
for (let i = 1; i <= aLen; i++) {
|
|
1403
|
+
let prev = i;
|
|
1404
|
+
for (let j = 1; j <= bLen; j++) {
|
|
1405
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1406
|
+
const val = Math.min(row[j] + 1, prev + 1, row[j - 1] + cost);
|
|
1407
|
+
row[j - 1] = prev;
|
|
1408
|
+
prev = val;
|
|
1409
|
+
}
|
|
1410
|
+
row[bLen] = prev;
|
|
1411
|
+
}
|
|
1412
|
+
return row[bLen];
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Find canonical-name suggestions for `input` by matching against every
|
|
1416
|
+
* sibling's canonical name **and** any aliases declared on each sibling.
|
|
1417
|
+
*
|
|
1418
|
+
* Matched aliases are mapped back to their canonical, so suggestions only
|
|
1419
|
+
* ever report canonical names — mirroring `router.ts`, which records
|
|
1420
|
+
* canonicals on `commandPath`. When both a canonical and its alias score
|
|
1421
|
+
* within threshold, the better score wins for that command (a short alias
|
|
1422
|
+
* cannot lose to a more-distant canonical, and vice-versa).
|
|
1423
|
+
*
|
|
1424
|
+
* Subcommands marked `meta.hidden: true` are excluded from the candidate
|
|
1425
|
+
* set so internal commands (e.g. `__complete`) cannot leak into
|
|
1426
|
+
* user-facing typo suggestions. They remain invocable by direct name —
|
|
1427
|
+
* routing does not consult `meta.hidden`.
|
|
1428
|
+
*
|
|
1429
|
+
* The matching is limited to: (a) `candidate.startsWith(input)` (a
|
|
1430
|
+
* forward-completion hint, useful when the user typed a prefix) and
|
|
1431
|
+
* (b) Levenshtein distance ≤ 3. The reverse `input.startsWith(candidate)`
|
|
1432
|
+
* shortcut is intentionally omitted: with aliases in the candidate set,
|
|
1433
|
+
* any 1–2 char alias would falsely match every typo as distance 0.
|
|
1434
|
+
*/
|
|
1435
|
+
function findSuggestions(input, subCommands) {
|
|
1436
|
+
const best = /* @__PURE__ */ new Map();
|
|
1437
|
+
const score = (text) => {
|
|
1438
|
+
if (text.startsWith(input)) return 0;
|
|
1439
|
+
const d = levenshtein(input, text);
|
|
1440
|
+
return d <= 3 ? d : null;
|
|
1441
|
+
};
|
|
1442
|
+
const record = (canonical, distance) => {
|
|
1443
|
+
const prev = best.get(canonical);
|
|
1444
|
+
if (prev === void 0 || distance < prev) best.set(canonical, distance);
|
|
1445
|
+
};
|
|
1446
|
+
for (const [name, node] of Object.entries(subCommands)) {
|
|
1447
|
+
if (!isListed(node)) continue;
|
|
1448
|
+
const d = score(name);
|
|
1449
|
+
if (d !== null) record(name, d);
|
|
1450
|
+
for (const alias of node.meta.aliases ?? []) {
|
|
1451
|
+
const da = score(alias);
|
|
1452
|
+
if (da !== null) record(name, da);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return [...best.entries()].sort(([aName, aDist], [bName, bDist]) => aDist !== bDist ? aDist - bDist : aName.localeCompare(bName)).map(([name]) => name);
|
|
1456
|
+
}
|
|
1457
|
+
const didYouMean = defineExtension(DID_YOU_MEAN, (options = {}) => {
|
|
1458
|
+
const mode = options.mode ?? "error";
|
|
1459
|
+
return { hooks: { onError(error, context) {
|
|
1460
|
+
if (!(error instanceof CrustError) || !error.is("COMMAND_NOT_FOUND")) return;
|
|
1461
|
+
const details = error.details;
|
|
1462
|
+
const suggestions = findSuggestions(details.input, details.parentCommand.subCommands);
|
|
1463
|
+
let message = `Unknown command "${details.input}".`;
|
|
1464
|
+
if (suggestions.length > 0) message += ` Did you mean "${suggestions[0]}"?`;
|
|
1465
|
+
if (mode === "help") {
|
|
1466
|
+
context.stdout(message);
|
|
1467
|
+
context.stdout("");
|
|
1468
|
+
context.stdout(renderHelp(details.parentCommand, details.commandPath));
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
if (details.available.length > 0) message += `\n\nAvailable commands: ${details.available.join(", ")}`;
|
|
1472
|
+
context.stderr(message);
|
|
1473
|
+
return true;
|
|
1474
|
+
} } };
|
|
1475
|
+
});
|
|
1476
|
+
//#endregion
|
|
1477
|
+
//#region src/no-color.ts
|
|
1478
|
+
const NO_COLOR = defineExtensionId("crust:no-color");
|
|
1479
|
+
let activeRuns = 0;
|
|
1480
|
+
let baseForceColor;
|
|
1481
|
+
let baseNoColor;
|
|
1482
|
+
const colorRuns = /* @__PURE__ */ new WeakSet();
|
|
1483
|
+
const colorFlags = [{
|
|
1484
|
+
name: "color",
|
|
1485
|
+
type: "boolean",
|
|
1486
|
+
description: "Enable colored output"
|
|
1487
|
+
}];
|
|
1488
|
+
/**
|
|
1489
|
+
* Adds a recursive `--color` / `--no-color` flag pair that scopes the
|
|
1490
|
+
* standard color environment variables around command execution:
|
|
1491
|
+
*
|
|
1492
|
+
* - `--color` sets `FORCE_COLOR=3` (and clears `NO_COLOR`, so strict
|
|
1493
|
+
* no-color.org-only child processes also comply) — forces all ANSI on
|
|
1494
|
+
* (truecolor), overriding non-TTY detection. Any color library that
|
|
1495
|
+
* honors `FORCE_COLOR` (including `@crustjs/style` and chalk) obeys it,
|
|
1496
|
+
* and child processes inherit it.
|
|
1497
|
+
* - `--no-color` sets `NO_COLOR=1` (and clears `FORCE_COLOR` so the flag
|
|
1498
|
+
* wins over ambient env) — suppresses colors while non-color modifiers
|
|
1499
|
+
* and hyperlinks keep following TTY detection, per
|
|
1500
|
+
* [no-color.org](https://no-color.org/).
|
|
1501
|
+
*
|
|
1502
|
+
* Previous values are restored after the command finishes. When overlapping
|
|
1503
|
+
* programmatic runs in one process use opposite flags, the later run wins
|
|
1504
|
+
* mid-flight (the env is process-global); the ambient values are restored
|
|
1505
|
+
* once all runs finish.
|
|
1506
|
+
*/
|
|
1507
|
+
const noColor = defineExtension(NO_COLOR, () => ({
|
|
1508
|
+
flags: colorFlags,
|
|
1509
|
+
hooks: {
|
|
1510
|
+
preRun(context) {
|
|
1511
|
+
const flagValue = context.flags.color;
|
|
1512
|
+
if (flagValue !== true && flagValue !== false) return;
|
|
1513
|
+
if (activeRuns === 0) {
|
|
1514
|
+
baseForceColor = process.env.FORCE_COLOR;
|
|
1515
|
+
baseNoColor = process.env.NO_COLOR;
|
|
1516
|
+
}
|
|
1517
|
+
activeRuns++;
|
|
1518
|
+
colorRuns.add(context);
|
|
1519
|
+
if (flagValue) {
|
|
1520
|
+
delete process.env.NO_COLOR;
|
|
1521
|
+
process.env.FORCE_COLOR = "3";
|
|
1522
|
+
} else {
|
|
1523
|
+
delete process.env.FORCE_COLOR;
|
|
1524
|
+
process.env.NO_COLOR = "1";
|
|
1525
|
+
}
|
|
1526
|
+
},
|
|
1527
|
+
postRun(context) {
|
|
1528
|
+
if (!colorRuns.has(context)) return;
|
|
1529
|
+
colorRuns.delete(context);
|
|
1530
|
+
activeRuns--;
|
|
1531
|
+
if (activeRuns === 0) {
|
|
1532
|
+
if (baseForceColor === void 0) delete process.env.FORCE_COLOR;
|
|
1533
|
+
else process.env.FORCE_COLOR = baseForceColor;
|
|
1534
|
+
if (baseNoColor === void 0) delete process.env.NO_COLOR;
|
|
1535
|
+
else process.env.NO_COLOR = baseNoColor;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}));
|
|
1540
|
+
//#endregion
|
|
1541
|
+
//#region ../utils/src/process.ts
|
|
1542
|
+
/** Parse the package manager from npm's user-agent environment value. */
|
|
1543
|
+
function packageManagerFromUserAgent(userAgent) {
|
|
1544
|
+
if (userAgent?.startsWith("bun")) return "bun";
|
|
1545
|
+
if (userAgent?.startsWith("pnpm")) return "pnpm";
|
|
1546
|
+
if (userAgent?.startsWith("yarn")) return "yarn";
|
|
1547
|
+
if (userAgent?.startsWith("npm")) return "npm";
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
//#endregion
|
|
1551
|
+
//#region src/update-notifier.ts
|
|
1552
|
+
const UPDATE_NOTIFIER = defineExtensionId("crust:update-notifier");
|
|
1553
|
+
/** Default check interval: 24 hours. */
|
|
1554
|
+
const DEFAULT_INTERVAL_MS = 864e5;
|
|
1555
|
+
/** Default network timeout: 5 seconds. */
|
|
1556
|
+
const DEFAULT_TIMEOUT_MS = 5e3;
|
|
1557
|
+
/** Default npm registry URL. */
|
|
1558
|
+
const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
|
|
1559
|
+
function compareSemver(left, right) {
|
|
1560
|
+
const parse = (version) => {
|
|
1561
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
|
|
1562
|
+
if (!match) throw new TypeError(`Invalid semantic version: ${version}`);
|
|
1563
|
+
return {
|
|
1564
|
+
core: match.slice(1, 4).map(Number),
|
|
1565
|
+
prerelease: match[4]?.split(".") ?? []
|
|
1566
|
+
};
|
|
1567
|
+
};
|
|
1568
|
+
const a = parse(left);
|
|
1569
|
+
const b = parse(right);
|
|
1570
|
+
for (let index = 0; index < 3; index++) if (a.core[index] !== b.core[index]) return a.core[index] < b.core[index] ? -1 : 1;
|
|
1571
|
+
if (a.prerelease.length === 0 || b.prerelease.length === 0) return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
|
|
1572
|
+
for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) {
|
|
1573
|
+
const x = a.prerelease[index];
|
|
1574
|
+
const y = b.prerelease[index];
|
|
1575
|
+
if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
|
|
1576
|
+
if (x === y) continue;
|
|
1577
|
+
const xNumeric = /^\d+$/.test(x);
|
|
1578
|
+
const yNumeric = /^\d+$/.test(y);
|
|
1579
|
+
if (xNumeric && yNumeric) return Number(x) < Number(y) ? -1 : 1;
|
|
1580
|
+
if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
|
|
1581
|
+
return x < y ? -1 : 1;
|
|
1582
|
+
}
|
|
1583
|
+
return 0;
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Returns whether `latest` is newer, or false when either version is invalid.
|
|
1587
|
+
*
|
|
1588
|
+
* @internal
|
|
1589
|
+
*/
|
|
1590
|
+
function isNewerVersion(current, latest) {
|
|
1591
|
+
try {
|
|
1592
|
+
return compareSemver(latest, current) === 1;
|
|
1593
|
+
} catch {
|
|
1594
|
+
return false;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
function hasLatestDistTag(value) {
|
|
1598
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || !("dist-tags" in value)) return false;
|
|
1599
|
+
const tags = value["dist-tags"];
|
|
1600
|
+
return typeof tags === "object" && tags !== null && !Array.isArray(tags) && "latest" in tags && typeof tags.latest === "string" && tags.latest.length > 0;
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Fetch the `dist-tags.latest` version string for a package from an npm
|
|
1604
|
+
* registry.
|
|
1605
|
+
*
|
|
1606
|
+
* Uses the platform timeout signal so network stalls cannot hang the CLI process.
|
|
1607
|
+
*
|
|
1608
|
+
* Returns `null` on any failure (network error, timeout, non-OK status,
|
|
1609
|
+
* missing/malformed response body).
|
|
1610
|
+
*
|
|
1611
|
+
* @internal
|
|
1612
|
+
*/
|
|
1613
|
+
async function fetchLatestVersion(packageName, registryUrl, timeoutMs) {
|
|
1614
|
+
try {
|
|
1615
|
+
const url = `${registryUrl.replace(/\/+$/, "")}/${encodeURIComponent(packageName)}`;
|
|
1616
|
+
const response = await fetch(url, {
|
|
1617
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1618
|
+
headers: { Accept: "application/vnd.npm.install-v1+json" }
|
|
1619
|
+
});
|
|
1620
|
+
if (!response.ok) return null;
|
|
1621
|
+
const data = await response.json();
|
|
1622
|
+
if (!hasLatestDistTag(data)) return null;
|
|
1623
|
+
return data["dist-tags"].latest;
|
|
1624
|
+
} catch {
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
function normalizeNotifierState(input) {
|
|
1629
|
+
if (!input) return { lastCheckedAt: 0 };
|
|
1630
|
+
return {
|
|
1631
|
+
lastCheckedAt: Number.isFinite(input.lastCheckedAt) ? input.lastCheckedAt : 0,
|
|
1632
|
+
latestVersion: input.latestVersion?.length ? input.latestVersion : void 0,
|
|
1633
|
+
lastNotifiedVersion: input.lastNotifiedVersion?.length ? input.lastNotifiedVersion : void 0
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
const NO_CACHE_ADAPTER = {
|
|
1637
|
+
read: async () => null,
|
|
1638
|
+
write: async () => {}
|
|
1639
|
+
};
|
|
1640
|
+
/** @internal */
|
|
1641
|
+
async function createStoreCacheAdapter(packageName, registryUrl) {
|
|
1642
|
+
const { createStore, stateDir } = await import("@crustjs/store");
|
|
1643
|
+
const store = createStore({
|
|
1644
|
+
dirPath: stateDir(encodeURIComponent(packageName)),
|
|
1645
|
+
name: "update-notifier",
|
|
1646
|
+
fields: {
|
|
1647
|
+
lastCheckedAt: {
|
|
1648
|
+
type: "number",
|
|
1649
|
+
default: 0
|
|
1650
|
+
},
|
|
1651
|
+
latestVersion: { type: "string" },
|
|
1652
|
+
lastNotifiedVersion: { type: "string" },
|
|
1653
|
+
registryUrl: { type: "string" }
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
return {
|
|
1657
|
+
read: async () => {
|
|
1658
|
+
const state = await store.read();
|
|
1659
|
+
if (state.registryUrl !== registryUrl) return null;
|
|
1660
|
+
return {
|
|
1661
|
+
lastCheckedAt: state.lastCheckedAt,
|
|
1662
|
+
latestVersion: state.latestVersion,
|
|
1663
|
+
lastNotifiedVersion: state.lastNotifiedVersion
|
|
1664
|
+
};
|
|
1665
|
+
},
|
|
1666
|
+
write: async (state) => {
|
|
1667
|
+
await store.write({
|
|
1668
|
+
lastCheckedAt: state.lastCheckedAt,
|
|
1669
|
+
latestVersion: state.latestVersion,
|
|
1670
|
+
lastNotifiedVersion: state.lastNotifiedVersion,
|
|
1671
|
+
registryUrl
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
function detectPackageManager() {
|
|
1677
|
+
const detectedFromUserAgent = packageManagerFromUserAgent(process.env.npm_config_user_agent);
|
|
1678
|
+
if (detectedFromUserAgent) return detectedFromUserAgent;
|
|
1679
|
+
const detectedFromExecPath = detectPackageManagerFromExecPath(process.env.npm_execpath);
|
|
1680
|
+
if (detectedFromExecPath) return detectedFromExecPath;
|
|
1681
|
+
const detectedFromRuntime = detectPackageManagerFromExecPath(process.execPath);
|
|
1682
|
+
if (detectedFromRuntime) return detectedFromRuntime;
|
|
1683
|
+
return "npm";
|
|
1684
|
+
}
|
|
1685
|
+
function detectPackageManagerFromExecPath(execPath) {
|
|
1686
|
+
if (!execPath) return null;
|
|
1687
|
+
const executable = basename(execPath).toLowerCase();
|
|
1688
|
+
if (executable === "bun" || executable.startsWith("bun-")) return "bun";
|
|
1689
|
+
if (executable === "pnpm" || executable.startsWith("pnpm-")) return "pnpm";
|
|
1690
|
+
if (executable === "yarn" || executable.startsWith("yarn-")) return "yarn";
|
|
1691
|
+
if (executable === "npm" || executable.startsWith("npm-")) return "npm";
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
function defaultUpdateCommand(packageName, packageManager, scope) {
|
|
1695
|
+
if (packageManager === "pnpm") return scope === "global" ? `pnpm add -g ${packageName}@latest` : `pnpm add ${packageName}@latest`;
|
|
1696
|
+
if (packageManager === "yarn") return scope === "global" ? `npm install -g ${packageName}@latest` : `yarn add ${packageName}@latest`;
|
|
1697
|
+
if (packageManager === "bun") return scope === "global" ? `bun add -g ${packageName}@latest` : `bun add ${packageName}@latest`;
|
|
1698
|
+
return scope === "global" ? `npm install -g ${packageName}@latest` : `npm install ${packageName}@latest`;
|
|
1699
|
+
}
|
|
1700
|
+
function resolveUpdateCommand(packageName, updateCommand) {
|
|
1701
|
+
if (updateCommand === void 0 || typeof updateCommand === "string") return updateCommand;
|
|
1702
|
+
const packageManager = detectPackageManager();
|
|
1703
|
+
if (typeof updateCommand === "function") return updateCommand({
|
|
1704
|
+
packageName,
|
|
1705
|
+
packageManager
|
|
1706
|
+
});
|
|
1707
|
+
return defaultUpdateCommand(packageName, packageManager, updateCommand.scope);
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Creates an update notifier extension that checks the npm registry after a
|
|
1711
|
+
* successful command action and displays a notice when a newer version is available.
|
|
1712
|
+
*
|
|
1713
|
+
* **Behavior:**
|
|
1714
|
+
* - By default, checks are cached for 24 hours in the package's state directory.
|
|
1715
|
+
* - `cache: false` disables cross-run persistence.
|
|
1716
|
+
* - A custom cache adapter can override the built-in persistence.
|
|
1717
|
+
* - The notice is command-less unless `updateCommand` is configured.
|
|
1718
|
+
* - The postRun hook awaits the network check and cache reads and writes,
|
|
1719
|
+
* delaying invocation completion after the action.
|
|
1720
|
+
* - `timeoutMs` bounds only the network request, defaulting to 5 seconds.
|
|
1721
|
+
* - Network, cache, and parsing errors are silently swallowed. A missing
|
|
1722
|
+
* current version throws a DEFINITION error before that recovery block.
|
|
1723
|
+
* - Update notices are intentionally written to the invocation's stderr callback.
|
|
1724
|
+
* - Duplicate notifications for the same version are suppressed.
|
|
1725
|
+
*
|
|
1726
|
+
* @param options - Extension configuration. `packageName` is required.
|
|
1727
|
+
* @returns An Extension registered with `.extend()`.
|
|
1728
|
+
*
|
|
1729
|
+
* @example
|
|
1730
|
+
* ```ts
|
|
1731
|
+
* import { Crust } from "@crustjs/core";
|
|
1732
|
+
* import { updateNotifier } from "@crustjs/extensions";
|
|
1733
|
+
*
|
|
1734
|
+
* const app = new Crust("my-cli", { description: "My awesome CLI", version: "1.2.3" })
|
|
1735
|
+
* .extend(updateNotifier({ packageName: "my-cli" }))
|
|
1736
|
+
* .action(() => {
|
|
1737
|
+
* console.log("Hello!");
|
|
1738
|
+
* });
|
|
1739
|
+
*
|
|
1740
|
+
* await app.execute();
|
|
1741
|
+
* ```
|
|
1742
|
+
*/
|
|
1743
|
+
const updateNotifier = defineExtension(UPDATE_NOTIFIER, (options) => {
|
|
1744
|
+
const { currentVersion, packageName, timeoutMs = DEFAULT_TIMEOUT_MS, registryUrl = DEFAULT_REGISTRY_URL, updateCommand, updateDocsUrl, cache } = options;
|
|
1745
|
+
const intervalMs = (cache === false ? void 0 : cache?.intervalMs) ?? DEFAULT_INTERVAL_MS;
|
|
1746
|
+
return { hooks: { async postRun(context, outcome) {
|
|
1747
|
+
if (outcome.status !== "completed") return;
|
|
1748
|
+
const resolvedCurrentVersion = currentVersion ?? context.rootCommand.meta.version;
|
|
1749
|
+
if (resolvedCurrentVersion === void 0) throw new CrustError("DEFINITION", "The update notifier extension requires a version in new Crust(name, { version }) or currentVersion");
|
|
1750
|
+
try {
|
|
1751
|
+
let cacheAdapter = NO_CACHE_ADAPTER;
|
|
1752
|
+
if (cache !== false) {
|
|
1753
|
+
if (cache?.adapter) cacheAdapter = cache.adapter;
|
|
1754
|
+
else cacheAdapter = await createStoreCacheAdapter(packageName, registryUrl);
|
|
1755
|
+
}
|
|
1756
|
+
const state = normalizeNotifierState(await cacheAdapter.read().catch(() => null));
|
|
1757
|
+
const resolvedUpdateCommand = resolveUpdateCommand(packageName, updateCommand);
|
|
1758
|
+
const now = Date.now();
|
|
1759
|
+
const elapsed = now - state.lastCheckedAt;
|
|
1760
|
+
if (cache !== false && elapsed >= 0 && elapsed < intervalMs) {
|
|
1761
|
+
if (state.latestVersion && isNewerVersion(resolvedCurrentVersion, state.latestVersion) && state.lastNotifiedVersion !== state.latestVersion) {
|
|
1762
|
+
emitUpdateNotice(resolvedCurrentVersion, state.latestVersion, resolvedUpdateCommand, updateDocsUrl, context.stderr);
|
|
1763
|
+
await cacheAdapter.write({
|
|
1764
|
+
...state,
|
|
1765
|
+
lastNotifiedVersion: state.latestVersion
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const latestVersion = await fetchLatestVersion(packageName, registryUrl, timeoutMs);
|
|
1771
|
+
if (latestVersion === null) {
|
|
1772
|
+
await cacheAdapter.write({
|
|
1773
|
+
...state,
|
|
1774
|
+
lastCheckedAt: now
|
|
1775
|
+
});
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const nextState = {
|
|
1779
|
+
...state,
|
|
1780
|
+
lastCheckedAt: now,
|
|
1781
|
+
latestVersion
|
|
1782
|
+
};
|
|
1783
|
+
if (isNewerVersion(resolvedCurrentVersion, latestVersion) && state.lastNotifiedVersion !== latestVersion) {
|
|
1784
|
+
emitUpdateNotice(resolvedCurrentVersion, latestVersion, resolvedUpdateCommand, updateDocsUrl, context.stderr);
|
|
1785
|
+
nextState.lastNotifiedVersion = latestVersion;
|
|
1786
|
+
}
|
|
1787
|
+
await cacheAdapter.write(nextState);
|
|
1788
|
+
} catch {}
|
|
1789
|
+
} } };
|
|
1790
|
+
});
|
|
1791
|
+
/**
|
|
1792
|
+
* Emits a styled, boxed update notice to stderr.
|
|
1793
|
+
*
|
|
1794
|
+
* Uses stderr so the notice does not interfere with piped stdout.
|
|
1795
|
+
*
|
|
1796
|
+
* The notice uses rounded-corner box-drawing characters and ANSI colors:
|
|
1797
|
+
* - Yellow box border
|
|
1798
|
+
* - Dim current version, bold green latest version
|
|
1799
|
+
* - Optional cyan update command and documentation URL
|
|
1800
|
+
*
|
|
1801
|
+
* @internal
|
|
1802
|
+
*/
|
|
1803
|
+
function emitUpdateNotice(currentVersion, latestVersion, updateCommand, updateDocsUrl, stderr) {
|
|
1804
|
+
const PADDING = 3;
|
|
1805
|
+
const contentLines = [
|
|
1806
|
+
`Update available ${dim(currentVersion)} ${yellow("→")} ${bold(green(latestVersion))}`,
|
|
1807
|
+
...updateCommand !== void 0 ? [`Run ${cyan(updateCommand)}`] : [],
|
|
1808
|
+
...updateDocsUrl !== void 0 ? [`See ${cyan(updateDocsUrl)} to update`] : []
|
|
1809
|
+
];
|
|
1810
|
+
const contentWidth = Math.max(...contentLines.map((line) => stringWidth(line)));
|
|
1811
|
+
const innerWidth = contentWidth + 6;
|
|
1812
|
+
const border = "─".repeat(innerWidth);
|
|
1813
|
+
const pad = " ".repeat(PADDING);
|
|
1814
|
+
const emptyLine = `${yellow("│")}${" ".repeat(innerWidth)}${yellow("│")}`;
|
|
1815
|
+
stderr([
|
|
1816
|
+
"",
|
|
1817
|
+
`${yellow("╭")}${yellow(border)}${yellow("╮")}`,
|
|
1818
|
+
emptyLine,
|
|
1819
|
+
...contentLines.map((line) => `${yellow("│")}${pad}${padEnd(line, contentWidth)}${pad}${yellow("│")}`),
|
|
1820
|
+
emptyLine,
|
|
1821
|
+
`${yellow("╰")}${yellow(border)}${yellow("╯")}`,
|
|
1822
|
+
""
|
|
1823
|
+
].join("\n"));
|
|
1824
|
+
}
|
|
1825
|
+
//#endregion
|
|
1826
|
+
//#region src/version.ts
|
|
1827
|
+
const VERSION = defineExtensionId("crust:version");
|
|
1828
|
+
const versionFlags = [{
|
|
1829
|
+
name: "version",
|
|
1830
|
+
type: "boolean",
|
|
1831
|
+
short: "v",
|
|
1832
|
+
noNegate: true,
|
|
1833
|
+
description: "Show version number",
|
|
1834
|
+
recursive: false
|
|
1835
|
+
}];
|
|
1836
|
+
function makeVersion(resolve, options) {
|
|
1837
|
+
const { format } = options;
|
|
1838
|
+
return defineExtension()(VERSION, {
|
|
1839
|
+
flags: versionFlags,
|
|
1840
|
+
hooks: { preRun(context) {
|
|
1841
|
+
if (context.commandPath.length !== 1 || context.flags.version !== true) return;
|
|
1842
|
+
const resolvedVersion = resolve(context);
|
|
1843
|
+
const line = format === "plain" ? resolvedVersion : format ? format(resolvedVersion, context) : `${context.rootCommand.meta.name} v${resolvedVersion}`;
|
|
1844
|
+
context.stdout(line);
|
|
1845
|
+
return context.finish();
|
|
1846
|
+
} }
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
function createVersion(value, options = {}) {
|
|
1850
|
+
if (value === void 0) return makeVersion((context) => context.rootCommand.meta.version, options);
|
|
1851
|
+
return makeVersion(() => {
|
|
1852
|
+
return typeof value === "function" ? value() : value;
|
|
1853
|
+
}, options);
|
|
1854
|
+
}
|
|
1855
|
+
const version = Object.assign(createVersion, { id: VERSION });
|
|
1856
|
+
//#endregion
|
|
1857
|
+
export { completion, didYouMean, help, noColor, renderBashCompletion, renderFishCompletion, renderHelp, renderZshCompletion, updateNotifier, version };
|