@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/README.md +17 -1
- package/dist/cli.mjs +611 -388
- package/dist/cli.mjs.map +1 -1
- package/package.json +4 -4
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
|
+
"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
|
|
1869
|
-
|
|
1870
|
-
|
|
1856
|
+
status,
|
|
1857
|
+
bumped,
|
|
1858
|
+
skipped
|
|
1871
1859
|
};
|
|
1872
1860
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
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
|
-
|
|
1893
|
-
return !pkg.private && pkg.name ? [pkg.name] : [];
|
|
1864
|
+
if (!isDir(pkgDir)) continue;
|
|
1894
1865
|
} catch {
|
|
1895
|
-
|
|
1866
|
+
continue;
|
|
1896
1867
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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$
|
|
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$
|
|
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("
|
|
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
|
|
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 (
|
|
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
|
-
}).
|
|
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().
|
|
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);
|