@crustjs/extensions 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Official Extensions for the Crust CLI framework
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- bun add @crustjs/extensions
8
+ npm install @crustjs/extensions
9
9
  ```
10
10
 
11
11
  ## Documentation
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 `crust build --name`
17
- * or the npm bin key installs the CLI under a different name.
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.** `crust build` writes the same three files under
61
- * `<outDir>/completions/`; `--package` stages that directory. The binary name
62
- * defaults to the snapshot's `meta.name`, unless `options.binName` is set.
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
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
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(" COMPREPLY=( $(compgen -P \"${_flag}=\" -f -- \"$_value\") )");
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(" COMPREPLY=( $(compgen -f -- \"$cur\") )");
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 baseRule = {
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
- 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 = {
731
+ for (const [index, spelling] of spellings.entries()) {
732
+ const rule = {
730
733
  condition,
731
- long: alias,
734
+ long: spelling,
732
735
  description: desc
733
736
  };
734
- if (flag.takesValue) emitValueRule(aliasRule);
735
- else out.push(renderRule(binName, aliasRule));
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-${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}`,
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>_<path>` helper per command in the tree.
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 `_<segment>` for
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}_${path.map(toShellIdent).join("_")}`;
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
- async function writeCompletionFiles(dir, root, options) {
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
- 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;
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.** `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.
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
- await writeCompletionFiles(resolve(outputDir), context.rootCommand, options);
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: 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
- }
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 suggestions for `input` by matching against every
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 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
- };
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
- 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);
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 [...best.entries()].sort(([aName, aDist], [bName, bDist]) => aDist !== bDist ? aDist - bDist : aName.localeCompare(bName)).map(([name]) => name);
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 suggestions = findSuggestions(details.input, details.parentCommand.subCommands);
1452
+ const suggestion = findSuggestion(details.input, details.parentCommand.subCommands);
1463
1453
  let message = `Unknown command "${details.input}".`;
1464
- if (suggestions.length > 0) message += ` Did you mean "${suggestions[0]}"?`;
1454
+ if (suggestion !== void 0) message += ` Did you mean "${suggestion}"?`;
1465
1455
  if (mode === "help") {
1466
1456
  context.stdout(message);
1467
1457
  context.stdout("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/extensions",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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.3.0",
50
- "@crustjs/style": "^0.3.0"
49
+ "@crustjs/store": "^0.4.0",
50
+ "@crustjs/style": "^0.3.2"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@crustjs/config": "0.0.0",
54
- "@crustjs/core": "0.2.0",
54
+ "@crustjs/core": "0.3.0",
55
55
  "@crustjs/utils": "0.0.0",
56
56
  "tsdown": "^0.23.0"
57
57
  },
58
58
  "peerDependencies": {
59
- "@crustjs/core": "^0.2.0",
59
+ "@crustjs/core": "^0.3.0",
60
60
  "typescript": "^7.0.0"
61
61
  },
62
62
  "peerDependenciesMeta": {
@@ -65,7 +65,7 @@
65
65
  }
66
66
  },
67
67
  "engines": {
68
- "bun": ">=1.3.14",
68
+ "bun": ">=1.4.0",
69
69
  "node": ">=22",
70
70
  "deno": ">=2.8"
71
71
  }