@theholocron/cli 4.16.4 → 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 +16 -0
- package/dist/cli.mjs +325 -169
- package/dist/cli.mjs.map +1 -1
- package/package.json +4 -4
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
|
|
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
|
|
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
|
|
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
|
|
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().
|
|
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);
|