@crustjs/extensions 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +11 -9
- package/dist/index.js +83 -84
- package/package.json +5 -5
package/README.md
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ interface CompletionOptions {
|
|
|
13
13
|
/**
|
|
14
14
|
* Binary name embedded in generated scripts (the `complete -F` target,
|
|
15
15
|
* the `#compdef` line, the `complete -c <bin>` rules).
|
|
16
|
-
* Applies to the runtime command and build hook; set it when
|
|
17
|
-
*
|
|
16
|
+
* Applies to the runtime command and build hook; set it when the npm bin key
|
|
17
|
+
* installs the CLI under a different name.
|
|
18
18
|
*
|
|
19
19
|
* @default The root command's `meta.name`
|
|
20
20
|
*/
|
|
@@ -57,9 +57,10 @@ export declare function renderFishCompletion(root: CommandSnapshot, options?: Co
|
|
|
57
57
|
* distribution channels — distributors run it once at packaging time
|
|
58
58
|
* and the resulting files become drop-ins.
|
|
59
59
|
*
|
|
60
|
-
* **Build hook.**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* **Build hook.** Returns the same three files under `completions/`; `crust build`
|
|
61
|
+
* writes them to `.crust/artifacts/completions/` and copies that directory into
|
|
62
|
+
* the root package and each platform package's `bin/`. The binary name defaults
|
|
63
|
+
* to the snapshot's `meta.name`, unless `options.binName` is set.
|
|
63
64
|
*/
|
|
64
65
|
export declare const completion: ExtensionFactory<[options?: CompletionOptions], {}, [], [], readonly CommandDefinition<any, any, any, any>[]>;
|
|
65
66
|
//#endregion
|
|
@@ -108,10 +109,11 @@ declare const colorFlags: readonly [{
|
|
|
108
109
|
* and hyperlinks keep following TTY detection, per
|
|
109
110
|
* [no-color.org](https://no-color.org/).
|
|
110
111
|
*
|
|
111
|
-
* Previous values are restored after the command finishes.
|
|
112
|
-
* programmatic runs in one process
|
|
113
|
-
*
|
|
114
|
-
*
|
|
112
|
+
* Previous values are restored after the command finishes. Overlapping
|
|
113
|
+
* programmatic runs in one process may share a direction (both `--color` or
|
|
114
|
+
* both `--no-color`); the ambient values are restored once all runs finish.
|
|
115
|
+
* An overlapping run with the opposing flag throws a `CrustError` in
|
|
116
|
+
* `preRun`, because the env is process-global and cannot hold both values.
|
|
115
117
|
*/
|
|
116
118
|
export declare const noColor: ExtensionFactory<[], {}, [], typeof colorFlags>;
|
|
117
119
|
//#endregion
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, join, resolve } from "node:path";
|
|
3
3
|
import { CrustError, defineCommand, defineExtension, defineExtensionId } from "@crustjs/core";
|
|
4
4
|
import { buildCommandDocumentation, formatDescription, isListed, sectionsFor } from "@crustjs/core/tooling";
|
|
@@ -410,6 +410,16 @@ function renderBash(spec, binName, version) {
|
|
|
410
410
|
lines.push(" return 1");
|
|
411
411
|
lines.push("}");
|
|
412
412
|
lines.push("");
|
|
413
|
+
if (valueTypeCases.some((c) => c.kind === "path")) {
|
|
414
|
+
lines.push(`__${ident}_file_candidates() {`);
|
|
415
|
+
lines.push(" local f");
|
|
416
|
+
lines.push(" compopt -o filenames 2>/dev/null || :");
|
|
417
|
+
lines.push(" while IFS= read -r f; do");
|
|
418
|
+
lines.push(" if [[ -n \"$f\" ]]; then COMPREPLY[${#COMPREPLY[@]}]=\"$1$f\"; fi");
|
|
419
|
+
lines.push(" done <<< \"$(compgen -f -- \"$2\")\"");
|
|
420
|
+
lines.push("}");
|
|
421
|
+
lines.push("");
|
|
422
|
+
}
|
|
413
423
|
lines.push(`${fnName}() {`);
|
|
414
424
|
lines.push(" local cur prev words cword");
|
|
415
425
|
lines.push(" if declare -F _init_completion >/dev/null 2>&1; then");
|
|
@@ -478,7 +488,7 @@ function renderBash(spec, binName, version) {
|
|
|
478
488
|
lines.push(" case \"$cmd_path|$_flag\" in");
|
|
479
489
|
for (const c of valueTypeCases) {
|
|
480
490
|
lines.push(`\t\t\t"${bashDoubleQuoteInner(c.key)}")`);
|
|
481
|
-
if (c.kind === "path") lines.push(
|
|
491
|
+
if (c.kind === "path") lines.push(`\t\t\t\t__${ident}_file_candidates "\${_flag}=" "$_value"`);
|
|
482
492
|
else lines.push(" compopt +o default 2>/dev/null");
|
|
483
493
|
lines.push(" return");
|
|
484
494
|
lines.push(" ;;");
|
|
@@ -503,7 +513,7 @@ function renderBash(spec, binName, version) {
|
|
|
503
513
|
lines.push(" case \"$cmd_path|$prev\" in");
|
|
504
514
|
for (const c of valueTypeCases) {
|
|
505
515
|
lines.push(`\t\t"${bashDoubleQuoteInner(c.key)}")`);
|
|
506
|
-
if (c.kind === "path") lines.push(
|
|
516
|
+
if (c.kind === "path") lines.push(`\t\t\t__${ident}_file_candidates "" "$cur"`);
|
|
507
517
|
else lines.push(" compopt +o default 2>/dev/null");
|
|
508
518
|
lines.push(" return");
|
|
509
519
|
lines.push(" ;;");
|
|
@@ -699,12 +709,7 @@ function emitRules(binName, ident, path, current, out) {
|
|
|
699
709
|
};
|
|
700
710
|
for (const flag of current.flags) {
|
|
701
711
|
const desc = flag.description ?? "";
|
|
702
|
-
const
|
|
703
|
-
condition,
|
|
704
|
-
long: flag.name,
|
|
705
|
-
description: desc
|
|
706
|
-
};
|
|
707
|
-
if (flag.short !== void 0) baseRule.short = flag.short;
|
|
712
|
+
const spellings = [flag.name, ...flag.aliases ?? []];
|
|
708
713
|
const emitValueRule = (rule) => {
|
|
709
714
|
if (flag.choices !== void 0 && flag.choices.length > 0) {
|
|
710
715
|
emitChoiceFlag(rule, flag.choices);
|
|
@@ -723,27 +728,21 @@ function emitRules(binName, ident, path, current, out) {
|
|
|
723
728
|
requireParameter: true
|
|
724
729
|
}));
|
|
725
730
|
};
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (flag.aliases !== void 0) for (const alias of flag.aliases) {
|
|
729
|
-
const aliasRule = {
|
|
731
|
+
for (const [index, spelling] of spellings.entries()) {
|
|
732
|
+
const rule = {
|
|
730
733
|
condition,
|
|
731
|
-
long:
|
|
734
|
+
long: spelling,
|
|
732
735
|
description: desc
|
|
733
736
|
};
|
|
734
|
-
if (flag.
|
|
735
|
-
|
|
737
|
+
if (index === 0 && flag.short !== void 0) rule.short = flag.short;
|
|
738
|
+
if (flag.takesValue) emitValueRule(rule);
|
|
739
|
+
else out.push(renderRule(binName, rule));
|
|
736
740
|
}
|
|
737
741
|
if (flag.negatable) {
|
|
738
742
|
const negDesc = `disable: ${desc}`.trim();
|
|
739
|
-
out.push(renderRule(binName, {
|
|
743
|
+
for (const spelling of spellings) out.push(renderRule(binName, {
|
|
740
744
|
condition,
|
|
741
|
-
long: `no-${
|
|
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}`,
|
|
745
|
+
long: `no-${spelling}`,
|
|
747
746
|
description: negDesc
|
|
748
747
|
}));
|
|
749
748
|
}
|
|
@@ -863,7 +862,8 @@ function renderFish(spec, binName, version) {
|
|
|
863
862
|
/**
|
|
864
863
|
* Pure-static zsh completion script renderer.
|
|
865
864
|
*
|
|
866
|
-
* Strategy: emit one `_<bin>
|
|
865
|
+
* Strategy: emit one `_<bin>__<path>` helper per command in the tree
|
|
866
|
+
* (path segments joined by `__`, see {@link helperName}).
|
|
867
867
|
* Helpers for non-leaf commands declare an `_arguments -C` spec with
|
|
868
868
|
* `1: :->cmds` and `*::arg:->args`, then dispatch via `case "$line[1]"`
|
|
869
869
|
* into the child helper — the canonical `->state` routing pattern from
|
|
@@ -958,13 +958,26 @@ function renderArgSpecs(node) {
|
|
|
958
958
|
return specs;
|
|
959
959
|
}
|
|
960
960
|
/**
|
|
961
|
+
* Encode one validated command name (`[A-Za-z0-9][A-Za-z0-9._-]*`) as a
|
|
962
|
+
* helper-name segment. Alphanumerics pass through; `.`, `-` and `_`
|
|
963
|
+
* become `_<hex>` (`_2e`, `_2d`, `_5f`). The result never contains `__`
|
|
964
|
+
* and never ends in `_`, so `__` is unambiguous as the path separator in
|
|
965
|
+
* {@link helperName}: `foo-bar`, `foo_bar` and nested `foo bar` map to
|
|
966
|
+
* `foo_2dbar`, `foo_5fbar` and `foo__bar`. Mapping every non-alphanumeric
|
|
967
|
+
* to `_` (as {@link toShellIdent} does) let distinct commands overwrite
|
|
968
|
+
* one another's helper.
|
|
969
|
+
*/
|
|
970
|
+
function encodeHelperSegment(name) {
|
|
971
|
+
return name.replace(/[^A-Za-z0-9]/g, (ch) => `_${ch.charCodeAt(0).toString(16)}`);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
961
974
|
* Build the function name for the helper that handles a given command
|
|
962
|
-
* path. The root is `_<ident>`; nested children append `
|
|
963
|
-
* each step.
|
|
975
|
+
* path. The root is `_<ident>`; nested children append `__<segment>` for
|
|
976
|
+
* each step, with segments encoded by {@link encodeHelperSegment}.
|
|
964
977
|
*/
|
|
965
978
|
function helperName(rootIdent, path) {
|
|
966
979
|
if (path.length === 0) return `_${rootIdent}`;
|
|
967
|
-
return `_${rootIdent}
|
|
980
|
+
return `_${rootIdent}__${path.map(encodeHelperSegment).join("__")}`;
|
|
968
981
|
}
|
|
969
982
|
/**
|
|
970
983
|
* Render a single command's helper function.
|
|
@@ -1213,17 +1226,13 @@ function prepareRender(root, options) {
|
|
|
1213
1226
|
version: sanitizeFreeText(version)
|
|
1214
1227
|
};
|
|
1215
1228
|
}
|
|
1216
|
-
|
|
1229
|
+
/** Every supported shell's drop-in file, named by its autoload convention. */
|
|
1230
|
+
function renderCompletionFiles(root, options) {
|
|
1217
1231
|
const { spec, binName, version } = prepareRender(root, options);
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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;
|
|
1232
|
+
return SUPPORTED_SHELLS.map((shell) => ({
|
|
1233
|
+
path: filenameForShell(shell, binName),
|
|
1234
|
+
content: SHELL_RENDERERS[shell](spec, binName, version)
|
|
1235
|
+
}));
|
|
1227
1236
|
}
|
|
1228
1237
|
function renderCompletionScript(shell, root, options = {}) {
|
|
1229
1238
|
const { spec, binName, version } = prepareRender(root, options);
|
|
@@ -1263,9 +1272,10 @@ function renderFishCompletion(root, options) {
|
|
|
1263
1272
|
* distribution channels — distributors run it once at packaging time
|
|
1264
1273
|
* and the resulting files become drop-ins.
|
|
1265
1274
|
*
|
|
1266
|
-
* **Build hook.**
|
|
1267
|
-
*
|
|
1268
|
-
*
|
|
1275
|
+
* **Build hook.** Returns the same three files under `completions/`; `crust build`
|
|
1276
|
+
* writes them to `.crust/artifacts/completions/` and copies that directory into
|
|
1277
|
+
* the root package and each platform package's `bin/`. The binary name defaults
|
|
1278
|
+
* to the snapshot's `meta.name`, unless `options.binName` is set.
|
|
1269
1279
|
*/
|
|
1270
1280
|
const completion = defineExtension(COMPLETION, (options = {}) => {
|
|
1271
1281
|
const subcommandName = options.command ?? "completion";
|
|
@@ -1286,27 +1296,15 @@ const completion = defineExtension(COMPLETION, (options = {}) => {
|
|
|
1286
1296
|
context.stdout(renderCompletionScript(context.args.shell, context.rootCommand, options));
|
|
1287
1297
|
return;
|
|
1288
1298
|
}
|
|
1289
|
-
|
|
1299
|
+
const dir = resolve(outputDir);
|
|
1300
|
+
const files = renderCompletionFiles(context.rootCommand, options);
|
|
1301
|
+
await mkdir(dir, { recursive: true });
|
|
1302
|
+
for (const file of files) await writeFile(join(dir, file.path), file.content);
|
|
1290
1303
|
}))],
|
|
1291
|
-
build:
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
}
|
|
1304
|
+
build: ({ snapshot }) => renderCompletionFiles(snapshot, options).map((file) => ({
|
|
1305
|
+
...file,
|
|
1306
|
+
path: `completions/${file.path}`
|
|
1307
|
+
}))
|
|
1310
1308
|
};
|
|
1311
1309
|
});
|
|
1312
1310
|
//#endregion
|
|
@@ -1412,7 +1410,7 @@ function levenshtein(a, b) {
|
|
|
1412
1410
|
return row[bLen];
|
|
1413
1411
|
}
|
|
1414
1412
|
/**
|
|
1415
|
-
* Find canonical-name
|
|
1413
|
+
* Find the best canonical-name suggestion for `input` by matching against every
|
|
1416
1414
|
* sibling's canonical name **and** any aliases declared on each sibling.
|
|
1417
1415
|
*
|
|
1418
1416
|
* Matched aliases are mapped back to their canonical, so suggestions only
|
|
@@ -1432,36 +1430,28 @@ function levenshtein(a, b) {
|
|
|
1432
1430
|
* shortcut is intentionally omitted: with aliases in the candidate set,
|
|
1433
1431
|
* any 1–2 char alias would falsely match every typo as distance 0.
|
|
1434
1432
|
*/
|
|
1435
|
-
function
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
-
};
|
|
1433
|
+
function findSuggestion(input, subCommands) {
|
|
1434
|
+
let bestName;
|
|
1435
|
+
let bestDistance = Infinity;
|
|
1446
1436
|
for (const [name, node] of Object.entries(subCommands)) {
|
|
1447
1437
|
if (!isListed(node)) continue;
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1438
|
+
let distance = Infinity;
|
|
1439
|
+
for (const spelling of [name, ...node.meta.aliases ?? []]) distance = Math.min(distance, spelling.startsWith(input) ? 0 : levenshtein(input, spelling));
|
|
1440
|
+
if (distance <= 3 && (bestName === void 0 || distance < bestDistance || distance === bestDistance && name.localeCompare(bestName) < 0)) {
|
|
1441
|
+
bestName = name;
|
|
1442
|
+
bestDistance = distance;
|
|
1453
1443
|
}
|
|
1454
1444
|
}
|
|
1455
|
-
return
|
|
1445
|
+
return bestName;
|
|
1456
1446
|
}
|
|
1457
1447
|
const didYouMean = defineExtension(DID_YOU_MEAN, (options = {}) => {
|
|
1458
1448
|
const mode = options.mode ?? "error";
|
|
1459
1449
|
return { hooks: { onError(error, context) {
|
|
1460
1450
|
if (!(error instanceof CrustError) || !error.is("COMMAND_NOT_FOUND")) return;
|
|
1461
1451
|
const details = error.details;
|
|
1462
|
-
const
|
|
1452
|
+
const suggestion = findSuggestion(details.input, details.parentCommand.subCommands);
|
|
1463
1453
|
let message = `Unknown command "${details.input}".`;
|
|
1464
|
-
if (
|
|
1454
|
+
if (suggestion !== void 0) message += ` Did you mean "${suggestion}"?`;
|
|
1465
1455
|
if (mode === "help") {
|
|
1466
1456
|
context.stdout(message);
|
|
1467
1457
|
context.stdout("");
|
|
@@ -1477,6 +1467,7 @@ const didYouMean = defineExtension(DID_YOU_MEAN, (options = {}) => {
|
|
|
1477
1467
|
//#region src/no-color.ts
|
|
1478
1468
|
const NO_COLOR = defineExtensionId("crust:no-color");
|
|
1479
1469
|
let activeRuns = 0;
|
|
1470
|
+
let activeFlag;
|
|
1480
1471
|
let baseForceColor;
|
|
1481
1472
|
let baseNoColor;
|
|
1482
1473
|
const colorRuns = /* @__PURE__ */ new WeakSet();
|
|
@@ -1499,10 +1490,11 @@ const colorFlags = [{
|
|
|
1499
1490
|
* and hyperlinks keep following TTY detection, per
|
|
1500
1491
|
* [no-color.org](https://no-color.org/).
|
|
1501
1492
|
*
|
|
1502
|
-
* Previous values are restored after the command finishes.
|
|
1503
|
-
* programmatic runs in one process
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1493
|
+
* Previous values are restored after the command finishes. Overlapping
|
|
1494
|
+
* programmatic runs in one process may share a direction (both `--color` or
|
|
1495
|
+
* both `--no-color`); the ambient values are restored once all runs finish.
|
|
1496
|
+
* An overlapping run with the opposing flag throws a `CrustError` in
|
|
1497
|
+
* `preRun`, because the env is process-global and cannot hold both values.
|
|
1506
1498
|
*/
|
|
1507
1499
|
const noColor = defineExtension(NO_COLOR, () => ({
|
|
1508
1500
|
flags: colorFlags,
|
|
@@ -1510,6 +1502,12 @@ const noColor = defineExtension(NO_COLOR, () => ({
|
|
|
1510
1502
|
preRun(context) {
|
|
1511
1503
|
const flagValue = context.flags.color;
|
|
1512
1504
|
if (flagValue !== true && flagValue !== false) return;
|
|
1505
|
+
if (activeRuns > 0 && activeFlag !== flagValue) throw new CrustError("DEFINITION", "noColor: cannot start a --color run while a --no-color run is in flight (or vice versa); opposing overlapping runs share process.env", {
|
|
1506
|
+
subject: "extension",
|
|
1507
|
+
name: NO_COLOR,
|
|
1508
|
+
reason: "opposing-overlap"
|
|
1509
|
+
});
|
|
1510
|
+
activeFlag = flagValue;
|
|
1513
1511
|
if (activeRuns === 0) {
|
|
1514
1512
|
baseForceColor = process.env.FORCE_COLOR;
|
|
1515
1513
|
baseNoColor = process.env.NO_COLOR;
|
|
@@ -1529,6 +1527,7 @@ const noColor = defineExtension(NO_COLOR, () => ({
|
|
|
1529
1527
|
colorRuns.delete(context);
|
|
1530
1528
|
activeRuns--;
|
|
1531
1529
|
if (activeRuns === 0) {
|
|
1530
|
+
activeFlag = void 0;
|
|
1532
1531
|
if (baseForceColor === void 0) delete process.env.FORCE_COLOR;
|
|
1533
1532
|
else process.env.FORCE_COLOR = baseForceColor;
|
|
1534
1533
|
if (baseNoColor === void 0) delete process.env.NO_COLOR;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crustjs/extensions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Official Extensions for the Crust CLI framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -46,17 +46,17 @@
|
|
|
46
46
|
"postpack": "rm -f LICENSE"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@crustjs/store": "^0.
|
|
50
|
-
"@crustjs/style": "^0.3.
|
|
49
|
+
"@crustjs/store": "^0.4.0",
|
|
50
|
+
"@crustjs/style": "^0.3.3"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@crustjs/config": "0.0.0",
|
|
54
|
-
"@crustjs/core": "0.
|
|
54
|
+
"@crustjs/core": "0.3.1",
|
|
55
55
|
"@crustjs/utils": "0.0.0",
|
|
56
56
|
"tsdown": "^0.23.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@crustjs/core": "^0.
|
|
59
|
+
"@crustjs/core": "^0.3.1",
|
|
60
60
|
"typescript": "^7.0.0"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|