@theholocron/cli 2.0.0-alpha.1 → 2.0.0-alpha.6

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
@@ -1,11 +1,183 @@
1
1
  #!/usr/bin/env -S tsx
2
- import { t as CARDINALITY } from "./capabilities-QjjhVlDd.mjs";
3
- import { n as resolveConfig, t as ConfigError } from "./config-DWlIfFZm.mjs";
2
+ import { n as ProviderApiError, t as CARDINALITY } from "./capabilities-DapaKOlX.mjs";
3
+ import { a as ConfigError, c as resolvePluginPackage, i as setToken, n as getToken, o as resolveConfig, r as listStoredProviders, t as deleteToken } from "./keyring-DwNEmrBc.mjs";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
5
  import yargs from "yargs";
5
6
  import { hideBin } from "yargs/helpers";
7
+ import path, { join } from "node:path";
6
8
  import { spawnSync } from "node:child_process";
7
9
  import { readFile, stat } from "node:fs/promises";
8
- import { join } from "node:path";
10
+ //#region src/commands/auth.ts
11
+ /**
12
+ * `holocron auth <subcommand>` — manage bootstrap credentials in the
13
+ * OS keyring.
14
+ *
15
+ * Subcommands:
16
+ * auth set <provider> [token] verify + store
17
+ * auth unset <provider> remove
18
+ * auth check <provider> re-verify a stored token
19
+ * auth list every provider with a stored entry
20
+ *
21
+ * See `.notes/tech-auth-bootstrap.spec.md`.
22
+ *
23
+ * Verification lives in each plugin as a top-level `verifyToken(token)`
24
+ * export. The auth command dynamically imports
25
+ * `@theholocron/holocron-plugin-<provider>` and calls it. Plugins that
26
+ * don't export `verifyToken` can still store — with a warning — because
27
+ * "no verify path" shouldn't block credential storage.
28
+ */
29
+ const defaultImporter$1 = async (pkg) => await import(pkg);
30
+ /**
31
+ * Resolve a token from (positional → HOLOCRON_<X> → vendor-native env).
32
+ * Keyring is NOT consulted here — `auth set` writes TO the keyring, so
33
+ * pulling FROM it would just re-store the same value.
34
+ */
35
+ function resolveAuthSetToken(input) {
36
+ const env = input.env ?? process.env;
37
+ const upper = input.provider.toUpperCase();
38
+ const holocronKey = `HOLOCRON_${upper}_TOKEN`;
39
+ return input.positional || env[holocronKey] || env[upper + "_TOKEN"] || null;
40
+ }
41
+ async function runAuthSet(input) {
42
+ const print = input.print ?? ((l) => console.log(l));
43
+ const importer = input.importer ?? defaultImporter$1;
44
+ const { provider } = input;
45
+ const token = resolveAuthSetToken({
46
+ provider,
47
+ positional: input.positional,
48
+ env: input.env
49
+ });
50
+ const packageName = resolvePluginPackage(provider);
51
+ if (!token) {
52
+ print(`no token supplied for \`${provider}\`.`);
53
+ print(` pass as positional arg: holocron auth set ${provider} <token>`);
54
+ print(` or via env: HOLOCRON_${provider.toUpperCase()}_TOKEN / ${provider.toUpperCase()}_TOKEN`);
55
+ const hint = await tryLoadHint(importer, packageName);
56
+ if (hint) print(` hint: ${hint}`);
57
+ return {
58
+ status: "fail",
59
+ message: "no token supplied"
60
+ };
61
+ }
62
+ let subject;
63
+ try {
64
+ const module = await importer(packageName);
65
+ if (typeof module.verifyToken === "function") {
66
+ const verified = await module.verifyToken(token);
67
+ if (!verified.ok) {
68
+ print(`token rejected by ${provider}: ${verified.message}`);
69
+ if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
70
+ return {
71
+ status: "fail",
72
+ message: verified.message
73
+ };
74
+ }
75
+ subject = verified.subject;
76
+ } else print(`(${provider} plugin has no verifyToken; storing without verification)`);
77
+ } catch (err) {
78
+ print(`cannot verify token — failed to load ${packageName}: ${err instanceof Error ? err.message : String(err)}`);
79
+ print(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`);
80
+ }
81
+ if (!setToken(provider, token)) {
82
+ print(`keyring unavailable — token not stored. Use env vars instead.`);
83
+ return {
84
+ status: "fail",
85
+ message: "keyring unavailable"
86
+ };
87
+ }
88
+ print(`stored ${provider} token${subject ? ` (${subject})` : ""}`);
89
+ return {
90
+ status: "ok",
91
+ ...subject ? { message: subject } : {}
92
+ };
93
+ }
94
+ function runAuthUnset(input) {
95
+ const print = input.print ?? ((l) => console.log(l));
96
+ if (deleteToken(input.provider)) {
97
+ print(`removed ${input.provider} token`);
98
+ return { status: "ok" };
99
+ }
100
+ print(`no stored token for ${input.provider}`);
101
+ return {
102
+ status: "skip",
103
+ message: "nothing to remove"
104
+ };
105
+ }
106
+ async function runAuthCheck(input) {
107
+ const print = input.print ?? ((l) => console.log(l));
108
+ const importer = input.importer ?? defaultImporter$1;
109
+ const { provider } = input;
110
+ const token = getToken(provider);
111
+ if (!token) {
112
+ print(`no stored token for ${provider}`);
113
+ return {
114
+ status: "skip",
115
+ message: "no stored token"
116
+ };
117
+ }
118
+ const packageName = resolvePluginPackage(provider);
119
+ try {
120
+ const module = await importer(packageName);
121
+ if (typeof module.verifyToken !== "function") {
122
+ print(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`);
123
+ return {
124
+ status: "ok",
125
+ message: "stored, unverified"
126
+ };
127
+ }
128
+ const verified = await module.verifyToken(token);
129
+ if (verified.ok) {
130
+ print(`${provider}: ok — ${verified.subject}`);
131
+ return {
132
+ status: "ok",
133
+ message: verified.subject
134
+ };
135
+ }
136
+ print(`${provider}: rejected — ${verified.message}`);
137
+ if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
138
+ return {
139
+ status: "fail",
140
+ message: verified.message
141
+ };
142
+ } catch (err) {
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ print(`${provider}: cannot verify — ${msg}`);
145
+ return {
146
+ status: "fail",
147
+ message: msg
148
+ };
149
+ }
150
+ }
151
+ async function runAuthList(input = {}) {
152
+ const print = input.print ?? ((l) => console.log(l));
153
+ const importer = input.importer ?? defaultImporter$1;
154
+ const providers = listStoredProviders();
155
+ if (providers.length === 0) {
156
+ print("no stored tokens.");
157
+ print("run: holocron auth set <provider> <token>");
158
+ return {
159
+ status: "ok",
160
+ message: "none"
161
+ };
162
+ }
163
+ for (const provider of providers.sort()) {
164
+ const check = await runAuthCheck({
165
+ provider,
166
+ importer,
167
+ print: () => {}
168
+ });
169
+ print(` ${check.status === "ok" ? "✓" : check.status === "fail" ? "✗" : "·"} ${provider}${check.message ? ` — ${check.message}` : ""}`);
170
+ }
171
+ return { status: "ok" };
172
+ }
173
+ async function tryLoadHint(importer, packageName) {
174
+ try {
175
+ return (await importer(packageName)).AUTH_HINT ?? null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+ //#endregion
9
181
  //#region src/loader.ts
10
182
  var LoaderError = class extends Error {
11
183
  name = "LoaderError";
@@ -58,12 +230,23 @@ var PluginLoader = class {
58
230
  });
59
231
  if (typeof module.createPlugin !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\``);
60
232
  const factory = module.createPlugin({
233
+ ...this.projectDefaults(),
61
234
  ...this.context,
62
235
  ...tuple.options
63
236
  }).capabilities[key];
64
237
  if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
65
238
  return factory();
66
239
  }
240
+ /**
241
+ * Project-level defaults that get merged into every plugin's options
242
+ * unless overridden by the CLI context or per-plugin tuple options.
243
+ * See `.notes/tech-setup-and-config.spec.md` §Design.
244
+ */
245
+ projectDefaults() {
246
+ const defaults = {};
247
+ if (this.config.project.repo) defaults.repo = this.config.project.repo;
248
+ return defaults;
249
+ }
67
250
  };
68
251
  /** Default importer — native dynamic import. */
69
252
  const defaultImporter = async (pkg) => {
@@ -193,6 +376,76 @@ function pad(s, width) {
193
376
  return s.length >= width ? s : s + " ".repeat(width - s.length);
194
377
  }
195
378
  //#endregion
379
+ //#region src/commands/npm-bump-versions.ts
380
+ async function runNpmBumpVersions(input) {
381
+ const print = input.print ?? ((line) => console.log(line));
382
+ const cwd = input.cwd ?? process.cwd();
383
+ const { version, dryRun = false } = input;
384
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
385
+ const writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
386
+ const listDir = input.listDir ?? ((p) => readdirSync(p));
387
+ const isDir = input.isDir ?? ((p) => statSync(p).isDirectory());
388
+ const bumped = [];
389
+ const skipped = [];
390
+ print(`Bumping monorepo to ${version}${dryRun ? " (dry-run)" : ""}…`);
391
+ function bumpFile(absPath, label) {
392
+ let pkg;
393
+ try {
394
+ pkg = JSON.parse(readFile(absPath));
395
+ } catch {
396
+ print(` ✗ could not parse ${label}`);
397
+ return;
398
+ }
399
+ const old = pkg.version;
400
+ if (!dryRun) {
401
+ pkg.version = version;
402
+ writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
403
+ }
404
+ print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
405
+ bumped.push(label);
406
+ }
407
+ bumpFile(join(cwd, "package.json"), "root");
408
+ const packagesDir = join(cwd, "packages");
409
+ let entries;
410
+ try {
411
+ entries = listDir(packagesDir);
412
+ } catch {
413
+ return {
414
+ status: "fail",
415
+ bumped,
416
+ skipped,
417
+ message: `packages/ directory not found in ${cwd}`
418
+ };
419
+ }
420
+ for (const entry of entries) {
421
+ const pkgDir = join(packagesDir, entry);
422
+ try {
423
+ if (!isDir(pkgDir)) continue;
424
+ } catch {
425
+ continue;
426
+ }
427
+ const pkgFile = join(pkgDir, "package.json");
428
+ let pkg;
429
+ try {
430
+ pkg = JSON.parse(readFile(pkgFile));
431
+ } catch {
432
+ print(` ! skipping packages/${entry}: no package.json or malformed JSON`);
433
+ continue;
434
+ }
435
+ if (pkg.private) {
436
+ print(` · skipping private package packages/${entry}`);
437
+ skipped.push(`packages/${entry}`);
438
+ continue;
439
+ }
440
+ bumpFile(pkgFile, `packages/${entry}`);
441
+ }
442
+ return {
443
+ status: dryRun ? "dry-run" : "ok",
444
+ bumped,
445
+ skipped
446
+ };
447
+ }
448
+ //#endregion
196
449
  //#region src/commands/npm-publish-initial.ts
197
450
  /**
198
451
  * `holocron npm publish-initial` — bottles up the chicken-and-egg
@@ -269,7 +522,7 @@ async function runNpmPublishInitial(input = {}) {
269
522
  print("");
270
523
  print(" … (dry-run) skipping actual publish");
271
524
  print(` would run: pnpm ${publishArgs.join(" ")}`);
272
- printNextSteps(print, env);
525
+ printNextSteps$1(print, env);
273
526
  return {
274
527
  status: "dry-run",
275
528
  message: "dry-run — no publish executed",
@@ -294,13 +547,13 @@ async function runNpmPublishInitial(input = {}) {
294
547
  };
295
548
  }
296
549
  print(" ✓ publish complete");
297
- printNextSteps(print, env);
550
+ printNextSteps$1(print, env);
298
551
  return {
299
552
  status: "ok",
300
553
  packageNames: PUBLISHABLE_PACKAGES
301
554
  };
302
555
  }
303
- function printNextSteps(print, env) {
556
+ function printNextSteps$1(print, env) {
304
557
  print("");
305
558
  print(" → next: configure Trusted Publisher for each package on npm:");
306
559
  for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
@@ -328,6 +581,1124 @@ const defaultExec = async (cmd, args, opts) => {
328
581
  };
329
582
  };
330
583
  //#endregion
584
+ //#region src/commands/plugin-create/template-inputs.ts
585
+ /** Derive the standard defaults from a slug + vendor name. */
586
+ function deriveDefaults(input) {
587
+ const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
588
+ const capability = input.capability;
589
+ return {
590
+ vendorUpper,
591
+ capabilityClass: `${input.vendorName}${capability.charAt(0).toUpperCase() + capability.slice(1)}`,
592
+ tokenEnv: `HOLOCRON_${vendorUpper}_TOKEN`,
593
+ transport: "rest"
594
+ };
595
+ }
596
+ //#endregion
597
+ //#region src/commands/plugin-create/templates/auth.ts
598
+ function render$17(inputs) {
599
+ return `/**
600
+ * Token resolution for the ${inputs.vendorName} plugin.
601
+ *
602
+ * Resolution order (matches the standard 4-step precedence set by
603
+ * \`.notes/tech-auth-bootstrap.spec.md\`):
604
+ * 1. explicit \`cliToken\` argument (from \`--token\` flag)
605
+ * 2. ${inputs.tokenEnv} env var (preferred — explicit intent)
606
+ * 3. ${inputs.vendorEnv} env var (vendor-native)
607
+ * 4. keyring (com.theholocron.cli / "${inputs.slug}")
608
+ * 5. AuthError naming all four options + the bootstrap hint
609
+ */
610
+
611
+ import { getToken as getKeyringToken } from "@theholocron/cli";
612
+
613
+ export class AuthError extends Error {
614
+ override name = "AuthError";
615
+ }
616
+
617
+ export interface ResolveTokenInput {
618
+ /** From \`--token\` CLI flag. */
619
+ cliToken?: string;
620
+ /** Env vars; passed in for testability. Defaults to \`process.env\`. */
621
+ env?: NodeJS.ProcessEnv;
622
+ /** Keyring lookup fn; passed in for testability. Defaults to \`getToken(provider)\`. */
623
+ keyring?: (provider: string) => string | null;
624
+ }
625
+
626
+ export function resolveToken(input: ResolveTokenInput = {}): string {
627
+ const env = input.env ?? process.env;
628
+ const keyring = input.keyring ?? getKeyringToken;
629
+ // Bracket access so numeric-prefixed slugs (e.g., env.HOLOCRON_1PASSWORD_TOKEN
630
+ // which is invalid JS) still produce syntactically valid code.
631
+ const token =
632
+ input.cliToken || env["${inputs.tokenEnv}"] || env["${inputs.vendorEnv}"] || keyring("${inputs.slug}");
633
+ if (!token) {
634
+ throw new AuthError(
635
+ "no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
636
+ "or run: holocron auth set ${inputs.slug} <TOKEN>"
637
+ );
638
+ }
639
+ return token;
640
+ }
641
+ `;
642
+ }
643
+ //#endregion
644
+ //#region src/commands/plugin-create/templates/auth-test.ts
645
+ function render$16(inputs) {
646
+ return `import { describe, expect, it } from "vitest";
647
+
648
+ import { AuthError, resolveToken } from "../auth.js";
649
+
650
+ const noKeyring = () => null;
651
+
652
+ describe("resolveToken", () => {
653
+ it("prefers --token over env vars + keyring", () => {
654
+ expect(
655
+ resolveToken({
656
+ cliToken: "flag",
657
+ env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
658
+ keyring: () => "kr",
659
+ })
660
+ ).toBe("flag");
661
+ });
662
+
663
+ it("prefers ${inputs.tokenEnv} over ${inputs.vendorEnv}", () => {
664
+ expect(
665
+ resolveToken({
666
+ env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
667
+ keyring: noKeyring,
668
+ })
669
+ ).toBe("hlc");
670
+ });
671
+
672
+ it("falls back to ${inputs.vendorEnv} when ${inputs.tokenEnv} is unset", () => {
673
+ expect(resolveToken({ env: { ${inputs.vendorEnv}: "vendor" }, keyring: noKeyring })).toBe("vendor");
674
+ });
675
+
676
+ it("falls back to keyring when env vars are unset", () => {
677
+ expect(resolveToken({ env: {}, keyring: (p) => (p === "${inputs.slug}" ? "kr" : null) })).toBe("kr");
678
+ });
679
+
680
+ it("throws AuthError with a helpful message when nothing is set", () => {
681
+ try {
682
+ resolveToken({ env: {}, keyring: noKeyring });
683
+ throw new Error("should have thrown");
684
+ } catch (err) {
685
+ expect(err).toBeInstanceOf(AuthError);
686
+ expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
687
+ expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
688
+ }
689
+ });
690
+ });
691
+ `;
692
+ }
693
+ //#endregion
694
+ //#region src/commands/plugin-create/templates/capability.ts
695
+ function render$15(inputs) {
696
+ const clientClass = `${inputs.vendorName}RestClient`;
697
+ const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
698
+ return `/**
699
+ * \`${inputs.capability}\` capability for ${inputs.vendorName}.
700
+ *
701
+ * Methods are STUBS — implement them against ${inputs.vendorName}'s
702
+ * REST API per the \`${capabilityInterface}\` interface contract in
703
+ * \`@theholocron/cli\`.
704
+ *
705
+ * TODO once you've stubbed the interface methods, restore the type
706
+ * import + \`implements\` clause:
707
+ * import type { ${capabilityInterface} } from "@theholocron/cli";
708
+ * export class ${inputs.capabilityClass} implements ${capabilityInterface} { ... }
709
+ */
710
+
711
+ import type { ${clientClass} } from "../rest.js";
712
+
713
+ export class ${inputs.capabilityClass} {
714
+ readonly key = "${inputs.capability}" as const;
715
+ readonly providerName = "${inputs.slug}";
716
+
717
+ constructor(private readonly rest: ${clientClass}) {}
718
+
719
+ // TODO: implement the ${capabilityInterface} interface methods
720
+ // (see \`packages/cli/src/capabilities/index.ts\`). Each method
721
+ // should hit a specific ${inputs.vendorName} REST endpoint via
722
+ // \`this.rest.request(...)\`. Once methods are stubbed, add
723
+ // \`implements ${capabilityInterface}\` to the class declaration
724
+ // above and remove the \`as unknown as\` cast in src/index.ts.
725
+ }
726
+ `;
727
+ }
728
+ //#endregion
729
+ //#region src/commands/plugin-create/templates/capability-test.ts
730
+ function render$14(inputs) {
731
+ const clientClass = `${inputs.vendorName}RestClient`;
732
+ return `import { describe, it } from "vitest";
733
+
734
+ import { ${inputs.capabilityClass} } from "../capabilities/${inputs.capability}.js";
735
+ import { ${clientClass} } from "../rest.js";
736
+ import { stubFetch } from "./helpers.js";
737
+
738
+ // Stub client used by the constructor smoke test. Real capability
739
+ // tests replace this with per-method stubs once implementations land.
740
+ function makeCapability() {
741
+ const stub = stubFetch([]);
742
+ const rest = new ${clientClass}({ token: "t", fetch: stub.fetch });
743
+ return new ${inputs.capabilityClass}(rest);
744
+ }
745
+
746
+ describe("${inputs.capabilityClass}", () => {
747
+ it("constructs with a REST client", () => {
748
+ makeCapability();
749
+ });
750
+
751
+ // TODO: implement one test per ${inputs.capability} capability method
752
+ // as you fill in the class stubs. See other plugins for reference
753
+ // patterns:
754
+ // - REST behaviors (URL / method / body / query) via \`stub.calls\`
755
+ // - Error paths via \`stubFetch([{ status: 4xx, body: {...} }])\`
756
+ // - Idempotency (409 handling for ensure* methods, if applicable)
757
+ it.todo("implement per-method tests");
758
+ });
759
+ `;
760
+ }
761
+ //#endregion
762
+ //#region src/commands/plugin-create/templates/eslint-config.ts
763
+ function render$13(_inputs) {
764
+ return `import root from "../../eslint.config.js";
765
+
766
+ export default [
767
+ ...root,
768
+ {
769
+ ignores: ["dist/**", "coverage/**"],
770
+ },
771
+ ];
772
+ `;
773
+ }
774
+ //#endregion
775
+ //#region src/commands/plugin-create/templates/helpers.ts
776
+ function render$12(_inputs) {
777
+ return `import { vi, type Mock } from "vitest";
778
+
779
+ export interface FetchCall {
780
+ url: string;
781
+ method: string;
782
+ headers: Record<string, string>;
783
+ body: unknown;
784
+ }
785
+
786
+ export interface FetchStub {
787
+ fetch: typeof fetch;
788
+ calls: FetchCall[];
789
+ mock: Mock;
790
+ }
791
+
792
+ export function stubFetch(responses: Array<{ status?: number; body?: unknown; text?: string }>): FetchStub {
793
+ const calls: FetchCall[] = [];
794
+ let i = 0;
795
+ const mock = vi.fn(async (input: string | URL, init?: RequestInit) => {
796
+ const url = typeof input === "string" ? input : input.toString();
797
+ const body = typeof init?.body === "string" ? safeJsonParse(init.body) : (init?.body ?? null);
798
+ calls.push({
799
+ url,
800
+ method: (init?.method ?? "GET").toUpperCase(),
801
+ headers: (init?.headers as Record<string, string>) ?? {},
802
+ body,
803
+ });
804
+ const next = responses[i++] ?? { status: 200, body: {} };
805
+ const status = next.status ?? 200;
806
+ if (status === 204 || status === 205 || status === 304) {
807
+ return new Response(null, { status });
808
+ }
809
+ const text = next.text ?? (typeof next.body === "string" ? next.body : JSON.stringify(next.body ?? {}));
810
+ return new Response(text, { status });
811
+ });
812
+ return { fetch: mock as unknown as typeof fetch, calls, mock };
813
+ }
814
+
815
+ function safeJsonParse(s: string): unknown {
816
+ try {
817
+ return JSON.parse(s);
818
+ } catch {
819
+ return s;
820
+ }
821
+ }
822
+ `;
823
+ }
824
+ //#endregion
825
+ //#region src/commands/plugin-create/templates/index-test.ts
826
+ function render$11(inputs) {
827
+ return `import { describe, expect, it } from "vitest";
828
+
829
+ import { AUTH_HINT, createPlugin } from "../index.js";
830
+ import { stubFetch } from "./helpers.js";
831
+
832
+ describe("createPlugin", () => {
833
+ it("wires the ${inputs.capability} capability against the given fetch + token", () => {
834
+ const stub = stubFetch([]);
835
+ const plugin = createPlugin({
836
+ cliToken: "test-token",
837
+ fetch: stub.fetch,
838
+ });
839
+ expect(plugin.name).toBe("@theholocron/holocron-plugin-${inputs.slug}");
840
+ expect(typeof plugin.capabilities.${inputs.capability}).toBe("function");
841
+ });
842
+ });
843
+
844
+ describe("AUTH_HINT", () => {
845
+ it("mentions the holocron auth set command", () => {
846
+ expect(AUTH_HINT).toMatch(/holocron auth set ${inputs.slug}/);
847
+ });
848
+ });
849
+ `;
850
+ }
851
+ //#endregion
852
+ //#region src/commands/plugin-create/templates/package-json.ts
853
+ function render$10(inputs) {
854
+ return `{
855
+ "name": "@theholocron/holocron-plugin-${inputs.slug}",
856
+ "version": "2.0.0-alpha.1",
857
+ "description": "Holocron plugin for ${inputs.vendorName}. Implements the ${inputs.capability} capability against ${inputs.vendorName}'s REST API, plus exports verifyToken + AUTH_HINT for \`holocron auth\`.",
858
+ "homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-${inputs.slug}#readme",
859
+ "bugs": "https://github.com/theholocron/holocron/issues",
860
+ "repository": {
861
+ "type": "git",
862
+ "url": "git+https://github.com/theholocron/holocron.git",
863
+ "directory": "packages/holocron-plugin-${inputs.slug}"
864
+ },
865
+ "license": "MIT",
866
+ "author": "Newton Koumantzelis",
867
+ "type": "module",
868
+ "main": "./src/index.ts",
869
+ "exports": {
870
+ ".": "./src/index.ts"
871
+ },
872
+ "scripts": {
873
+ "build": "tsdown",
874
+ "lint": "eslint .",
875
+ "typecheck": "tsc --noEmit",
876
+ "test": "vitest run",
877
+ "test:watch": "vitest",
878
+ "test:coverage": "vitest run --coverage",
879
+ "validate": "tsx scripts/validate.mjs"
880
+ },
881
+ "peerDependencies": {
882
+ "@theholocron/cli": "workspace:*"
883
+ },
884
+ "devDependencies": {
885
+ "@theholocron/cli": "workspace:*",
886
+ "@theholocron/tsconfig": "catalog:",
887
+ "@tsconfig/node-lts": "catalog:",
888
+ "@vitest/coverage-v8": "catalog:",
889
+ "eslint": "catalog:",
890
+ "globals": "catalog:",
891
+ "typescript": "catalog:",
892
+ "vitest": "catalog:",
893
+ "tsdown": "catalog:",
894
+ "tsx": "catalog:"
895
+ },
896
+ "publishConfig": {
897
+ "access": "public",
898
+ "main": "./dist/index.mjs",
899
+ "types": "./dist/index.d.mts",
900
+ "exports": {
901
+ ".": {
902
+ "types": "./dist/index.d.mts",
903
+ "import": "./dist/index.mjs",
904
+ "default": "./dist/index.mjs"
905
+ }
906
+ }
907
+ },
908
+ "files": [
909
+ "dist",
910
+ "README.md"
911
+ ]
912
+ }
913
+ `;
914
+ }
915
+ //#endregion
916
+ //#region src/commands/plugin-create/templates/plugin-index.ts
917
+ function render$9(inputs) {
918
+ const clientClass = `${inputs.vendorName}RestClient`;
919
+ const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
920
+ return `/**
921
+ * \`@theholocron/holocron-plugin-${inputs.slug}\` — entrypoint.
922
+ *
923
+ * Implements the \`${inputs.capability}\` capability against ${inputs.vendorName}'s
924
+ * REST API. Also exports \`verifyToken\` + \`AUTH_HINT\` for
925
+ * \`holocron auth\`. See README for auth + config docs.
926
+ *
927
+ * TODO: once you fill in the ${inputs.capabilityClass} methods and add
928
+ * \`implements ${capabilityInterface}\` to the class, add the type import:
929
+ * import type { ${capabilityInterface} } from "@theholocron/cli";
930
+ * and set \`: ${capabilityInterface}\` as the factory return type below.
931
+ */
932
+
933
+ import { resolveToken, type ResolveTokenInput } from "./auth.js";
934
+ import { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
935
+ import { ${clientClass} } from "./rest.js";
936
+
937
+ export interface ${inputs.vendorName}PluginOptions extends ResolveTokenInput {
938
+ /** Override base URL for tests. */
939
+ baseUrl?: string;
940
+ /** Override \`fetch\` for tests. */
941
+ fetch?: typeof fetch;
942
+ }
943
+
944
+ export interface PluginContext {
945
+ options: ${inputs.vendorName}PluginOptions;
946
+ rest: ${clientClass};
947
+ }
948
+
949
+ export function createContext(options: ${inputs.vendorName}PluginOptions): PluginContext {
950
+ const token = resolveToken(options);
951
+ const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
952
+ if (options.baseUrl !== undefined) restOpts.baseUrl = options.baseUrl;
953
+ if (options.fetch !== undefined) restOpts.fetch = options.fetch;
954
+ return {
955
+ options,
956
+ rest: new ${clientClass}(restOpts),
957
+ };
958
+ }
959
+
960
+ export function ${inputs.capability}(ctx: PluginContext) {
961
+ // Return type inferred at scaffold time — the class doesn't yet
962
+ // \`implements ${capabilityInterface}\`. Add the type import + the
963
+ // \`: ${capabilityInterface}\` annotation once methods are stubbed.
964
+ return new ${inputs.capabilityClass}(ctx.rest);
965
+ }
966
+
967
+ export function createPlugin(options: ${inputs.vendorName}PluginOptions) {
968
+ const ctx = createContext(options);
969
+ return {
970
+ name: "@theholocron/holocron-plugin-${inputs.slug}",
971
+ capabilities: {
972
+ ${inputs.capability}: () => ${inputs.capability}(ctx),
973
+ },
974
+ };
975
+ }
976
+
977
+ /**
978
+ * One-line hint printed by \`holocron auth set ${inputs.slug}\` when no
979
+ * token is supplied or the supplied token is rejected. Edit this to
980
+ * point operators at the specific ${inputs.vendorName} docs path for
981
+ * generating a token.
982
+ */
983
+ export const AUTH_HINT =
984
+ "generate a ${inputs.vendorName} API token, then run: holocron auth set ${inputs.slug} <TOKEN>";
985
+
986
+ // ── Public re-exports ────────────────────────────────────────────────
987
+
988
+ export * from "./auth.js";
989
+ export { ${clientClass} } from "./rest.js";
990
+ export { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
991
+ export { verifyToken } from "./verify-token.js";
992
+ export type { VerifyTokenResult, VerifyTokenSuccess, VerifyTokenFailure } from "./verify-token.js";
993
+ `;
994
+ }
995
+ //#endregion
996
+ //#region src/commands/plugin-create/templates/readme.ts
997
+ function render$8(inputs) {
998
+ return `<!-- editorconfig-checker-disable-file -->
999
+
1000
+ # \`@theholocron/holocron-plugin-${inputs.slug}\`
1001
+
1002
+ ${inputs.vendorName} plugin for [Holocron](../cli). Implements the
1003
+ \`${inputs.capability}\` capability against [${inputs.vendorName}'s REST API](${inputs.baseUrl}),
1004
+ plus exports \`verifyToken\` + \`AUTH_HINT\` for use by \`holocron auth\`.
1005
+
1006
+ ## Install
1007
+
1008
+ \`\`\`bash
1009
+ pnpm add -D @theholocron/holocron-plugin-${inputs.slug}@alpha
1010
+ \`\`\`
1011
+
1012
+ ## Auth
1013
+
1014
+ Token resolution order (matches the standard 4-step precedence set by
1015
+ \`.notes/tech-auth-bootstrap.spec.md\`):
1016
+
1017
+ 1. \`--token <TOKEN>\` flag on the holocron invocation
1018
+ 2. \`${inputs.tokenEnv}\` env var (preferred — explicit intent)
1019
+ 3. \`${inputs.vendorEnv}\` env var (${inputs.vendorName}-native)
1020
+ 4. **Keyring** — \`com.theholocron.cli\` service, account \`${inputs.slug}\`
1021
+ 5. \`AuthError\` naming all four options + the bootstrap hint
1022
+
1023
+ ## Setup
1024
+
1025
+ \`\`\`bash
1026
+ # Generate a ${inputs.vendorName} API token (see vendor docs), then:
1027
+ holocron auth set ${inputs.slug} <TOKEN>
1028
+ holocron auth check ${inputs.slug} # verify
1029
+ \`\`\`
1030
+
1031
+ ## Config
1032
+
1033
+ \`\`\`jsonc
1034
+ {
1035
+ "providers": {
1036
+ "${inputs.capability}": "${inputs.slug}",
1037
+ },
1038
+ }
1039
+ \`\`\`
1040
+
1041
+ Plugin options extend \`ResolveTokenInput\` — add whatever ${inputs.vendorName}-
1042
+ specific options you need here (project id, workspace slug, etc.) via
1043
+ the tuple form:
1044
+
1045
+ \`\`\`jsonc
1046
+ {
1047
+ "providers": {
1048
+ "${inputs.capability}": ["${inputs.slug}", { "baseUrl": "${inputs.baseUrl}" }],
1049
+ },
1050
+ }
1051
+ \`\`\`
1052
+
1053
+ ## What's implemented
1054
+
1055
+ TODO: fill in as capability methods land.
1056
+
1057
+ ## Status
1058
+
1059
+ **\`v2.0.0-alpha.1\`** — scaffolded via \`holocron plugin create\`.
1060
+ Not yet published; capability methods are stubs.
1061
+ `;
1062
+ }
1063
+ //#endregion
1064
+ //#region src/commands/plugin-create/templates/rest.ts
1065
+ function render$7(inputs) {
1066
+ const clientClass = `${inputs.vendorName}RestClient`;
1067
+ return `/**
1068
+ * Thin REST wrapper around ${inputs.baseUrl}.
1069
+ *
1070
+ * Bearer auth, JSON-only bodies, transport-failure wrapping with
1071
+ * \`status: 0\` so orchestrator soft-skip paths see a clear message
1072
+ * instead of a generic \`TypeError: fetch failed\`.
1073
+ */
1074
+
1075
+ import { ProviderApiError } from "@theholocron/cli";
1076
+
1077
+ export interface RestClientOptions {
1078
+ token: string;
1079
+ fetch?: typeof fetch;
1080
+ baseUrl?: string;
1081
+ }
1082
+
1083
+ export interface RequestOptions {
1084
+ method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
1085
+ body?: unknown;
1086
+ query?: Record<string, string>;
1087
+ /** Treat this response as void even if 200 is returned. */
1088
+ expectNoContent?: boolean;
1089
+ }
1090
+
1091
+ export class ${clientClass} {
1092
+ private readonly token: string;
1093
+ private readonly fetchImpl: typeof fetch;
1094
+ readonly baseUrl: string;
1095
+
1096
+ constructor(opts: RestClientOptions) {
1097
+ this.token = opts.token;
1098
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
1099
+ // Manual trailing-slash trim — CodeQL flags regex on library
1100
+ // input as polynomial ReDoS. O(n) loop, no backtracking risk.
1101
+ let url = opts.baseUrl ?? "${inputs.baseUrl}";
1102
+ while (url.endsWith("/")) url = url.slice(0, -1);
1103
+ this.baseUrl = url;
1104
+ }
1105
+
1106
+ async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
1107
+ const url = new URL(\`\${this.baseUrl}\${path.startsWith("/") ? path : "/" + path}\`);
1108
+ for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
1109
+ const fullUrl = url.toString();
1110
+
1111
+ const headers: Record<string, string> = {
1112
+ authorization: \`Bearer \${this.token}\`,
1113
+ accept: "application/json",
1114
+ };
1115
+ const init: RequestInit = {
1116
+ method: opts.method ?? "GET",
1117
+ headers,
1118
+ };
1119
+ if (opts.body !== undefined) {
1120
+ headers["content-type"] = "application/json";
1121
+ init.body = JSON.stringify(opts.body);
1122
+ }
1123
+
1124
+ let res: Response;
1125
+ try {
1126
+ res = await this.fetchImpl(fullUrl, init);
1127
+ } catch (err) {
1128
+ const detail = err instanceof Error ? \`\${err.name}: \${err.message}\` : String(err);
1129
+ throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} failed: \${detail}\`, 0, undefined);
1130
+ }
1131
+ if (!res.ok) {
1132
+ const body = await res.text().catch(() => "");
1133
+ throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} → \${res.status}\`, res.status, body);
1134
+ }
1135
+ if (opts.expectNoContent || res.status === 204) return undefined as T;
1136
+ const text = await res.text();
1137
+ if (!text) return undefined as T;
1138
+ return JSON.parse(text) as T;
1139
+ }
1140
+ }
1141
+ `;
1142
+ }
1143
+ //#endregion
1144
+ //#region src/commands/plugin-create/templates/rest-test.ts
1145
+ function render$6(inputs) {
1146
+ const clientClass = `${inputs.vendorName}RestClient`;
1147
+ return `import { ProviderApiError } from "@theholocron/cli";
1148
+ import { describe, expect, it } from "vitest";
1149
+
1150
+ import { ${clientClass} } from "../rest.js";
1151
+ import { stubFetch } from "./helpers.js";
1152
+
1153
+ describe("${clientClass}", () => {
1154
+ it("sends bearer + accept headers and returns the parsed body", async () => {
1155
+ const stub = stubFetch([{ status: 200, body: { ok: true } }]);
1156
+ const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
1157
+ const res = await client.request<{ ok: boolean }>("/me");
1158
+ expect(res.ok).toBe(true);
1159
+ expect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
1160
+ expect(stub.calls[0]?.headers["accept"]).toBe("application/json");
1161
+ });
1162
+
1163
+ it("serializes body as JSON and sets content-type when present", async () => {
1164
+ const stub = stubFetch([{ status: 200, body: {} }]);
1165
+ const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
1166
+ await client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
1167
+ expect(stub.calls[0]?.method).toBe("POST");
1168
+ expect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
1169
+ expect(stub.calls[0]?.body).toEqual({ name: "demo" });
1170
+ });
1171
+
1172
+ it("returns undefined on 204", async () => {
1173
+ const stub = stubFetch([{ status: 204 }]);
1174
+ const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
1175
+ expect(await client.request<unknown>("/whatever")).toBeUndefined();
1176
+ });
1177
+
1178
+ it("throws ProviderApiError with the HTTP status on non-2xx", async () => {
1179
+ const stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
1180
+ const client = new ${clientClass}({ token: "bad", fetch: stub.fetch });
1181
+ try {
1182
+ await client.request<unknown>("/me");
1183
+ throw new Error("should have thrown");
1184
+ } catch (err) {
1185
+ expect(err).toBeInstanceOf(ProviderApiError);
1186
+ expect((err as ProviderApiError).status).toBe(401);
1187
+ }
1188
+ });
1189
+
1190
+ it("wraps transport-level failures with status 0", async () => {
1191
+ const throwing: typeof fetch = async () => {
1192
+ throw new TypeError("fetch failed");
1193
+ };
1194
+ const client = new ${clientClass}({ token: "t", fetch: throwing });
1195
+ try {
1196
+ await client.request<unknown>("/me");
1197
+ throw new Error("should have thrown");
1198
+ } catch (err) {
1199
+ expect(err).toBeInstanceOf(ProviderApiError);
1200
+ expect((err as ProviderApiError).status).toBe(0);
1201
+ }
1202
+ });
1203
+
1204
+ it("trims trailing slashes from the base URL", () => {
1205
+ const client = new ${clientClass}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
1206
+ expect(client.baseUrl).toBe("${inputs.baseUrl}");
1207
+ });
1208
+ });
1209
+ `;
1210
+ }
1211
+ //#endregion
1212
+ //#region src/commands/plugin-create/templates/tsconfig-json.ts
1213
+ function render$5(inputs) {
1214
+ return `{
1215
+ "display": "Holocron Plugin: ${inputs.vendorName}",
1216
+ "extends": "@tsconfig/node-lts/tsconfig.json",
1217
+ "compilerOptions": {
1218
+ "baseUrl": "./",
1219
+ "outDir": "./dist",
1220
+ "paths": {
1221
+ "@/*": ["./src/*"]
1222
+ }
1223
+ },
1224
+ "include": ["src/**/*.ts"],
1225
+ "exclude": ["node_modules", "dist"]
1226
+ }
1227
+ `;
1228
+ }
1229
+ //#endregion
1230
+ //#region src/commands/plugin-create/templates/tsdown-config.ts
1231
+ function render$4(_inputs) {
1232
+ return `import { defineConfig } from "tsdown";
1233
+
1234
+ export default defineConfig({
1235
+ entry: ["src/index.ts"],
1236
+ format: "esm",
1237
+ dts: true,
1238
+ clean: true,
1239
+ deps: { neverBundle: [/^@theholocron\\//] },
1240
+ });
1241
+ `;
1242
+ }
1243
+ //#endregion
1244
+ //#region src/commands/plugin-create/templates/validate-script.ts
1245
+ /**
1246
+ * Scaffolds `scripts/validate.mjs` — a smoke-test the operator runs
1247
+ * against a live vendor account to verify the plugin's REST endpoints,
1248
+ * auth, and response parsing all work in reality (the unit tests only
1249
+ * exercise stubbed HTTP responses). READ-ONLY BY DESIGN.
1250
+ *
1251
+ * Convention: every plugin ships this script + a matching `validate`
1252
+ * entry in `package.json`'s scripts block, so operators run
1253
+ * `pnpm --filter @theholocron/holocron-plugin-<slug> validate`.
1254
+ * Capability-specific args (project id, workspace, etc.) come from
1255
+ * positional command-line arguments; the plugin author fills in the
1256
+ * exact shape per their vendor.
1257
+ *
1258
+ * Includes a `hintFor(message)` helper the operator customizes with
1259
+ * vendor-specific "here's the likely fix" guidance for common error
1260
+ * shapes (401 / 403 / 404 / network / 5xx). Points users at docs and
1261
+ * setup steps rather than leaving them staring at a raw stack trace.
1262
+ */
1263
+ function render$3(inputs) {
1264
+ return `#!/usr/bin/env node
1265
+ /**
1266
+ * Read-only smoke test for @theholocron/holocron-plugin-${inputs.slug} against
1267
+ * a live ${inputs.vendorName} account.
1268
+ *
1269
+ * READ-ONLY BY DESIGN. Never calls write(), or bootstrap methods
1270
+ * (\`ensureProject\`, \`ensureEnvironment\`, etc.). Any ERROR line means
1271
+ * the plugin needs adjusting — the \`hintFor\` helper below points at
1272
+ * the most likely fix per error shape.
1273
+ *
1274
+ * Auth: reads the ${inputs.vendorName} token from holocron's keyring —
1275
+ * you must have run \`pnpm holocron auth set ${inputs.slug} <TOKEN>\` first.
1276
+ *
1277
+ * Usage:
1278
+ * pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <arg1> [arg2] ...
1279
+ *
1280
+ * TODO: replace the positional args below with whatever your
1281
+ * capability's methods need (e.g., project id, environment slug,
1282
+ * secret name). Adapt the test steps to your capability's method
1283
+ * surface. Model on \`packages/holocron-plugin-infisical/scripts/validate.mjs\`.
1284
+ */
1285
+
1286
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- filled in by operator
1287
+ import { AuthError, createPlugin, resolveToken, verifyToken } from "../src/index.ts";
1288
+
1289
+ const args = process.argv.slice(2);
1290
+
1291
+ if (args.length === 0) {
1292
+ console.error("usage: pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <args>");
1293
+ process.exit(2);
1294
+ }
1295
+
1296
+ // Use the plugin's own auth resolution so the validate script honors
1297
+ // the same 4-step precedence as the plugin's runtime:
1298
+ // --token → ${inputs.tokenEnv} → ${inputs.vendorEnv} → keyring
1299
+ // (--token isn't available here since this is a bare Node script;
1300
+ // the other three all work.)
1301
+ let token;
1302
+ try {
1303
+ token = resolveToken();
1304
+ } catch (err) {
1305
+ if (err instanceof AuthError) {
1306
+ console.error(err.message);
1307
+ console.error(" see: packages/holocron-plugin-${inputs.slug}/README.md#setup");
1308
+ process.exit(2);
1309
+ }
1310
+ throw err;
1311
+ }
1312
+
1313
+ console.log("Validating @theholocron/holocron-plugin-${inputs.slug} (READ-ONLY)");
1314
+ console.log("");
1315
+
1316
+ // ── 1. verifyToken ─────────────────────────────────────────────────
1317
+ console.log("[1/N] verifyToken");
1318
+ const verifyResult = await verifyToken(token);
1319
+ console.log(\` \${verifyResult.ok ? "✓" : "✗"} \${JSON.stringify(verifyResult)}\`);
1320
+ if (!verifyResult.ok) {
1321
+ const hint = hintFor(verifyResult.message);
1322
+ if (hint) console.log(\` hint: \${hint}\`);
1323
+ }
1324
+ console.log("");
1325
+
1326
+ // ── 2..N. capability method calls ──────────────────────────────────
1327
+ // TODO: implement one \`runStep\`-wrapped call per meaningful read-side
1328
+ // capability method. Model on the infisical validate.mjs. Never call
1329
+ // write / ensure* / other mutating paths.
1330
+ //
1331
+ // Example:
1332
+ // console.log("[2/N] vault.list()");
1333
+ // await runStep(async () => {
1334
+ // const keys = await vault.list();
1335
+ // console.log(\` ✓ \${keys.length} secrets\`);
1336
+ // });
1337
+
1338
+ console.log("Done. Fill in capability method calls above before shipping.");
1339
+
1340
+ // ── helpers ────────────────────────────────────────────────────────
1341
+
1342
+ /** Wrap a capability call, print ERROR + hint on failure. */
1343
+ async function runStep(body) {
1344
+ try {
1345
+ await body();
1346
+ } catch (err) {
1347
+ const message = err instanceof Error ? err.message : String(err);
1348
+ console.log(\` ✗ ERROR: \${message}\`);
1349
+ const hint = hintFor(message);
1350
+ if (hint) console.log(\` hint: \${hint}\`);
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * Point the operator at the most likely fix based on the error shape.
1356
+ * Generic defaults below — CUSTOMIZE per your vendor's docs URLs and
1357
+ * common permission/config gotchas.
1358
+ */
1359
+ function hintFor(message) {
1360
+ if (/→ 401/.test(message)) {
1361
+ return "token invalid — regenerate per ${inputs.vendorName}'s docs (see README §Setup)";
1362
+ }
1363
+ if (/→ 403/.test(message)) {
1364
+ return "token authenticates but lacks scope — check the token/identity has permissions on the resource";
1365
+ }
1366
+ if (/→ 404/.test(message)) {
1367
+ return "endpoint or resource not found — verify your positional args match a real resource";
1368
+ }
1369
+ if (/fetch failed|status: 0|network/i.test(message)) {
1370
+ return "network error — check the base URL (${inputs.baseUrl}) and connectivity";
1371
+ }
1372
+ if (/→ 5\\d\\d/.test(message)) {
1373
+ return "server error — vendor-side. Retry, and check the vendor's status page if persistent";
1374
+ }
1375
+ return null;
1376
+ }
1377
+ `;
1378
+ }
1379
+ //#endregion
1380
+ //#region src/commands/plugin-create/templates/verify-token.ts
1381
+ function render$2(inputs) {
1382
+ const clientClass = `${inputs.vendorName}RestClient`;
1383
+ return `/**
1384
+ * \`verifyToken\` — plugin-level export used by \`holocron auth set\` +
1385
+ * \`holocron auth check\`. Hits a lightweight whoami-style endpoint
1386
+ * and translates the response into the normalized \`VerifyTokenResult\`
1387
+ * shape.
1388
+ *
1389
+ * Kept as a standalone function (not a capability method) so the auth
1390
+ * command can call it without initializing the full plugin — plugin
1391
+ * construction requires an already-resolved token, which is exactly
1392
+ * what we don't have yet at bootstrap time.
1393
+ *
1394
+ * TODO: replace \`/me\` with the ${inputs.vendorName} equivalent of a
1395
+ * "check my token" endpoint. Common shapes: \`/user\`, \`/whoami\`,
1396
+ * \`/me\`, \`/account\`.
1397
+ */
1398
+
1399
+ import { ${clientClass} } from "./rest.js";
1400
+
1401
+ export interface VerifyTokenSuccess {
1402
+ ok: true;
1403
+ subject: string;
1404
+ }
1405
+
1406
+ export interface VerifyTokenFailure {
1407
+ ok: false;
1408
+ message: string;
1409
+ }
1410
+
1411
+ export type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
1412
+
1413
+ interface MeResponse {
1414
+ /** Adjust to whatever ${inputs.vendorName}'s whoami endpoint returns. */
1415
+ name?: string;
1416
+ email?: string;
1417
+ id?: string;
1418
+ }
1419
+
1420
+ export interface VerifyTokenOptions {
1421
+ baseUrl?: string;
1422
+ fetch?: typeof fetch;
1423
+ }
1424
+
1425
+ export async function verifyToken(token: string, opts: VerifyTokenOptions = {}): Promise<VerifyTokenResult> {
1426
+ const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
1427
+ if (opts.baseUrl !== undefined) restOpts.baseUrl = opts.baseUrl;
1428
+ if (opts.fetch !== undefined) restOpts.fetch = opts.fetch;
1429
+ const rest = new ${clientClass}(restOpts);
1430
+ try {
1431
+ const me = await rest.request<MeResponse>("/me");
1432
+ // Optional chaining because \`me\` is \`undefined\` on 204 / empty body.
1433
+ const subject = me?.email ?? me?.name ?? me?.id ?? "unknown";
1434
+ return { ok: true, subject: \`user @ \${subject}\` };
1435
+ } catch (err) {
1436
+ const message = err instanceof Error ? err.message : String(err);
1437
+ return { ok: false, message };
1438
+ }
1439
+ }
1440
+ `;
1441
+ }
1442
+ //#endregion
1443
+ //#region src/commands/plugin-create/templates/verify-token-test.ts
1444
+ function render$1(_inputs) {
1445
+ return `import { describe, expect, it } from "vitest";
1446
+
1447
+ import { verifyToken } from "../verify-token.js";
1448
+ import { stubFetch } from "./helpers.js";
1449
+
1450
+ describe("verifyToken", () => {
1451
+ it("returns ok with a subject when /me returns 200", async () => {
1452
+ const stub = stubFetch([{ status: 200, body: { email: "user@example.com" } }]);
1453
+ const result = await verifyToken("token", { fetch: stub.fetch });
1454
+ expect(result.ok).toBe(true);
1455
+ if (result.ok) {
1456
+ expect(result.subject).toMatch(/user@example.com/);
1457
+ }
1458
+ });
1459
+
1460
+ it("returns ok:false with the error message on 401", async () => {
1461
+ const stub = stubFetch([{ status: 401, body: { messages: ["Invalid token"] } }]);
1462
+ const result = await verifyToken("bad", { fetch: stub.fetch });
1463
+ expect(result.ok).toBe(false);
1464
+ if (!result.ok) {
1465
+ expect(result.message).toMatch(/→ 401/);
1466
+ }
1467
+ });
1468
+
1469
+ it("returns ok:false when the network layer throws", async () => {
1470
+ const throwing: typeof fetch = async () => {
1471
+ throw new TypeError("network down");
1472
+ };
1473
+ const result = await verifyToken("t", { fetch: throwing });
1474
+ expect(result.ok).toBe(false);
1475
+ if (!result.ok) {
1476
+ expect(result.message).toMatch(/network down/);
1477
+ }
1478
+ });
1479
+ });
1480
+ `;
1481
+ }
1482
+ //#endregion
1483
+ //#region src/commands/plugin-create/templates/vitest-config.ts
1484
+ function render(_inputs) {
1485
+ return `import { defineConfig } from "vitest/config";
1486
+
1487
+ export default defineConfig({
1488
+ test: {
1489
+ environment: "node",
1490
+ globals: false,
1491
+ coverage: {
1492
+ provider: "v8",
1493
+ reporter: ["text", "html", "json-summary"],
1494
+ include: ["src/**/*.ts"],
1495
+ exclude: ["src/**/__tests__/**", "src/**/*.test.ts", "src/index.ts"],
1496
+ thresholds: { lines: 0, functions: 0, branches: 0, statements: 0 },
1497
+ },
1498
+ },
1499
+ });
1500
+ `;
1501
+ }
1502
+ //#endregion
1503
+ //#region src/commands/plugin-create/index.ts
1504
+ /**
1505
+ * `holocron plugin create <slug> <vendor>` — scaffold a new plugin
1506
+ * package matching the proven template.
1507
+ *
1508
+ * Design: see `.notes/tool-plugin-create.spec.md`.
1509
+ *
1510
+ * Flow:
1511
+ * 1. Preflight — verify CWD is a workspace root (pnpm-workspace.yaml
1512
+ * + packages/ dir present).
1513
+ * 2. Slug collision — packages/holocron-plugin-<slug>/ must not exist.
1514
+ * 3. Capability sanity — must be one of the 14 known keys; warn for
1515
+ * many-cardinality caps.
1516
+ * 4. Prompt — fill in any missing flags via cli-utils / inquirer
1517
+ * (Phase B; Phase A takes fully-populated input).
1518
+ * 5. Generate — for each template, write to
1519
+ * packages/holocron-plugin-<slug>/<path>.
1520
+ * 6. Verify (unless --no-verify) — Phase B; runs pnpm install +
1521
+ * pnpm --filter <pkg> typecheck lint test.
1522
+ * 7. Print next steps.
1523
+ */
1524
+ var PluginCreateError = class extends Error {
1525
+ name = "PluginCreateError";
1526
+ };
1527
+ const TEMPLATES = [
1528
+ {
1529
+ path: "package.json",
1530
+ render: render$10
1531
+ },
1532
+ {
1533
+ path: "tsconfig.json",
1534
+ render: render$5
1535
+ },
1536
+ {
1537
+ path: "vitest.config.ts",
1538
+ render
1539
+ },
1540
+ {
1541
+ path: "eslint.config.js",
1542
+ render: render$13
1543
+ },
1544
+ {
1545
+ path: "tsdown.config.ts",
1546
+ render: render$4
1547
+ },
1548
+ {
1549
+ path: "README.md",
1550
+ render: render$8
1551
+ },
1552
+ {
1553
+ path: "src/auth.ts",
1554
+ render: render$17
1555
+ },
1556
+ {
1557
+ path: "src/rest.ts",
1558
+ render: render$7
1559
+ },
1560
+ {
1561
+ path: "src/verify-token.ts",
1562
+ render: render$2
1563
+ },
1564
+ {
1565
+ path: "src/index.ts",
1566
+ render: render$9
1567
+ },
1568
+ {
1569
+ path: "src/capabilities/{{capability}}.ts",
1570
+ render: render$15
1571
+ },
1572
+ {
1573
+ path: "src/__tests__/helpers.ts",
1574
+ render: render$12
1575
+ },
1576
+ {
1577
+ path: "src/__tests__/auth.test.ts",
1578
+ render: render$16
1579
+ },
1580
+ {
1581
+ path: "src/__tests__/rest.test.ts",
1582
+ render: render$6
1583
+ },
1584
+ {
1585
+ path: "src/__tests__/verify-token.test.ts",
1586
+ render: render$1
1587
+ },
1588
+ {
1589
+ path: "src/__tests__/{{capability}}.test.ts",
1590
+ render: render$14
1591
+ },
1592
+ {
1593
+ path: "src/__tests__/index.test.ts",
1594
+ render: render$11
1595
+ },
1596
+ {
1597
+ path: "scripts/validate.mjs",
1598
+ render: render$3
1599
+ }
1600
+ ];
1601
+ /** Replace `{{capability}}` in a template path with the actual capability key. */
1602
+ function resolvePath(template, inputs) {
1603
+ return template.replace(/\{\{capability\}\}/g, inputs.capability);
1604
+ }
1605
+ function runPluginCreate(input) {
1606
+ const cwd = input.cwd ?? process.cwd();
1607
+ const print = input.print ?? ((line) => console.log(line));
1608
+ const write = input.writeFile ?? defaultWrite;
1609
+ preflight(cwd);
1610
+ validateSlug(input.slug);
1611
+ validateVendorName(input.vendorName);
1612
+ const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
1613
+ if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
1614
+ validateCapability(input.capability, print);
1615
+ const derived = deriveDefaults({
1616
+ slug: input.slug,
1617
+ vendorName: input.vendorName,
1618
+ capability: input.capability
1619
+ });
1620
+ const inputs = {
1621
+ slug: input.slug,
1622
+ vendorName: input.vendorName,
1623
+ vendorUpper: derived.vendorUpper,
1624
+ capability: input.capability,
1625
+ capabilityClass: derived.capabilityClass,
1626
+ tokenEnv: input.tokenEnv ?? derived.tokenEnv,
1627
+ vendorEnv: input.vendorEnv,
1628
+ baseUrl: trimTrailingSlashes(input.baseUrl),
1629
+ transport: derived.transport
1630
+ };
1631
+ const filesWritten = [];
1632
+ print(`Scaffolding @theholocron/holocron-plugin-${inputs.slug}${input.dryRun ? " (dry-run)" : ""}`);
1633
+ print(` → ${packageDir}`);
1634
+ for (const template of TEMPLATES) {
1635
+ const resolvedPath = resolvePath(template.path, inputs);
1636
+ const filepath = path.join(packageDir, resolvedPath);
1637
+ const content = template.render(inputs);
1638
+ if (input.dryRun) print(` … would write ${resolvedPath} (${content.length} bytes)`);
1639
+ else {
1640
+ write(filepath, content);
1641
+ print(` ✓ ${resolvedPath}`);
1642
+ }
1643
+ filesWritten.push(resolvedPath);
1644
+ }
1645
+ if (!input.dryRun) printNextSteps(print, inputs);
1646
+ return {
1647
+ status: "ok",
1648
+ packagePath: packageDir,
1649
+ filesWritten
1650
+ };
1651
+ }
1652
+ function preflight(cwd) {
1653
+ if (!existsSync(path.join(cwd, "pnpm-workspace.yaml"))) throw new PluginCreateError(`\`pnpm-workspace.yaml\` not found in \`${cwd}\`. Run \`holocron plugin create\` from the monorepo root.`);
1654
+ if (!existsSync(path.join(cwd, "packages"))) throw new PluginCreateError(`\`packages/\` directory not found in \`${cwd}\`.`);
1655
+ }
1656
+ /**
1657
+ * npm-package-name conventions: kebab-case, starts with lowercase
1658
+ * letter. Rejecting numeric-prefixed slugs also sidesteps the
1659
+ * `env.HOLOCRON_1FOO_TOKEN` invalid-identifier trap in the auth
1660
+ * template.
1661
+ */
1662
+ const SLUG_RE = /^[a-z][a-z0-9-]*$/;
1663
+ function validateSlug(slug) {
1664
+ if (!SLUG_RE.test(slug)) throw new PluginCreateError(`invalid slug "${slug}" — must be kebab-case: lowercase letter followed by lowercase letters, digits, or hyphens. Examples: "clerk", "cloud-flare", "doppler".`);
1665
+ }
1666
+ /** PascalCase — starts with uppercase, alphanumeric only. */
1667
+ const VENDOR_NAME_RE = /^[A-Z][a-zA-Z0-9]*$/;
1668
+ function validateVendorName(name) {
1669
+ if (!VENDOR_NAME_RE.test(name)) throw new PluginCreateError(`invalid vendor name "${name}" — must be PascalCase: starts with uppercase, alphanumeric only. Examples: "Clerk", "CloudFlare", "Doppler".`);
1670
+ }
1671
+ /**
1672
+ * Trim trailing slashes with a plain loop (no regex — matches the
1673
+ * rest.ts template's own trimming to stay ReDoS-safe under CodeQL).
1674
+ */
1675
+ function trimTrailingSlashes(url) {
1676
+ let out = url;
1677
+ while (out.endsWith("/")) out = out.slice(0, -1);
1678
+ return out;
1679
+ }
1680
+ function validateCapability(capability, print) {
1681
+ if (!(capability in CARDINALITY)) throw new PluginCreateError(`\`${capability}\` is not a known capability. See \`packages/cli/src/capabilities/index.ts\`.`);
1682
+ if (CARDINALITY[capability] === "many") print(` ! ${capability} is a many-cardinality capability — multiple providers can be active at once. Confirm your config wiring accordingly.`);
1683
+ }
1684
+ function defaultWrite(filepath, content) {
1685
+ mkdirSync(path.dirname(filepath), { recursive: true });
1686
+ writeFileSync(filepath, content, "utf8");
1687
+ }
1688
+ function printNextSteps(print, inputs) {
1689
+ print("");
1690
+ print(` Scaffolded @theholocron/holocron-plugin-${inputs.slug} (18 files).`);
1691
+ print("");
1692
+ print(" Next:");
1693
+ print(` 1. pnpm install # picks up the workspace package`);
1694
+ print(` 2. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} typecheck # green`);
1695
+ print(` 3. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} lint # green`);
1696
+ print(` 4. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} test # green (stubs pass, real tests are it.todo)`);
1697
+ print(` 5. Implement ${inputs.capabilityClass} methods in src/capabilities/${inputs.capability}.ts`);
1698
+ print(` 6. Replace it.todo(...) with real tests in src/__tests__/${inputs.capability}.test.ts`);
1699
+ print(" 7. Commit + push when capability is functionally complete.");
1700
+ }
1701
+ //#endregion
331
1702
  //#region src/commands/secret-set.ts
332
1703
  async function runSecretSet(input) {
333
1704
  const print = input.print ?? ((line) => console.log(line));
@@ -494,7 +1865,389 @@ function vaultProviderName(loader) {
494
1865
  return loader.get("vault").providerName;
495
1866
  }
496
1867
  //#endregion
1868
+ //#region src/commands/setup-workflows.ts
1869
+ /**
1870
+ * Thin workflow wrapper templates for `holocron setup`.
1871
+ *
1872
+ * Each entry is a complete `.github/workflows/<name>.yml` that delegates
1873
+ * to the corresponding reusable `ci-<name>.yml` in `theholocron/.github`.
1874
+ * Files are overwritten on each setup run — they are generated artifacts.
1875
+ */
1876
+ const WORKFLOW_REPO = "theholocron/.github";
1877
+ const WORKFLOW_REF = "main";
1878
+ function ref(name) {
1879
+ return `${WORKFLOW_REPO}/.github/workflows/${name}.yml@${WORKFLOW_REF}`;
1880
+ }
1881
+ /** Header prepended when holocron setup writes a thin caller to a repo. */
1882
+ const WORKFLOW_HEADER = `\
1883
+ # AUTO-GENERATED by holocron — do not edit directly.
1884
+ # Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts
1885
+ # Run \`holocron setup\` to regenerate.
1886
+
1887
+ `;
1888
+ const WORKFLOW_TEMPLATES = {
1889
+ lint: `\
1890
+ name: Lint
1891
+
1892
+ on: # yamllint disable-line rule:truthy
1893
+ push:
1894
+ branches: [main, alpha]
1895
+ pull_request:
1896
+
1897
+ concurrency:
1898
+ group: lint-\${{ github.ref }}
1899
+ cancel-in-progress: true
1900
+
1901
+ permissions:
1902
+ contents: write
1903
+ statuses: write
1904
+
1905
+ jobs:
1906
+ lint:
1907
+ name: Lint
1908
+ uses: ${ref("lint")}
1909
+ secrets: inherit
1910
+ with:
1911
+ enable-auto-commit: true
1912
+ `,
1913
+ test: `\
1914
+ name: Test
1915
+
1916
+ on: # yamllint disable-line rule:truthy
1917
+ push:
1918
+ branches: [main, alpha]
1919
+ pull_request:
1920
+
1921
+ concurrency:
1922
+ group: test-\${{ github.ref }}
1923
+ cancel-in-progress: true
1924
+
1925
+ permissions:
1926
+ contents: read
1927
+
1928
+ jobs:
1929
+ test:
1930
+ name: Test
1931
+ uses: ${ref("test")}
1932
+ secrets: inherit
1933
+ `,
1934
+ typecheck: `\
1935
+ name: Typecheck
1936
+
1937
+ on: # yamllint disable-line rule:truthy
1938
+ push:
1939
+ branches: [main, alpha]
1940
+ pull_request:
1941
+
1942
+ concurrency:
1943
+ group: typecheck-\${{ github.ref }}
1944
+ cancel-in-progress: true
1945
+
1946
+ permissions:
1947
+ contents: read
1948
+
1949
+ jobs:
1950
+ typecheck:
1951
+ name: Typecheck
1952
+ uses: ${ref("typecheck")}
1953
+ secrets: inherit
1954
+ `,
1955
+ codeql: `\
1956
+ name: CodeQL
1957
+
1958
+ on: # yamllint disable-line rule:truthy
1959
+ push:
1960
+ branches:
1961
+ - main
1962
+ pull_request:
1963
+ branches:
1964
+ - main
1965
+ schedule:
1966
+ - cron: "0 0 * * 1"
1967
+
1968
+ permissions:
1969
+ actions: read
1970
+ contents: read
1971
+ security-events: write
1972
+
1973
+ jobs:
1974
+ codeql:
1975
+ uses: ${ref("codeql")}
1976
+ secrets: inherit
1977
+ `,
1978
+ review: `\
1979
+ name: Review
1980
+
1981
+ on: # yamllint disable-line rule:truthy
1982
+ pull_request:
1983
+
1984
+ concurrency:
1985
+ group: review-\${{ github.ref }}
1986
+ cancel-in-progress: true
1987
+
1988
+ permissions:
1989
+ contents: read
1990
+ checks: write
1991
+ pull-requests: write
1992
+
1993
+ jobs:
1994
+ review:
1995
+ name: Review
1996
+ uses: ${ref("review")}
1997
+ secrets: inherit
1998
+ `,
1999
+ release: `\
2000
+ name: Release
2001
+
2002
+ on: # yamllint disable-line rule:truthy
2003
+ push:
2004
+ branches:
2005
+ - main
2006
+
2007
+ permissions:
2008
+ contents: write
2009
+ id-token: write
2010
+ issues: write
2011
+ pull-requests: write
2012
+
2013
+ jobs:
2014
+ release:
2015
+ uses: ${ref("release")}
2016
+ secrets: inherit
2017
+ `,
2018
+ stale: `\
2019
+ name: Stale
2020
+
2021
+ on: # yamllint disable-line rule:truthy
2022
+ schedule:
2023
+ - cron: "30 1 * * *"
2024
+
2025
+ permissions:
2026
+ contents: write
2027
+ issues: write
2028
+ pull-requests: write
2029
+
2030
+ jobs:
2031
+ stale:
2032
+ uses: ${ref("stale")}
2033
+ secrets: inherit
2034
+ `,
2035
+ greetings: `\
2036
+ name: Greetings
2037
+
2038
+ on: # yamllint disable-line rule:truthy
2039
+ pull_request:
2040
+ issues:
2041
+
2042
+ permissions:
2043
+ issues: write
2044
+ pull-requests: write
2045
+
2046
+ jobs:
2047
+ greetings:
2048
+ uses: ${ref("greetings")}
2049
+ secrets: inherit
2050
+ `,
2051
+ dependencies: `\
2052
+ name: Dependencies
2053
+
2054
+ on: # yamllint disable-line rule:truthy
2055
+ pull_request:
2056
+
2057
+ permissions:
2058
+ contents: write
2059
+ pull-requests: write
2060
+
2061
+ jobs:
2062
+ dependencies:
2063
+ uses: ${ref("dependencies")}
2064
+ secrets: inherit
2065
+ `,
2066
+ "bookkeeping-pr": `\
2067
+ name: PR Bookkeeping
2068
+
2069
+ on: # yamllint disable-line rule:truthy
2070
+ pull_request:
2071
+ types:
2072
+ - opened
2073
+ - edited
2074
+
2075
+ permissions:
2076
+ contents: read
2077
+ pull-requests: write
2078
+
2079
+ jobs:
2080
+ bookkeeping:
2081
+ uses: ${ref("bookkeeping-pr")}
2082
+ secrets: inherit
2083
+ `,
2084
+ audit: `\
2085
+ name: Audit
2086
+
2087
+ on: # yamllint disable-line rule:truthy
2088
+ push:
2089
+ branches: [main, alpha]
2090
+ pull_request:
2091
+
2092
+ permissions:
2093
+ contents: read
2094
+
2095
+ jobs:
2096
+ audit:
2097
+ uses: ${ref("audit")}
2098
+ secrets: inherit
2099
+ `
2100
+ };
2101
+ const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
2102
+ //#endregion
497
2103
  //#region src/commands/setup.ts
2104
+ const DEPENDABOT_CONFIG = `\
2105
+ # AUTO-GENERATED by holocron — run \`holocron setup\` to regenerate.
2106
+ version: 2
2107
+ updates:
2108
+ - package-ecosystem: npm
2109
+ directory: /
2110
+ schedule:
2111
+ interval: weekly
2112
+ groups:
2113
+ security-patches:
2114
+ applies-to: security-updates
2115
+ patterns:
2116
+ - "*"
2117
+ all-dependencies:
2118
+ update-types:
2119
+ - minor
2120
+ - patch
2121
+
2122
+ - package-ecosystem: github-actions
2123
+ directory: /
2124
+ schedule:
2125
+ interval: weekly
2126
+ groups:
2127
+ all-actions:
2128
+ patterns:
2129
+ - "*"
2130
+ `;
2131
+ const RULESET_NAME = "holocron-default-branch";
2132
+ const BALANCED_REPO_SETTINGS = {
2133
+ allow_squash_merge: true,
2134
+ allow_merge_commit: false,
2135
+ allow_rebase_merge: false,
2136
+ allow_auto_merge: true,
2137
+ allow_update_branch: true,
2138
+ delete_branch_on_merge: true,
2139
+ has_issues: true,
2140
+ has_discussions: true,
2141
+ has_projects: true,
2142
+ has_wiki: false
2143
+ };
2144
+ function buildClassicProtectionPayload(requiredChecks = []) {
2145
+ return {
2146
+ required_status_checks: requiredChecks.length > 0 ? {
2147
+ strict: false,
2148
+ contexts: requiredChecks
2149
+ } : null,
2150
+ enforce_admins: false,
2151
+ required_pull_request_reviews: {
2152
+ required_approving_review_count: 0,
2153
+ dismiss_stale_reviews: false,
2154
+ require_code_owner_reviews: false
2155
+ },
2156
+ restrictions: null,
2157
+ allow_force_pushes: false,
2158
+ allow_deletions: false
2159
+ };
2160
+ }
2161
+ function buildRulesetPayload(requiredChecks = []) {
2162
+ const rules = [
2163
+ { type: "deletion" },
2164
+ { type: "non_fast_forward" },
2165
+ {
2166
+ type: "pull_request",
2167
+ parameters: {
2168
+ required_approving_review_count: 0,
2169
+ dismiss_stale_reviews_on_push: false,
2170
+ require_code_owner_review: false,
2171
+ require_last_push_approval: false,
2172
+ required_review_thread_resolution: false
2173
+ }
2174
+ }
2175
+ ];
2176
+ if (requiredChecks.length > 0) rules.push({
2177
+ type: "required_status_checks",
2178
+ parameters: {
2179
+ required_status_checks: requiredChecks.map((context) => ({ context })),
2180
+ strict_required_status_checks_policy: false
2181
+ }
2182
+ });
2183
+ return {
2184
+ name: RULESET_NAME,
2185
+ target: "branch",
2186
+ enforcement: "active",
2187
+ conditions: { ref_name: {
2188
+ include: ["~DEFAULT_BRANCH"],
2189
+ exclude: []
2190
+ } },
2191
+ rules
2192
+ };
2193
+ }
2194
+ async function upsertBranchProtection(source, dryRun, requiredChecks) {
2195
+ const step = `upsert ruleset ${RULESET_NAME}`;
2196
+ if (dryRun) return {
2197
+ capability: "source",
2198
+ step,
2199
+ status: "dry-run"
2200
+ };
2201
+ try {
2202
+ const found = (await source.listRulesets()).find((r) => r.name === RULESET_NAME);
2203
+ if (found) {
2204
+ await source.updateRuleset(found.id, buildRulesetPayload(requiredChecks));
2205
+ return {
2206
+ capability: "source",
2207
+ step,
2208
+ status: "ok",
2209
+ message: "updated"
2210
+ };
2211
+ }
2212
+ await source.createRuleset(buildRulesetPayload(requiredChecks));
2213
+ return {
2214
+ capability: "source",
2215
+ step,
2216
+ status: "ok",
2217
+ message: "created"
2218
+ };
2219
+ } catch (err) {
2220
+ if (!(err instanceof ProviderApiError) || err.status !== 403) return {
2221
+ capability: "source",
2222
+ step,
2223
+ status: "fail",
2224
+ message: err instanceof Error ? err.message : String(err)
2225
+ };
2226
+ }
2227
+ try {
2228
+ const repo = await source.getRepo();
2229
+ await source.protectBranch(repo.defaultBranch, buildClassicProtectionPayload(requiredChecks));
2230
+ return {
2231
+ capability: "source",
2232
+ step,
2233
+ status: "ok",
2234
+ message: `classic protection on ${repo.defaultBranch}`
2235
+ };
2236
+ } catch (err) {
2237
+ if (err instanceof ProviderApiError && err.status === 403) return {
2238
+ capability: "source",
2239
+ step,
2240
+ status: "skip",
2241
+ message: "branch protection unavailable on private repos without GitHub Pro/Team"
2242
+ };
2243
+ return {
2244
+ capability: "source",
2245
+ step,
2246
+ status: "fail",
2247
+ message: err instanceof Error ? err.message : String(err)
2248
+ };
2249
+ }
2250
+ }
498
2251
  async function runSetup(input) {
499
2252
  const print = input.print ?? ((line) => console.log(line));
500
2253
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
@@ -512,13 +2265,57 @@ async function runSetup(input) {
512
2265
  "enableVulnerabilityAlerts",
513
2266
  "enableAutomatedSecurityFixes",
514
2267
  "enableSecretScanning",
515
- "enablePrivateVulnerabilityReporting"
2268
+ "enablePrivateVulnerabilityReporting",
2269
+ "enableDependencyGraph"
516
2270
  ]) {
517
2271
  steps.push(await runStep("source", method, dryRun, async () => {
518
2272
  await source[method]();
519
2273
  }));
520
2274
  print(formatStep(steps[steps.length - 1]));
521
2275
  }
2276
+ steps.push(await runStep("source", "enableCodeScanning", dryRun, async () => {
2277
+ return await source.enableCodeScanning();
2278
+ }));
2279
+ print(formatStep(steps[steps.length - 1]));
2280
+ const policy = config.project.repoPolicy;
2281
+ if (policy && policy.preset !== "none") {
2282
+ const preset = policy.preset ?? "balanced";
2283
+ steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
2284
+ await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
2285
+ }));
2286
+ print(formatStep(steps[steps.length - 1]));
2287
+ const requiredChecks = preset === "strict" ? policy.requiredChecks ?? [] : [];
2288
+ steps.push(await upsertBranchProtection(source, dryRun, requiredChecks));
2289
+ print(formatStep(steps[steps.length - 1]));
2290
+ }
2291
+ }
2292
+ const workflows = config.project.workflows;
2293
+ if (loader.has("source") && workflows && workflows.length > 0) {
2294
+ const source = loader.get("source");
2295
+ print(" → workflows");
2296
+ for (const name of workflows) {
2297
+ if (!KNOWN_WORKFLOWS.has(name)) {
2298
+ steps.push({
2299
+ capability: "source",
2300
+ step: `write workflow ${name}`,
2301
+ status: "skip",
2302
+ message: `unknown workflow "${name}" — no template available`
2303
+ });
2304
+ print(formatStep(steps[steps.length - 1]));
2305
+ continue;
2306
+ }
2307
+ steps.push(await runStep("source", `write workflow ${name}`, dryRun, async () => {
2308
+ await source.writeWorkflowFile(`${name}.yml`, WORKFLOW_HEADER + WORKFLOW_TEMPLATES[name]);
2309
+ }));
2310
+ print(formatStep(steps[steps.length - 1]));
2311
+ }
2312
+ }
2313
+ if (loader.has("source") && config.project.repoPolicy?.preset !== "none") {
2314
+ const source = loader.get("source");
2315
+ steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
2316
+ await source.writeRepoFile(".github/dependabot.yml", DEPENDABOT_CONFIG);
2317
+ }));
2318
+ print(formatStep(steps[steps.length - 1]));
522
2319
  }
523
2320
  if (loader.has("environments")) {
524
2321
  const envs = loader.get("environments");
@@ -559,6 +2356,22 @@ async function runSetup(input) {
559
2356
  if (loader.has("vault")) {
560
2357
  const vault = loader.get("vault");
561
2358
  print(" → vault");
2359
+ if (vault.ensureProject) {
2360
+ steps.push(await runStep("vault", `ensureProject ${config.project.name}`, dryRun, async () => {
2361
+ return `project ${(await vault.ensureProject(config.project.name)).alreadyExists ? "exists" : "created"}`;
2362
+ }));
2363
+ print(formatStep(steps[steps.length - 1]));
2364
+ }
2365
+ if (vault.ensureEnvironment) for (const envName of [
2366
+ "dev",
2367
+ "stg",
2368
+ "prd"
2369
+ ]) {
2370
+ steps.push(await runStep("vault", `ensureEnvironment ${envName}`, dryRun, async () => {
2371
+ return `${envName} ${(await vault.ensureEnvironment(config.project.name, envName)).alreadyExists ? "exists" : "created"}`;
2372
+ }));
2373
+ print(formatStep(steps[steps.length - 1]));
2374
+ }
562
2375
  try {
563
2376
  const keys = await vault.list();
564
2377
  steps.push({
@@ -695,6 +2508,7 @@ async function fileExists(path) {
695
2508
  }
696
2509
  //#endregion
697
2510
  //#region src/cli.ts
2511
+ const { version: CLI_VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
698
2512
  await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [options]").option("dry-run", {
699
2513
  type: "boolean",
700
2514
  default: false,
@@ -707,7 +2521,7 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
707
2521
  default: process.cwd(),
708
2522
  describe: "Directory to search for holocron.config.json"
709
2523
  }).command("version", "Print the CLI version", () => {}, () => {
710
- console.log("holocron 2.0.0-alpha.0");
2524
+ console.log(`holocron ${CLI_VERSION}`);
711
2525
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
712
2526
  type: "string",
713
2527
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
@@ -815,7 +2629,17 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
815
2629
  branch: argv.branch,
816
2630
  ...argv.target ? { target: argv.target } : {}
817
2631
  })).status === "fail") process.exitCode = 1;
818
- }).command("npm publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (y) => y.option("tag", {
2632
+ }).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", {
2633
+ type: "string",
2634
+ demandOption: true,
2635
+ describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
2636
+ }), async (argv) => {
2637
+ if ((await runNpmBumpVersions({
2638
+ version: argv.newVersion,
2639
+ cwd: argv.cwd,
2640
+ dryRun: argv.dryRun
2641
+ })).status === "fail") process.exitCode = 1;
2642
+ }).command("publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (yy) => yy.option("tag", {
819
2643
  type: "string",
820
2644
  default: "alpha",
821
2645
  describe: "npm distribution tag (defaults to alpha)"
@@ -829,10 +2653,76 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
829
2653
  dryRun: argv.dryRun,
830
2654
  ...argv.otp ? { otp: argv.otp } : {}
831
2655
  })).status === "fail") process.exitCode = 1;
832
- }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
2656
+ }).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
833
2657
  const loaded = await loadConfig(argv.cwd);
834
2658
  console.log(JSON.stringify(loaded.resolved, null, 2));
835
- }).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
2659
+ }).command("plugin create <slug> <vendor>", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
2660
+ type: "string",
2661
+ demandOption: true,
2662
+ describe: "Package slug (kebab-case)"
2663
+ }).positional("vendor", {
2664
+ type: "string",
2665
+ demandOption: true,
2666
+ describe: "Vendor display name (PascalCase)"
2667
+ }).option("capability", {
2668
+ type: "string",
2669
+ describe: "Capability key: source|ci|secrets|environments|issues|deployment|storage|auth|vault|dns|tooling|notifications|analytics|observability"
2670
+ }).option("token-env", {
2671
+ type: "string",
2672
+ describe: "Holocron env var name (defaults to HOLOCRON_<VENDOR>_TOKEN)"
2673
+ }).option("vendor-env", {
2674
+ type: "string",
2675
+ describe: "Vendor-native env var name"
2676
+ }).option("base-url", {
2677
+ type: "string",
2678
+ describe: "REST base URL"
2679
+ }).option("verify", {
2680
+ type: "boolean",
2681
+ default: true,
2682
+ describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
2683
+ }), (argv) => {
2684
+ try {
2685
+ if (!argv.capability || !argv.vendorEnv || !argv.baseUrl) throw new PluginCreateError("Phase A: --capability, --vendor-env, and --base-url are required. Interactive prompts land in Phase B.");
2686
+ if (runPluginCreate({
2687
+ slug: argv.slug,
2688
+ vendorName: argv.vendor,
2689
+ capability: argv.capability,
2690
+ vendorEnv: argv.vendorEnv,
2691
+ baseUrl: argv.baseUrl,
2692
+ ...argv.tokenEnv ? { tokenEnv: argv.tokenEnv } : {},
2693
+ dryRun: argv.dryRun,
2694
+ noVerify: !argv.verify,
2695
+ cwd: argv.cwd
2696
+ }).status === "fail") process.exitCode = 1;
2697
+ } catch (err) {
2698
+ if (err instanceof PluginCreateError) {
2699
+ console.error(`plugin create: ${err.message}`);
2700
+ process.exitCode = 1;
2701
+ return;
2702
+ }
2703
+ throw err;
2704
+ }
2705
+ }).command("auth <subcommand>", "Manage bootstrap credentials in the OS keyring", (y) => y.command("set <provider> [token]", "Verify + store a bootstrap token for a provider", (yy) => yy.positional("provider", {
2706
+ type: "string",
2707
+ demandOption: true
2708
+ }).positional("token", { type: "string" }), async (argv) => {
2709
+ if ((await runAuthSet({
2710
+ provider: argv.provider,
2711
+ ...argv.token ? { positional: argv.token } : {}
2712
+ })).status === "fail") process.exitCode = 1;
2713
+ }).command("unset <provider>", "Remove a stored bootstrap token", (yy) => yy.positional("provider", {
2714
+ type: "string",
2715
+ demandOption: true
2716
+ }), (argv) => {
2717
+ runAuthUnset({ provider: argv.provider });
2718
+ }).command("check <provider>", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", {
2719
+ type: "string",
2720
+ demandOption: true
2721
+ }), async (argv) => {
2722
+ if ((await runAuthCheck({ provider: argv.provider })).status === "fail") process.exitCode = 1;
2723
+ }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
2724
+ await runAuthList();
2725
+ }).demandCommand(1, "Run `holocron auth --help` to see available auth subcommands."), () => {}).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
836
2726
  /**
837
2727
  * Parse `--scope` strings: `repo` | `env=NAME` | `org=NAME`.
838
2728
  */