@theholocron/cli 4.16.5 → 4.17.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/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
+ "bump-versions": "global",
1212
+ publish: "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}`));
@@ -1712,226 +1844,55 @@ async function runNpmBumpVersions(input) {
1712
1844
  let entries;
1713
1845
  try {
1714
1846
  entries = listDir(packagesDir);
1715
- } catch {
1716
- const status = dryRun ? "dry-run" : "ok";
1717
- logger.info({
1718
- version,
1719
- status,
1720
- bumped: bumped.length,
1721
- skipped: skipped.length
1722
- }, "npm bump-versions: done");
1723
- return {
1724
- status,
1725
- bumped,
1726
- skipped
1727
- };
1728
- }
1729
- for (const entry of entries) {
1730
- const pkgDir = join(packagesDir, entry);
1731
- try {
1732
- if (!isDir(pkgDir)) continue;
1733
- } catch {
1734
- continue;
1735
- }
1736
- const pkgFile = join(pkgDir, "package.json");
1737
- let pkg;
1738
- try {
1739
- pkg = JSON.parse(readFile(pkgFile));
1740
- } catch {
1741
- print(` ! skipping packages/${entry}: no package.json or malformed JSON`);
1742
- continue;
1743
- }
1744
- if (pkg.private) {
1745
- print(` · skipping private package packages/${entry}`);
1746
- skipped.push(`packages/${entry}`);
1747
- continue;
1748
- }
1749
- bumpFile(pkgFile, `packages/${entry}`);
1750
- }
1751
- const status = dryRun ? "dry-run" : "ok";
1752
- logger.info({
1753
- version,
1754
- status,
1755
- bumped: bumped.length,
1756
- skipped: skipped.length
1757
- }, "npm bump-versions: done");
1758
- return {
1759
- status,
1760
- bumped,
1761
- skipped
1762
- };
1763
- }
1764
- //#endregion
1765
- //#region src/commands/npm-publish-initial.ts
1766
- /**
1767
- * `holocron npm publish-initial` — bottles up the chicken-and-egg
1768
- * bootstrap that every new npm-published holocron monorepo hits.
1769
- *
1770
- * npm requires a package to exist before Trusted Publishing can be
1771
- * configured on it. So the first publish has to happen outside the
1772
- * OIDC flow — using either a browser-auth session (`npm login
1773
- * --auth-type=web`) or an ephemeral automation token. This command
1774
- * runs the publish step + tells you exactly what to do next.
1775
- *
1776
- * Workflow:
1777
- *
1778
- * $ npm login --auth-type=web # one-time, browser-based
1779
- * $ pnpm install --frozen-lockfile
1780
- * $ pnpm build
1781
- * $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
1782
- *
1783
- * The command itself only handles the publish step + the post-publish
1784
- * Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
1785
- * stay outside the command (no pnpm-inside-pnpm).
1786
- *
1787
- * If `NPM_TOKEN` is detected in env, the command prints a final
1788
- * "revoke this token at <url>" reminder — same pattern as `rando vc
1789
- * setup` for the ephemeral GH admin PAT.
1790
- */
1791
- async function runNpmPublishInitial(input = {}) {
1792
- const print = input.print ?? ((line) => console.log(line));
1793
- const logger = input.logger ?? getLogger();
1794
- const cwd = input.cwd ?? process.cwd();
1795
- const tag = input.tag ?? "alpha";
1796
- const dryRun = input.dryRun ?? false;
1797
- const otp = input.otp;
1798
- const env = makeEnv(input.env);
1799
- const exec = input.exec ?? defaultExec$2;
1800
- const publishArgs = [
1801
- "-r",
1802
- "--filter=./packages/*",
1803
- "publish",
1804
- "--access",
1805
- "public",
1806
- "--no-git-checks",
1807
- "--tag",
1808
- tag,
1809
- ...otp ? ["--otp", otp] : []
1810
- ];
1811
- print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
1812
- print(` cwd: ${cwd}`);
1813
- print(` tag: ${tag}`);
1814
- logger.info({
1815
- tag,
1816
- dryRun: dryRun || void 0
1817
- }, "npm publish-initial: start");
1818
- if (otp) print(` otp: <${otp.length} chars>`);
1819
- print("");
1820
- print(" → verifying npm auth (`npm whoami`)…");
1821
- const whoami = await exec("npm", ["whoami"], { cwd });
1822
- if (whoami.exitCode !== 0) {
1823
- const message = "npm is not authenticated. Run `npm login --auth-type=web` (browser flow, no token stored) or `npm login`, then re-run this command.";
1824
- print(` ✗ ${message}`);
1825
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
1826
- logger.warn({ reason: "npm not authenticated" }, "npm publish-initial: done");
1827
- return {
1828
- status: "fail",
1829
- message,
1830
- packageNames
1831
- };
1832
- }
1833
- print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
1834
- const packageNames = input.packages ?? discoverPublicPackages(cwd);
1835
- const repoName = input.repoName ?? await resolveRepoName(cwd, exec);
1836
- if (dryRun) {
1837
- print("");
1838
- print(" … (dry-run) skipping actual publish");
1839
- print(` would run: pnpm ${publishArgs.join(" ")}`);
1840
- printNextSteps$1(print, env, packageNames, repoName);
1841
- logger.info({
1842
- tag,
1843
- status: "dry-run",
1844
- packages: packageNames.length
1845
- }, "npm publish-initial: done");
1846
- return {
1847
- status: "dry-run",
1848
- message: "dry-run — no publish executed",
1849
- packageNames
1850
- };
1851
- }
1852
- print("");
1853
- print(" → publishing all public @theholocron/* packages…");
1854
- const publish = await exec("pnpm", publishArgs, { cwd });
1855
- if (publish.exitCode !== 0) {
1856
- const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
1857
- print(` ✗ ${message}`);
1858
- if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
1859
- print("");
1860
- print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
1861
- print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
1862
- }
1863
- logger.warn({
1864
- tag,
1865
- reason: message
1866
- }, "npm publish-initial: done");
1847
+ } catch {
1848
+ const status = dryRun ? "dry-run" : "ok";
1849
+ logger.info({
1850
+ version,
1851
+ status,
1852
+ bumped: bumped.length,
1853
+ skipped: skipped.length
1854
+ }, "npm bump-versions: done");
1867
1855
  return {
1868
- status: "fail",
1869
- message,
1870
- packageNames
1856
+ status,
1857
+ bumped,
1858
+ skipped
1871
1859
  };
1872
1860
  }
1873
- print(" ✓ publish complete");
1874
- printNextSteps$1(print, env, packageNames, repoName);
1875
- logger.info({
1876
- tag,
1877
- status: "ok",
1878
- packages: packageNames.length
1879
- }, "npm publish-initial: done");
1880
- return {
1881
- status: "ok",
1882
- packageNames
1883
- };
1884
- }
1885
- function discoverPublicPackages(cwd) {
1886
- const packagesDir = join(cwd, "packages");
1887
- if (!existsSync(packagesDir)) return [];
1888
- return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
1889
- const pkgPath = join(packagesDir, e.name, "package.json");
1890
- if (!existsSync(pkgPath)) return [];
1861
+ for (const entry of entries) {
1862
+ const pkgDir = join(packagesDir, entry);
1891
1863
  try {
1892
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1893
- return !pkg.private && pkg.name ? [pkg.name] : [];
1864
+ if (!isDir(pkgDir)) continue;
1894
1865
  } catch {
1895
- return [];
1866
+ continue;
1896
1867
  }
1897
- });
1898
- }
1899
- async function resolveRepoName(cwd, exec) {
1900
- const result = await exec("git", [
1901
- "remote",
1902
- "get-url",
1903
- "origin"
1904
- ], { cwd });
1905
- if (result.exitCode !== 0) return "unknown";
1906
- return /[/:]([^/:]+?)(?:\.git)?$/.exec(result.stdout.trim())?.[1] ?? "unknown";
1907
- }
1908
- function printNextSteps$1(print, env, packageNames, repoName) {
1909
- print("");
1910
- print(" → next: configure Trusted Publisher for each package on npm:");
1911
- for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
1912
- print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: release.yml`);
1913
- if (env.get("NPM_TOKEN")) {
1914
- print("");
1915
- print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
1916
- print(" https://www.npmjs.com/settings/~/tokens");
1868
+ const pkgFile = join(pkgDir, "package.json");
1869
+ let pkg;
1870
+ try {
1871
+ pkg = JSON.parse(readFile(pkgFile));
1872
+ } catch {
1873
+ print(` ! skipping packages/${entry}: no package.json or malformed JSON`);
1874
+ continue;
1875
+ }
1876
+ if (pkg.private) {
1877
+ print(` · skipping private package packages/${entry}`);
1878
+ skipped.push(`packages/${entry}`);
1879
+ continue;
1880
+ }
1881
+ bumpFile(pkgFile, `packages/${entry}`);
1917
1882
  }
1918
- }
1919
- const defaultExec$2 = async (cmd, args, opts) => {
1920
- const result = spawnSync(cmd, args, {
1921
- cwd: opts.cwd,
1922
- encoding: "utf8",
1923
- stdio: [
1924
- "inherit",
1925
- "pipe",
1926
- "pipe"
1927
- ]
1928
- });
1883
+ const status = dryRun ? "dry-run" : "ok";
1884
+ logger.info({
1885
+ version,
1886
+ status,
1887
+ bumped: bumped.length,
1888
+ skipped: skipped.length
1889
+ }, "npm bump-versions: done");
1929
1890
  return {
1930
- exitCode: result.status ?? -1,
1931
- stdout: result.stdout ?? "",
1932
- stderr: result.stderr ?? ""
1891
+ status,
1892
+ bumped,
1893
+ skipped
1933
1894
  };
1934
- };
1895
+ }
1935
1896
  //#endregion
1936
1897
  //#region src/commands/plugin-create/template-inputs.ts
1937
1898
  /** Derive the standard defaults from a slug + vendor name. */
@@ -2905,7 +2866,7 @@ function runPluginCreate(input) {
2905
2866
  filesWritten.push(resolvedPath);
2906
2867
  }
2907
2868
  if (!input.dryRun && !input.noVerify) {
2908
- const execFn = input.exec ?? defaultExec$1;
2869
+ const execFn = input.exec ?? defaultExec$2;
2909
2870
  const pkg = `@theholocron/holocron-plugin-${inputs.slug}`;
2910
2871
  print("");
2911
2872
  print(" Verifying scaffold…");
@@ -2956,7 +2917,7 @@ function runPluginCreate(input) {
2956
2917
  };
2957
2918
  }
2958
2919
  }
2959
- if (!input.dryRun) printNextSteps(print, inputs);
2920
+ if (!input.dryRun) printNextSteps$1(print, inputs);
2960
2921
  logger.info({
2961
2922
  slug: inputs.slug,
2962
2923
  capability: inputs.capability,
@@ -3005,13 +2966,13 @@ function defaultWrite(filepath, content) {
3005
2966
  mkdirSync(path.dirname(filepath), { recursive: true });
3006
2967
  writeFileSync(filepath, content, "utf8");
3007
2968
  }
3008
- function defaultExec$1(cmd, args, opts) {
2969
+ function defaultExec$2(cmd, args, opts) {
3009
2970
  execFileSync(cmd, args, {
3010
2971
  cwd: opts.cwd,
3011
2972
  stdio: opts.stdio
3012
2973
  });
3013
2974
  }
3014
- function printNextSteps(print, inputs) {
2975
+ function printNextSteps$1(print, inputs) {
3015
2976
  print("");
3016
2977
  print(` Scaffolded @theholocron/holocron-plugin-${inputs.slug} (18 files).`);
3017
2978
  print("");
@@ -3025,12 +2986,242 @@ function printNextSteps(print, inputs) {
3025
2986
  print(" 7. Commit + push when capability is functionally complete.");
3026
2987
  }
3027
2988
  //#endregion
2989
+ //#region src/commands/publish.ts
2990
+ /**
2991
+ * `holocron publish --initial` — bottles up the chicken-and-egg bootstrap
2992
+ * that every new npm-published holocron monorepo hits.
2993
+ *
2994
+ * npm requires a package to exist before Trusted Publishing can be
2995
+ * configured on it. So the first publish has to happen outside the OIDC
2996
+ * flow — a browser-auth session (`npm login --auth-type=web`) or an
2997
+ * ephemeral automation token. This command drives the login step itself
2998
+ * (when `npm whoami` shows you're not authenticated), runs the publish
2999
+ * step, and tells you exactly what to do next.
3000
+ *
3001
+ * Workflow:
3002
+ *
3003
+ * $ pnpm install --frozen-lockfile
3004
+ * $ pnpm build
3005
+ * $ pnpm exec tsx packages/cli/src/cli.ts publish --initial
3006
+ *
3007
+ * `npm login --auth-type=web` runs automatically the first time — no
3008
+ * separate manual step. `pnpm install` + `pnpm build` stay outside the
3009
+ * command (no pnpm-inside-pnpm).
3010
+ *
3011
+ * `--initial` is required today — this command only implements the
3012
+ * bootstrap publish. A non-initial `holocron publish` (for a manual publish
3013
+ * outside the semantic-release/OIDC steady state) isn't built yet; the flag
3014
+ * exists so the surface doesn't need another rename when it is.
3015
+ *
3016
+ * If `NPM_TOKEN` is detected in env, the command prints a final
3017
+ * "revoke this token at <url>" reminder — same pattern as `rando vc
3018
+ * setup` for the ephemeral GH admin PAT.
3019
+ */
3020
+ async function runPublish(input = {}) {
3021
+ const print = input.print ?? ((line) => console.log(line));
3022
+ const logger = input.logger ?? getLogger();
3023
+ const cwd = input.cwd ?? process.cwd();
3024
+ const tag = input.tag ?? "alpha";
3025
+ const dryRun = input.dryRun ?? false;
3026
+ const otp = input.otp;
3027
+ const env = makeEnv(input.env);
3028
+ const exec = input.exec ?? defaultExec$1;
3029
+ const login = input.login ?? defaultLogin;
3030
+ const publishArgs = [
3031
+ ...hasPackagesDir(cwd) ? ["-r", "--filter=./packages/*"] : [],
3032
+ "publish",
3033
+ "--access",
3034
+ "public",
3035
+ "--no-git-checks",
3036
+ "--tag",
3037
+ tag,
3038
+ ...otp ? ["--otp", otp] : []
3039
+ ];
3040
+ print(`Holocron publish --initial${dryRun ? " (dry-run)" : ""}`);
3041
+ print(` cwd: ${cwd}`);
3042
+ print(` tag: ${tag}`);
3043
+ logger.info({
3044
+ tag,
3045
+ dryRun: dryRun || void 0
3046
+ }, "publish --initial: start");
3047
+ if (otp) print(` otp: <${otp.length} chars>`);
3048
+ print("");
3049
+ print(" → verifying npm auth (`npm whoami`)…");
3050
+ let whoami = await exec("npm", ["whoami"], { cwd });
3051
+ if (whoami.exitCode !== 0) {
3052
+ print(" not authenticated — running `npm login --auth-type=web`…");
3053
+ logger.info({}, "publish --initial: npm login");
3054
+ if ((await login(cwd)).exitCode !== 0) {
3055
+ const message = "npm login failed. Run `npm login --auth-type=web` (or `npm login`) manually, then re-run.";
3056
+ print(` ✗ ${message}`);
3057
+ const packageNames = input.packages ?? discoverPublicPackages(cwd);
3058
+ logger.warn({ reason: "npm login failed" }, "publish --initial: done");
3059
+ return {
3060
+ status: "fail",
3061
+ message,
3062
+ packageNames
3063
+ };
3064
+ }
3065
+ whoami = await exec("npm", ["whoami"], { cwd });
3066
+ if (whoami.exitCode !== 0) {
3067
+ const message = "npm login completed but `npm whoami` still fails — check your npm account.";
3068
+ print(` ✗ ${message}`);
3069
+ const packageNames = input.packages ?? discoverPublicPackages(cwd);
3070
+ logger.warn({ reason: "npm whoami still fails after login" }, "publish --initial: done");
3071
+ return {
3072
+ status: "fail",
3073
+ message,
3074
+ packageNames
3075
+ };
3076
+ }
3077
+ }
3078
+ print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
3079
+ const packageNames = input.packages ?? discoverPublicPackages(cwd);
3080
+ const repoName = input.repoName ?? await resolveRepoName(cwd, exec);
3081
+ if (packageNames.length === 0) {
3082
+ const message = "nothing to publish (no packages/* and root package.json is private or unnamed)";
3083
+ print(` ✗ ${message}`);
3084
+ logger.warn({ reason: message }, "publish --initial: done");
3085
+ return {
3086
+ status: "fail",
3087
+ message,
3088
+ packageNames
3089
+ };
3090
+ }
3091
+ if (dryRun) {
3092
+ print("");
3093
+ print(" … (dry-run) skipping actual publish");
3094
+ print(` would run: pnpm ${publishArgs.join(" ")}`);
3095
+ printNextSteps(print, env, packageNames, repoName);
3096
+ logger.info({
3097
+ tag,
3098
+ status: "dry-run",
3099
+ packages: packageNames.length
3100
+ }, "publish --initial: done");
3101
+ return {
3102
+ status: "dry-run",
3103
+ message: "dry-run — no publish executed",
3104
+ packageNames
3105
+ };
3106
+ }
3107
+ print("");
3108
+ print(" → publishing all public @theholocron/* packages…");
3109
+ const publish = await exec("pnpm", publishArgs, { cwd });
3110
+ if (publish.exitCode !== 0) {
3111
+ const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
3112
+ print(` ✗ ${message}`);
3113
+ if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
3114
+ print("");
3115
+ print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
3116
+ print(` pnpm exec tsx packages/cli/src/cli.ts publish --initial --otp <6-digit-code>`);
3117
+ }
3118
+ logger.warn({
3119
+ tag,
3120
+ reason: message
3121
+ }, "publish --initial: done");
3122
+ return {
3123
+ status: "fail",
3124
+ message,
3125
+ packageNames
3126
+ };
3127
+ }
3128
+ print(" ✓ publish complete");
3129
+ printNextSteps(print, env, packageNames, repoName);
3130
+ logger.info({
3131
+ tag,
3132
+ status: "ok",
3133
+ packages: packageNames.length
3134
+ }, "publish --initial: done");
3135
+ return {
3136
+ status: "ok",
3137
+ packageNames
3138
+ };
3139
+ }
3140
+ function hasPackagesDir(cwd) {
3141
+ return existsSync(join(cwd, "packages"));
3142
+ }
3143
+ /** Read a `package.json`'s `name`, when it's public (`!private && name`). */
3144
+ function publicPackageName(pkgPath) {
3145
+ if (!existsSync(pkgPath)) return void 0;
3146
+ try {
3147
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
3148
+ return !pkg.private && pkg.name ? pkg.name : void 0;
3149
+ } catch {
3150
+ return;
3151
+ }
3152
+ }
3153
+ /**
3154
+ * Monorepo (`packages/` present): every public `packages/*` workspace.
3155
+ * Single package (no `packages/`): the root `package.json`, if it's public —
3156
+ * most `node-template` scaffolds are one package at the repo root.
3157
+ */
3158
+ function discoverPublicPackages(cwd) {
3159
+ if (!hasPackagesDir(cwd)) {
3160
+ const name = publicPackageName(join(cwd, "package.json"));
3161
+ return name ? [name] : [];
3162
+ }
3163
+ const packagesDir = join(cwd, "packages");
3164
+ return readdirSync(packagesDir, { withFileTypes: true }).filter((e) => e.isDirectory()).flatMap((e) => {
3165
+ const name = publicPackageName(join(packagesDir, e.name, "package.json"));
3166
+ return name ? [name] : [];
3167
+ });
3168
+ }
3169
+ async function resolveRepoName(cwd, exec) {
3170
+ const result = await exec("git", [
3171
+ "remote",
3172
+ "get-url",
3173
+ "origin"
3174
+ ], { cwd });
3175
+ if (result.exitCode !== 0) return "unknown";
3176
+ return /[/:]([^/:]+?)(?:\.git)?$/.exec(result.stdout.trim())?.[1] ?? "unknown";
3177
+ }
3178
+ function printNextSteps(print, env, packageNames, repoName) {
3179
+ print("");
3180
+ print(" → next: configure Trusted Publisher for each package on npm:");
3181
+ for (const name of packageNames) print(` https://www.npmjs.com/package/${name}/access`);
3182
+ print(` Publisher: GitHub Actions Org: theholocron Repo: ${repoName} Workflow: release.yml`);
3183
+ if (env.get("NPM_TOKEN")) {
3184
+ print("");
3185
+ print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
3186
+ print(" https://www.npmjs.com/settings/~/tokens");
3187
+ }
3188
+ }
3189
+ const defaultExec$1 = async (cmd, args, opts) => {
3190
+ const result = spawnSync(cmd, args, {
3191
+ cwd: opts.cwd,
3192
+ encoding: "utf8",
3193
+ stdio: [
3194
+ "inherit",
3195
+ "pipe",
3196
+ "pipe"
3197
+ ]
3198
+ });
3199
+ return {
3200
+ exitCode: result.status ?? -1,
3201
+ stdout: result.stdout ?? "",
3202
+ stderr: result.stderr ?? ""
3203
+ };
3204
+ };
3205
+ /**
3206
+ * Fully interactive — `npm login --auth-type=web` prints a URL and waits for
3207
+ * the browser flow to complete; the operator needs to see that live, so all
3208
+ * three stdio streams are inherited (unlike `defaultExec`, which pipes
3209
+ * stdout/stderr for capture).
3210
+ */
3211
+ const defaultLogin = async (cwd) => {
3212
+ return { exitCode: spawnSync("npm", ["login", "--auth-type=web"], {
3213
+ cwd,
3214
+ stdio: "inherit"
3215
+ }).status ?? -1 };
3216
+ };
3217
+ //#endregion
3028
3218
  //#region src/commands/secret-set.ts
3029
3219
  async function runSecretSet(input) {
3030
3220
  const print = input.print ?? ((line) => console.log(line));
3031
3221
  const logger = input.logger ?? getLogger();
3032
3222
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3033
3223
  await loader.load();
3224
+ assertPluginsResolvable(loader, "secret set");
3034
3225
  const dryRun = input.context.dryRun ?? false;
3035
3226
  const scope = input.scope ?? { kind: "repo" };
3036
3227
  if (!loader.has("secrets")) throw new Error("`secrets` capability is not configured — add a `secrets` provider to holocron.config.json");
@@ -3104,6 +3295,7 @@ async function runSecretsSync(input) {
3104
3295
  const logger = input.logger ?? getLogger();
3105
3296
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3106
3297
  await withSpinner("Loading plugins…", () => loader.load());
3298
+ assertPluginsResolvable(loader, "secrets sync");
3107
3299
  logger.info({
3108
3300
  environment: input.environmentId,
3109
3301
  dryRun: (input.context.dryRun ?? false) || void 0
@@ -4373,6 +4565,7 @@ async function runSetup(input) {
4373
4565
  const print = input.print ?? ((line) => console.log(line));
4374
4566
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
4375
4567
  await withSpinner("Loading plugins…", () => loader.load());
4568
+ assertPluginsResolvable(loader, "setup");
4376
4569
  const config = input.loaded.resolved;
4377
4570
  const dryRun = input.context.dryRun ?? false;
4378
4571
  const steps = [];
@@ -6886,6 +7079,11 @@ const resolveSyncToken = createFeatureResolver({
6886
7079
  envName: "HOLOCRON_SYNC_TOKEN",
6887
7080
  keyringKey: "github.sync"
6888
7081
  });
7082
+ /**
7083
+ * Error class names whose `.message` is a complete, actionable sentence —
7084
+ * the top-level catch prints it and suppresses the stack trace.
7085
+ */
7086
+ const USER_FACING_ERRORS = /* @__PURE__ */ new Set(["WorkspaceContextError", "ConfigFileError"]);
6889
7087
  const { version: CLI_VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
6890
7088
  /** Whether to print the correlation id at command end (`--debug` / `--verbose`). */
6891
7089
  let printRunId = false;
@@ -6938,6 +7136,8 @@ function tokenContext(rawTokens) {
6938
7136
  init(CLI_VERSION);
6939
7137
  const updateCheckPromise = checkForUpdates(CLI_VERSION);
6940
7138
  let finishCommand = () => {};
7139
+ /** Set by `.fail()` once it has printed an error, so the outer catch doesn't repeat it. */
7140
+ let errorReported = false;
6941
7141
  try {
6942
7142
  await yargs(hideBin(process.argv)).scriptName("").usage("holocron <command> [options]").parserConfiguration({ "populate--": true }).option("dry-run", {
6943
7143
  type: "boolean",
@@ -7195,7 +7395,7 @@ try {
7195
7395
  project: argv.project,
7196
7396
  ...argv.repo ? { repo: argv.repo } : {}
7197
7397
  })).status === "fail") process.exitCode = 1;
7198
- }).command("npm", "npm-related monorepo utilities", (y) => y.command("bump-versions <new-version>", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (yy) => yy.positional("new-version", {
7398
+ }).command("bump-versions <new-version>", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (y) => y.positional("new-version", {
7199
7399
  type: "string",
7200
7400
  demandOption: true,
7201
7401
  describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
@@ -7205,7 +7405,11 @@ try {
7205
7405
  cwd: argv.cwd,
7206
7406
  dryRun: argv.dryRun
7207
7407
  })).status === "fail") process.exitCode = 1;
7208
- }).command("publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (yy) => yy.option("tag", {
7408
+ }).command("publish", "Publish @theholocron/* packages to npm", (y) => y.option("initial", {
7409
+ type: "boolean",
7410
+ default: false,
7411
+ describe: "One-shot bootstrap publish for trusted-publishing-eligible packages (npm needs the package to exist before Trusted Publishing can be configured for it). Required today — a non-initial `publish` isn't implemented yet."
7412
+ }).option("tag", {
7209
7413
  type: "string",
7210
7414
  default: "alpha",
7211
7415
  describe: "npm distribution tag (defaults to alpha)"
@@ -7213,13 +7417,18 @@ try {
7213
7417
  type: "string",
7214
7418
  describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
7215
7419
  }), async (argv) => {
7216
- if ((await runNpmPublishInitial({
7420
+ if (!argv.initial) {
7421
+ getLogger().error("publish: only `--initial` is supported today. Run `holocron publish --initial`.");
7422
+ process.exitCode = 1;
7423
+ return;
7424
+ }
7425
+ if ((await runPublish({
7217
7426
  cwd: argv.cwd,
7218
7427
  tag: argv.tag,
7219
7428
  dryRun: argv.dryRun,
7220
7429
  ...argv.otp ? { otp: argv.otp } : {}
7221
7430
  })).status === "fail") process.exitCode = 1;
7222
- }).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)", (y) => y.positional("steps", {
7431
+ }).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki)", (y) => y.positional("steps", {
7223
7432
  type: "string",
7224
7433
  array: true,
7225
7434
  describe: "Steps to run: labels, properties, teams, topics, keywords, description, homepage, readme, workflows, scripts, wiki (default: all)"
@@ -7739,9 +7948,23 @@ try {
7739
7948
  })).status === "fail") process.exitCode = 1;
7740
7949
  }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
7741
7950
  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();
7951
+ }).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:
7952
+ 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) => {
7953
+ if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
7954
+ captureException(err);
7955
+ getLogger().error(err.message);
7956
+ errorReported = true;
7957
+ process.exitCode = 1;
7958
+ return;
7959
+ }
7960
+ if (err) throw err;
7961
+ getLogger().error(msg);
7962
+ errorReported = true;
7963
+ process.exitCode = 1;
7964
+ }).parse();
7743
7965
  } catch (err) {
7744
7966
  captureException(err);
7967
+ if (!errorReported && err instanceof Error && USER_FACING_ERRORS.has(err.name)) getLogger().error(err.message);
7745
7968
  if (!process.exitCode) process.exitCode = 1;
7746
7969
  }
7747
7970
  finishCommand(!process.exitCode);