@theholocron/cli 4.16.5 → 4.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,22 @@ holocron --help
14
14
 
15
15
  ```
16
16
 
17
+ ## Execution contexts
18
+
19
+ Every command is tagged with how much of a repo it needs:
20
+
21
+ | Context | Needs | Examples |
22
+ | ------------ | -------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
23
+ | `global` | just the CLI binary | `version`, `clone`, `new`, `upgrade node`, `auth set` / `check`, `plugin create`, `skills …` |
24
+ | `repo-aware` | `./holocron.config` in cwd, no plugins | `run`, `ci`, `config show`, `sync-readme` |
25
+ | `workspace` | the `@theholocron/holocron-plugin-*` packages resolvable | `doctor`, `setup`, `sync`, `secrets sync`, `deploy` |
26
+
27
+ A `workspace` command run from a bare global install (no plugins next to the
28
+ CLI) fails with one actionable line — install the plugins as devDependencies or
29
+ use `pnpm exec holocron <cmd>`. `auth check` degrades to "token present,
30
+ verification skipped" instead. `holocron --help` prints the grouping; see
31
+ [Execution Contexts](https://theholocron.github.io/holocron/execution-contexts).
32
+
17
33
  ## Config file
18
34
 
19
35
  Holocron reads `holocron.config.{json,js,ts}` from the project root
package/dist/cli.mjs CHANGED
@@ -379,6 +379,240 @@ function getRunId() {
379
379
  return root?.runId;
380
380
  }
381
381
  //#endregion
382
+ //#region src/plugin/loader.ts
383
+ /**
384
+ * `PluginLoader` — loads provider plugins per the resolved config and
385
+ * builds a typed capability registry the runtime can query.
386
+ *
387
+ * Flow:
388
+ * 1. Walk `config.providers[*]` from `resolveConfig()`
389
+ * 2. For each entry, dynamic-import the resolved package name
390
+ * (`@theholocron/holocron-plugin-<provider>` by default)
391
+ * 3. Call the package's exported `createPlugin(options)` to get a
392
+ * plugin object whose `capabilities` map holds factories
393
+ * 4. Invoke the matching capability factory and stash the impl in
394
+ * the registry — single-cardinality entries hold one impl,
395
+ * many-cardinality entries hold an array
396
+ *
397
+ * A package may instead export a capability config (see
398
+ * `CapabilityConfigPackage` in config.ts). The loader detects the shape
399
+ * at import time and re-resolves to the underlying plugin, merging the
400
+ * preset options with any per-project overrides from the config file
401
+ * (project options win, mirroring ESLint's `extends` precedence).
402
+ *
403
+ * Loader keeps NO knowledge of vendor tokens. Each plugin reads its
404
+ * own env vars (`HOLOCRON_ADMIN_TOKEN`, `HOLOCRON_VERCEL_TOKEN`, etc.)
405
+ * inside its `createPlugin`. That keeps the loader vendor-agnostic
406
+ * and the auth story per-plugin explicit.
407
+ *
408
+ * `importer` is injectable so tests don't need real network or
409
+ * sibling packages installed.
410
+ */
411
+ var LoaderError = class extends Error {
412
+ name = "LoaderError";
413
+ };
414
+ var PluginLoader = class {
415
+ config;
416
+ context;
417
+ importer;
418
+ registry = /* @__PURE__ */ new Map();
419
+ failures = [];
420
+ constructor(config, context, importer = defaultImporter$1) {
421
+ this.config = config;
422
+ this.context = context;
423
+ this.importer = importer;
424
+ }
425
+ /**
426
+ * Imports every configured plugin and builds the capability registry.
427
+ *
428
+ * Never throws for a single plugin's failure — a missing vendor token,
429
+ * an uninstalled package, or an unimplemented capability records a
430
+ * {@link PluginLoadFailure} and the load continues. This is the
431
+ * "soft-skip over hard-fail" contract: a command that needs a
432
+ * capability learns it is absent via `has()` / `get()` (which
433
+ * re-surfaces the original error), and orchestrators report the skip
434
+ * in their summary. Inspect {@link loadFailures} for the full list.
435
+ */
436
+ async load() {
437
+ const entries = Object.entries(this.config.providers);
438
+ for (const [key, entry] of entries) {
439
+ if (!entry) continue;
440
+ if (entry.cardinality === "single") try {
441
+ this.registry.set(key, await this.loadOne(key, entry.tuple));
442
+ } catch (err) {
443
+ this.recordFailure(key, entry.tuple, err);
444
+ }
445
+ else {
446
+ const impls = [];
447
+ for (const tuple of entry.tuples) try {
448
+ impls.push(await this.loadOne(key, tuple));
449
+ } catch (err) {
450
+ this.recordFailure(key, tuple, err);
451
+ }
452
+ if (impls.length > 0) this.registry.set(key, impls);
453
+ }
454
+ }
455
+ }
456
+ recordFailure(key, tuple, err) {
457
+ this.failures.push({
458
+ key,
459
+ provider: tuple.provider,
460
+ packageName: tuple.packageName,
461
+ error: err instanceof Error ? err : new Error(String(err))
462
+ });
463
+ }
464
+ /** Providers that failed to load during {@link load}. Empty on a clean load. */
465
+ loadFailures() {
466
+ return this.failures;
467
+ }
468
+ /**
469
+ * Type-safe lookup. Single-cardinality keys return one impl;
470
+ * many-cardinality keys return an array. `ResolvedCapability<K>`
471
+ * encodes the split via the `CARDINALITY` map.
472
+ */
473
+ get(key) {
474
+ const impl = this.registry.get(key);
475
+ if (impl === void 0) {
476
+ const failure = this.failures.find((f) => f.key === key);
477
+ if (failure) throw failure.error;
478
+ throw new LoaderError(`capability \`${key}\` is not loaded — is it declared in holocron.config.json?`);
479
+ }
480
+ return impl;
481
+ }
482
+ /** Whether a capability has been loaded. */
483
+ has(key) {
484
+ return this.registry.has(key);
485
+ }
486
+ /** All capability keys currently loaded. Useful for the doctor command. */
487
+ loadedKeys() {
488
+ return Array.from(this.registry.keys());
489
+ }
490
+ /** Internal — invoke a plugin's capability factory and return the impl. */
491
+ async loadOne(key, tuple) {
492
+ const mod = await this.importer(tuple.packageName).catch((err) => {
493
+ throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
494
+ });
495
+ if (isPluginModule(mod)) {
496
+ const effectiveToken = this.context.cliTokens?.[tuple.provider] ?? this.context.cliToken;
497
+ const factory = mod.createPlugin({
498
+ ...this.projectDefaults(),
499
+ ...this.context,
500
+ ...effectiveToken !== void 0 ? { cliToken: effectiveToken } : {},
501
+ cliTokens: void 0,
502
+ ...tuple.options
503
+ }).capabilities[key];
504
+ if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
505
+ return factory();
506
+ }
507
+ if (isCapabilityConfigModule(mod)) {
508
+ const cap = mod.default;
509
+ return this.loadOne(key, {
510
+ provider: cap.provider,
511
+ packageName: resolvePluginPackage(cap.provider),
512
+ options: {
513
+ ...cap.options,
514
+ ...tuple.options
515
+ }
516
+ });
517
+ }
518
+ throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\` or a capability config ({ provider, options? })`);
519
+ }
520
+ /**
521
+ * Project-level defaults that get merged into every plugin's options
522
+ * unless overridden by the CLI context or per-plugin tuple options.
523
+ * See `docs/wiki/specifications/tech-setup-and-config.spec.md` §Design.
524
+ */
525
+ projectDefaults() {
526
+ const defaults = {};
527
+ if (this.config.repo?.name) defaults.repo = this.config.repo.name;
528
+ return defaults;
529
+ }
530
+ };
531
+ /** Default importer — resolves from cwd first so the global CLI finds project plugins. */
532
+ const defaultImporter$1 = async (pkg) => {
533
+ try {
534
+ return import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
535
+ } catch {
536
+ return import(pkg);
537
+ }
538
+ };
539
+ function isPluginModule(mod) {
540
+ return typeof mod.createPlugin === "function";
541
+ }
542
+ function isCapabilityConfigModule(mod) {
543
+ return typeof mod.default?.provider === "string";
544
+ }
545
+ //#endregion
546
+ //#region src/plugin/workspace.ts
547
+ /**
548
+ * The `workspace`-context guard.
549
+ *
550
+ * A `workspace` command (`sync`, `setup`, `doctor`, …) needs the
551
+ * configured providers' `@theholocron/holocron-plugin-*` packages to
552
+ * resolve. From a bare `npm i -g @theholocron/cli` they don't, and the
553
+ * {@link PluginLoader} — which soft-skips every failure — leaves the
554
+ * command with an empty registry and a stack of `Cannot find package`
555
+ * errors.
556
+ *
557
+ * {@link assertPluginsResolvable} turns that specific situation (every
558
+ * provider failed, and every failure is a module-resolution failure)
559
+ * into one actionable {@link WorkspaceContextError} the CLI prints
560
+ * instead of a stack trace. Any other mix — some plugins loaded, or a
561
+ * failure that's an auth/token error rather than a missing package —
562
+ * is left alone for the command's own soft-skip reporting.
563
+ *
564
+ * Spec: `docs/wiki/specifications/tech-cli-execution-contexts.spec.md`
565
+ * (theholocron/holocron#576).
566
+ */
567
+ /**
568
+ * Raised when a `workspace` command runs somewhere its provider plugins
569
+ * cannot be resolved — the "you're on a global install" case. Carries the
570
+ * unresolved package names so the message can be specific.
571
+ */
572
+ var WorkspaceContextError = class extends Error {
573
+ name = "WorkspaceContextError";
574
+ command;
575
+ packages;
576
+ constructor(command, packages) {
577
+ const [first, ...rest] = packages;
578
+ const subject = rest.length > 0 ? `${first} (and ${rest.length} other${rest.length === 1 ? "" : "s"})` : first ?? "its plugins";
579
+ super(`\`${command}\` needs ${subject}, but no plugin package resolves here.\nRun it from a repo that has the \`@theholocron/holocron-plugin-*\` packages as devDependencies, or \`pnpm exec holocron ${command}\`. A global install can't resolve them — see https://theholocron.github.io/holocron/execution-contexts`);
580
+ this.command = command;
581
+ this.packages = packages;
582
+ }
583
+ };
584
+ /**
585
+ * A raw "package isn't installed here" error from `import()` /
586
+ * `require.resolve` — `ERR_MODULE_NOT_FOUND` / `MODULE_NOT_FOUND`, or the
587
+ * "Cannot find package/module" message Node prints for it.
588
+ */
589
+ function isModuleNotFound(err) {
590
+ if (!(err instanceof Error)) return false;
591
+ const code = err.code;
592
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
593
+ return /Cannot find (?:package|module)|MODULE_NOT_FOUND/.test(err.message);
594
+ }
595
+ /** Did this failure come from the dynamic `import()` not finding the package? */
596
+ function isImportFailure(err) {
597
+ return err instanceof LoaderError && err.message.startsWith("failed to import");
598
+ }
599
+ /**
600
+ * Throw {@link WorkspaceContextError} when a loaded `PluginLoader` shows
601
+ * the global-install signature: nothing in the registry, at least one
602
+ * failure, and *every* failure is a missing-package error. A no-op in
603
+ * every other case (some capability loaded, non-import failure, or no
604
+ * providers configured at all).
605
+ *
606
+ * Call it right after `await loader.load()` in a `workspace` command.
607
+ */
608
+ function assertPluginsResolvable(loader, command) {
609
+ if (loader.loadedKeys().length > 0) return;
610
+ const failures = loader.loadFailures();
611
+ if (failures.length === 0) return;
612
+ if (!failures.every((f) => isImportFailure(f.error))) return;
613
+ throw new WorkspaceContextError(command, [...new Set(failures.map((f) => f.packageName))]);
614
+ }
615
+ //#endregion
382
616
  //#region src/ui/progress.ts
383
617
  /**
384
618
  * Runs `fn`, showing an ora spinner for its duration in TTY environments.
@@ -427,7 +661,7 @@ const style = {
427
661
  * don't export `verifyToken` can still store — with a warning — because
428
662
  * "no verify path" shouldn't block credential storage.
429
663
  */
430
- const defaultImporter$1 = async (pkg) => {
664
+ const defaultImporter = async (pkg) => {
431
665
  try {
432
666
  return await import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
433
667
  } catch {
@@ -448,7 +682,7 @@ function resolveAuthSetToken(input) {
448
682
  async function runAuthSet(input) {
449
683
  const print = input.print ?? ((l) => console.log(l));
450
684
  const logger = input.logger ?? getLogger();
451
- const importer = input.importer ?? defaultImporter$1;
685
+ const importer = input.importer ?? defaultImporter;
452
686
  const { provider } = input;
453
687
  const keyringKey = input.org ? `${provider}.${input.org}` : provider;
454
688
  const token = resolveAuthSetToken({
@@ -551,7 +785,7 @@ function runAuthUnset(input) {
551
785
  async function runAuthCheck(input) {
552
786
  const print = input.print ?? ((l) => console.log(l));
553
787
  const logger = input.logger ?? getLogger();
554
- const importer = input.importer ?? defaultImporter$1;
788
+ const importer = input.importer ?? defaultImporter;
555
789
  const { provider } = input;
556
790
  const keyringKey = input.org ? `${provider}.${input.org}` : provider;
557
791
  const token = getToken(keyringKey);
@@ -616,6 +850,19 @@ async function runAuthCheck(input) {
616
850
  };
617
851
  } catch (err) {
618
852
  const msg = err instanceof Error ? err.message : String(err);
853
+ if (isModuleNotFound(err)) {
854
+ print(style.warn(`${keyringKey}: token present — verification skipped (plugin not available here)`));
855
+ logger.info({
856
+ provider,
857
+ keyringKey,
858
+ status: "ok",
859
+ reason: "plugin not resolvable"
860
+ }, `auth check: ${keyringKey}`);
861
+ return {
862
+ status: "ok",
863
+ message: "stored, unverified (plugin not available)"
864
+ };
865
+ }
619
866
  print(style.fail(`${keyringKey}: cannot verify — ${msg}`));
620
867
  logger.warn({
621
868
  provider,
@@ -632,7 +879,7 @@ async function runAuthCheck(input) {
632
879
  async function runAuthList(input = {}) {
633
880
  const print = input.print ?? ((l) => console.log(l));
634
881
  const logger = input.logger ?? getLogger();
635
- const importer = input.importer ?? defaultImporter$1;
882
+ const importer = input.importer ?? defaultImporter;
636
883
  const providers = listStoredProviders();
637
884
  if (providers.length === 0) {
638
885
  print(style.dim("no stored tokens."));
@@ -677,170 +924,6 @@ async function tryLoadHint(importer, packageName) {
677
924
  }
678
925
  }
679
926
  //#endregion
680
- //#region src/plugin/loader.ts
681
- /**
682
- * `PluginLoader` — loads provider plugins per the resolved config and
683
- * builds a typed capability registry the runtime can query.
684
- *
685
- * Flow:
686
- * 1. Walk `config.providers[*]` from `resolveConfig()`
687
- * 2. For each entry, dynamic-import the resolved package name
688
- * (`@theholocron/holocron-plugin-<provider>` by default)
689
- * 3. Call the package's exported `createPlugin(options)` to get a
690
- * plugin object whose `capabilities` map holds factories
691
- * 4. Invoke the matching capability factory and stash the impl in
692
- * the registry — single-cardinality entries hold one impl,
693
- * many-cardinality entries hold an array
694
- *
695
- * A package may instead export a capability config (see
696
- * `CapabilityConfigPackage` in config.ts). The loader detects the shape
697
- * at import time and re-resolves to the underlying plugin, merging the
698
- * preset options with any per-project overrides from the config file
699
- * (project options win, mirroring ESLint's `extends` precedence).
700
- *
701
- * Loader keeps NO knowledge of vendor tokens. Each plugin reads its
702
- * own env vars (`HOLOCRON_ADMIN_TOKEN`, `HOLOCRON_VERCEL_TOKEN`, etc.)
703
- * inside its `createPlugin`. That keeps the loader vendor-agnostic
704
- * and the auth story per-plugin explicit.
705
- *
706
- * `importer` is injectable so tests don't need real network or
707
- * sibling packages installed.
708
- */
709
- var LoaderError = class extends Error {
710
- name = "LoaderError";
711
- };
712
- var PluginLoader = class {
713
- config;
714
- context;
715
- importer;
716
- registry = /* @__PURE__ */ new Map();
717
- failures = [];
718
- constructor(config, context, importer = defaultImporter) {
719
- this.config = config;
720
- this.context = context;
721
- this.importer = importer;
722
- }
723
- /**
724
- * Imports every configured plugin and builds the capability registry.
725
- *
726
- * Never throws for a single plugin's failure — a missing vendor token,
727
- * an uninstalled package, or an unimplemented capability records a
728
- * {@link PluginLoadFailure} and the load continues. This is the
729
- * "soft-skip over hard-fail" contract: a command that needs a
730
- * capability learns it is absent via `has()` / `get()` (which
731
- * re-surfaces the original error), and orchestrators report the skip
732
- * in their summary. Inspect {@link loadFailures} for the full list.
733
- */
734
- async load() {
735
- const entries = Object.entries(this.config.providers);
736
- for (const [key, entry] of entries) {
737
- if (!entry) continue;
738
- if (entry.cardinality === "single") try {
739
- this.registry.set(key, await this.loadOne(key, entry.tuple));
740
- } catch (err) {
741
- this.recordFailure(key, entry.tuple, err);
742
- }
743
- else {
744
- const impls = [];
745
- for (const tuple of entry.tuples) try {
746
- impls.push(await this.loadOne(key, tuple));
747
- } catch (err) {
748
- this.recordFailure(key, tuple, err);
749
- }
750
- if (impls.length > 0) this.registry.set(key, impls);
751
- }
752
- }
753
- }
754
- recordFailure(key, tuple, err) {
755
- this.failures.push({
756
- key,
757
- provider: tuple.provider,
758
- packageName: tuple.packageName,
759
- error: err instanceof Error ? err : new Error(String(err))
760
- });
761
- }
762
- /** Providers that failed to load during {@link load}. Empty on a clean load. */
763
- loadFailures() {
764
- return this.failures;
765
- }
766
- /**
767
- * Type-safe lookup. Single-cardinality keys return one impl;
768
- * many-cardinality keys return an array. `ResolvedCapability<K>`
769
- * encodes the split via the `CARDINALITY` map.
770
- */
771
- get(key) {
772
- const impl = this.registry.get(key);
773
- if (impl === void 0) {
774
- const failure = this.failures.find((f) => f.key === key);
775
- if (failure) throw failure.error;
776
- throw new LoaderError(`capability \`${key}\` is not loaded — is it declared in holocron.config.json?`);
777
- }
778
- return impl;
779
- }
780
- /** Whether a capability has been loaded. */
781
- has(key) {
782
- return this.registry.has(key);
783
- }
784
- /** All capability keys currently loaded. Useful for the doctor command. */
785
- loadedKeys() {
786
- return Array.from(this.registry.keys());
787
- }
788
- /** Internal — invoke a plugin's capability factory and return the impl. */
789
- async loadOne(key, tuple) {
790
- const mod = await this.importer(tuple.packageName).catch((err) => {
791
- throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
792
- });
793
- if (isPluginModule(mod)) {
794
- const effectiveToken = this.context.cliTokens?.[tuple.provider] ?? this.context.cliToken;
795
- const factory = mod.createPlugin({
796
- ...this.projectDefaults(),
797
- ...this.context,
798
- ...effectiveToken !== void 0 ? { cliToken: effectiveToken } : {},
799
- cliTokens: void 0,
800
- ...tuple.options
801
- }).capabilities[key];
802
- if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
803
- return factory();
804
- }
805
- if (isCapabilityConfigModule(mod)) {
806
- const cap = mod.default;
807
- return this.loadOne(key, {
808
- provider: cap.provider,
809
- packageName: resolvePluginPackage(cap.provider),
810
- options: {
811
- ...cap.options,
812
- ...tuple.options
813
- }
814
- });
815
- }
816
- throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\` or a capability config ({ provider, options? })`);
817
- }
818
- /**
819
- * Project-level defaults that get merged into every plugin's options
820
- * unless overridden by the CLI context or per-plugin tuple options.
821
- * See `docs/wiki/specifications/tech-setup-and-config.spec.md` §Design.
822
- */
823
- projectDefaults() {
824
- const defaults = {};
825
- if (this.config.repo?.name) defaults.repo = this.config.repo.name;
826
- return defaults;
827
- }
828
- };
829
- /** Default importer — resolves from cwd first so the global CLI finds project plugins. */
830
- const defaultImporter = async (pkg) => {
831
- try {
832
- return import(pathToFileURL(createRequire(process.cwd() + "/").resolve(pkg)).href);
833
- } catch {
834
- return import(pkg);
835
- }
836
- };
837
- function isPluginModule(mod) {
838
- return typeof mod.createPlugin === "function";
839
- }
840
- function isCapabilityConfigModule(mod) {
841
- return typeof mod.default?.provider === "string";
842
- }
843
- //#endregion
844
927
  //#region src/commands/cleanup-preview.ts
845
928
  function prStateLabel(pr) {
846
929
  if (pr.merged) return style.success("merged");
@@ -853,6 +936,7 @@ async function runCleanupPreview(input) {
853
936
  // c8 ignore next -- real PluginLoader construction is integration-level; unit tests always supply loader
854
937
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
855
938
  await loader.load();
939
+ assertPluginsResolvable(loader, "cleanup-preview");
856
940
  logger.info({
857
941
  pr: input.prNumber,
858
942
  project: input.project
@@ -1101,12 +1185,59 @@ async function runClone(input) {
1101
1185
  };
1102
1186
  }
1103
1187
  //#endregion
1188
+ //#region src/commands/contexts.ts
1189
+ /**
1190
+ * Command → context. Keys are the command name as it lands in yargs'
1191
+ * `argv._` — the full path for sub-commands (`"auth set"`, `"upgrade
1192
+ * node"`), the bare verb otherwise.
1193
+ *
1194
+ * - **`global`** — needs nothing but the CLI binary. Works anywhere.
1195
+ * - **`repo-aware`** — reads `./holocron.config` + `./package.json`
1196
+ * relative to cwd, but never touches the plugin loader.
1197
+ * - **`workspace`** — needs the configured providers' plugin packages to
1198
+ * resolve (devDeps in a repo, or `pnpm exec`).
1199
+ */
1200
+ const COMMAND_CONTEXTS = {
1201
+ version: "global",
1202
+ clone: "global",
1203
+ new: "global",
1204
+ "upgrade node": "global",
1205
+ "upgrade deps": "global",
1206
+ "plugin create": "global",
1207
+ "auth set": "global",
1208
+ "auth unset": "global",
1209
+ "auth list": "global",
1210
+ "auth check": "global",
1211
+ "npm bump-versions": "global",
1212
+ "npm publish-initial": "global",
1213
+ "skills install": "global",
1214
+ "skills remove": "global",
1215
+ "skills update": "global",
1216
+ run: "repo-aware",
1217
+ ci: "repo-aware",
1218
+ "config show": "repo-aware",
1219
+ "sync-readme": "repo-aware",
1220
+ doctor: "workspace",
1221
+ setup: "workspace",
1222
+ "secret set": "workspace",
1223
+ "secrets sync": "workspace",
1224
+ deploy: "workspace",
1225
+ "cleanup-preview": "workspace",
1226
+ sync: "workspace",
1227
+ "sync-github": "workspace"
1228
+ };
1229
+ /** Command names in a given context, in registration order. */
1230
+ function commandsInContext(context) {
1231
+ return Object.entries(COMMAND_CONTEXTS).filter(([, c]) => c === context).map(([name]) => name);
1232
+ }
1233
+ //#endregion
1104
1234
  //#region src/commands/deploy.ts
1105
1235
  async function runDeploy(input) {
1106
1236
  const print = input.print ?? ((line) => console.log(line));
1107
1237
  const logger = input.logger ?? getLogger();
1108
1238
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1109
1239
  await loader.load();
1240
+ assertPluginsResolvable(loader, "deploy");
1110
1241
  const dryRun = input.context.dryRun ?? false;
1111
1242
  logger.info({
1112
1243
  branch: input.branch,
@@ -1162,6 +1293,7 @@ async function runDoctor(input) {
1162
1293
  const print = input.print ?? ((line) => console.log(line));
1163
1294
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1164
1295
  await withSpinner("Loading plugins…", () => loader.load());
1296
+ assertPluginsResolvable(loader, "doctor");
1165
1297
  const rows = [];
1166
1298
  const config = input.loaded.resolved;
1167
1299
  print(style.header(`Holocron doctor — ${config.name}`));
@@ -3031,6 +3163,7 @@ async function runSecretSet(input) {
3031
3163
  const logger = input.logger ?? getLogger();
3032
3164
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3033
3165
  await loader.load();
3166
+ assertPluginsResolvable(loader, "secret set");
3034
3167
  const dryRun = input.context.dryRun ?? false;
3035
3168
  const scope = input.scope ?? { kind: "repo" };
3036
3169
  if (!loader.has("secrets")) throw new Error("`secrets` capability is not configured — add a `secrets` provider to holocron.config.json");
@@ -3104,6 +3237,7 @@ async function runSecretsSync(input) {
3104
3237
  const logger = input.logger ?? getLogger();
3105
3238
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3106
3239
  await withSpinner("Loading plugins…", () => loader.load());
3240
+ assertPluginsResolvable(loader, "secrets sync");
3107
3241
  logger.info({
3108
3242
  environment: input.environmentId,
3109
3243
  dryRun: (input.context.dryRun ?? false) || void 0
@@ -4373,6 +4507,7 @@ async function runSetup(input) {
4373
4507
  const print = input.print ?? ((line) => console.log(line));
4374
4508
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
4375
4509
  await withSpinner("Loading plugins…", () => loader.load());
4510
+ assertPluginsResolvable(loader, "setup");
4376
4511
  const config = input.loaded.resolved;
4377
4512
  const dryRun = input.context.dryRun ?? false;
4378
4513
  const steps = [];
@@ -6886,6 +7021,11 @@ const resolveSyncToken = createFeatureResolver({
6886
7021
  envName: "HOLOCRON_SYNC_TOKEN",
6887
7022
  keyringKey: "github.sync"
6888
7023
  });
7024
+ /**
7025
+ * Error class names whose `.message` is a complete, actionable sentence —
7026
+ * the top-level catch prints it and suppresses the stack trace.
7027
+ */
7028
+ const USER_FACING_ERRORS = /* @__PURE__ */ new Set(["WorkspaceContextError", "ConfigFileError"]);
6889
7029
  const { version: CLI_VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
6890
7030
  /** Whether to print the correlation id at command end (`--debug` / `--verbose`). */
6891
7031
  let printRunId = false;
@@ -6938,6 +7078,8 @@ function tokenContext(rawTokens) {
6938
7078
  init(CLI_VERSION);
6939
7079
  const updateCheckPromise = checkForUpdates(CLI_VERSION);
6940
7080
  let finishCommand = () => {};
7081
+ /** Set by `.fail()` once it has printed an error, so the outer catch doesn't repeat it. */
7082
+ let errorReported = false;
6941
7083
  try {
6942
7084
  await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").parserConfiguration({ "populate--": true }).option("dry-run", {
6943
7085
  type: "boolean",
@@ -7739,9 +7881,23 @@ try {
7739
7881
  })).status === "fail") process.exitCode = 1;
7740
7882
  }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
7741
7883
  await runAuthList();
7742
- }).demandCommand(1, "Run `holocron auth --help` to see available auth subcommands."), () => {}).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
7884
+ }).demandCommand(1, "Run `holocron auth --help` to see available auth subcommands."), () => {}).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().epilogue(`Execution contexts:
7885
+ global works from a bare 'npm i -g': ${commandsInContext("global").join(", ")}\n repo-aware needs ./holocron.config in cwd: ${commandsInContext("repo-aware").join(", ")}\n workspace also needs the plugin packages: ${commandsInContext("workspace").join(", ")}\n https://theholocron.github.io/holocron/execution-contexts`).fail((msg, err) => {
7886
+ if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
7887
+ captureException(err);
7888
+ getLogger().error(err.message);
7889
+ errorReported = true;
7890
+ process.exitCode = 1;
7891
+ return;
7892
+ }
7893
+ if (err) throw err;
7894
+ getLogger().error(msg);
7895
+ errorReported = true;
7896
+ process.exitCode = 1;
7897
+ }).parse();
7743
7898
  } catch (err) {
7744
7899
  captureException(err);
7900
+ if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) getLogger().error(err.message);
7745
7901
  if (!process.exitCode) process.exitCode = 1;
7746
7902
  }
7747
7903
  finishCommand(!process.exitCode);