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

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,404 @@
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";
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
3
  import yargs from "yargs";
5
4
  import { hideBin } from "yargs/helpers";
5
+ import { Entry, findCredentials } from "@napi-rs/keyring";
6
+ import path, { dirname, join } from "node:path";
7
+ import { createHash } from "node:crypto";
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/capabilities/index.ts
11
+ const CARDINALITY = {
12
+ source: "single",
13
+ ci: "single",
14
+ secrets: "single",
15
+ environments: "single",
16
+ issues: "single",
17
+ deployment: "single",
18
+ storage: "single",
19
+ auth: "single",
20
+ vault: "single",
21
+ dns: "single",
22
+ tooling: "many",
23
+ notifications: "many",
24
+ analytics: "many",
25
+ observability: "many"
26
+ };
27
+ /**
28
+ * No capabilities are strictly required — repos without secrets (e.g. org
29
+ * community health repos) legitimately omit vault. Plugins validate their
30
+ * own requirements at call time.
31
+ */
32
+ const REQUIRED_CAPABILITIES = [];
33
+ /**
34
+ * Surfaced from every capability call that hits a vendor API. Wraps
35
+ * the underlying error with `status` (HTTP) and `details` so
36
+ * orchestrators (`holocron setup`, `doctor`) can soft-skip rather
37
+ * than abort.
38
+ */
39
+ var ProviderApiError = class extends Error {
40
+ status;
41
+ details;
42
+ name = "ProviderApiError";
43
+ constructor(message, status, details) {
44
+ super(message);
45
+ this.status = status;
46
+ this.details = details;
47
+ }
48
+ };
49
+ //#endregion
50
+ //#region src/config.ts
51
+ /**
52
+ * `holocron.config.json` schema, parser, and provider resolution.
53
+ *
54
+ * ESLint-style entry forms:
55
+ *
56
+ * "source": "github" ← single, short
57
+ * "deployment": ["vercel", { team: "rando" }] ← single, with options
58
+ * "notifications": ["slack", "discord"] ← multi, short
59
+ * "notifications": [
60
+ * ["slack", { channel: "#ops" }],
61
+ * ["discord", { webhook: "env:HOOK" }]
62
+ * ] ← multi, with options
63
+ *
64
+ * Discriminator: an array entry is a `[provider, options]` tuple when
65
+ * the length is 2 AND element[1] is a non-array, non-null object.
66
+ * Otherwise it's a multi-provider list (string[] or tuple[]).
67
+ *
68
+ * Validation rules:
69
+ * - `vault` is REQUIRED (every project has secrets somewhere)
70
+ * - Entries for `'many'` capabilities are normalized to an array of
71
+ * normalized tuples; entries for `'single'` capabilities are
72
+ * normalized to one tuple
73
+ * - Tokens / secret values never appear in config — providers read
74
+ * them from env (or pull from `vault` at runtime)
75
+ */
76
+ var ConfigError = class extends Error {
77
+ name = "ConfigError";
78
+ };
79
+ const PLUGIN_PREFIX = "@theholocron/holocron-plugin-";
80
+ const COMMUNITY_PREFIX = "holocron-plugin-";
81
+ /**
82
+ * Resolve `"github"` → `"@theholocron/holocron-plugin-github"`.
83
+ * Fully-qualified names (scoped or not) are honored verbatim, which
84
+ * is how third-party plugins published outside the org work.
85
+ */
86
+ function resolvePluginPackage(provider) {
87
+ if (!provider) throw new ConfigError("provider name is empty");
88
+ if (provider.startsWith("@")) return provider;
89
+ if (provider.startsWith(COMMUNITY_PREFIX)) return provider;
90
+ if (provider.includes("/")) return provider;
91
+ return PLUGIN_PREFIX + provider;
92
+ }
93
+ /** A bare `[provider, options]` tuple, with both elements present? */
94
+ function isOptionsTuple(value) {
95
+ if (!Array.isArray(value)) return false;
96
+ if (value.length !== 2) return false;
97
+ if (typeof value[0] !== "string") return false;
98
+ const opt = value[1];
99
+ return typeof opt === "object" && opt !== null && !Array.isArray(opt);
100
+ }
101
+ function normalizeEntry(entry) {
102
+ if (typeof entry === "string") return {
103
+ provider: entry,
104
+ packageName: resolvePluginPackage(entry),
105
+ options: {}
106
+ };
107
+ const [provider, options] = entry;
108
+ return {
109
+ provider,
110
+ packageName: resolvePluginPackage(provider),
111
+ options
112
+ };
113
+ }
114
+ function resolveEntry(key, raw) {
115
+ const cardinality = CARDINALITY[key];
116
+ if (typeof raw === "string") {
117
+ if (cardinality === "many") throw new ConfigError(`\`${key}\` accepts multiple providers; wrap a single one in an array: ["${raw}"]`);
118
+ return {
119
+ cardinality: "single",
120
+ tuple: normalizeEntry(raw)
121
+ };
122
+ }
123
+ if (!Array.isArray(raw)) throw new ConfigError(`\`${key}\` entry must be a string or array, got ${typeof raw}`);
124
+ if (isOptionsTuple(raw)) {
125
+ if (cardinality === "many") return {
126
+ cardinality: "many",
127
+ tuples: [normalizeEntry(raw)]
128
+ };
129
+ return {
130
+ cardinality: "single",
131
+ tuple: normalizeEntry(raw)
132
+ };
133
+ }
134
+ if (cardinality === "single") throw new ConfigError(`\`${key}\` accepts exactly one provider; got a multi-provider list with ${raw.length} entries`);
135
+ return {
136
+ cardinality: "many",
137
+ tuples: raw.map((entry, idx) => {
138
+ if (typeof entry === "string") return normalizeEntry(entry);
139
+ if (isOptionsTuple(entry)) return normalizeEntry(entry);
140
+ throw new ConfigError(`\`${key}[${idx}]\` must be a provider string or [provider, options] tuple`);
141
+ })
142
+ };
143
+ }
144
+ function resolveConfig(raw) {
145
+ if (!raw.project?.name) throw new ConfigError("`project.name` is required");
146
+ if (!raw.providers || typeof raw.providers !== "object") throw new ConfigError("`providers` block is required");
147
+ const providers = {};
148
+ for (const [key, entry] of Object.entries(raw.providers)) {
149
+ if (entry === void 0) continue;
150
+ providers[key] = resolveEntry(key, entry);
151
+ }
152
+ for (const required of REQUIRED_CAPABILITIES) if (!providers[required]) throw new ConfigError(`required capability \`${required}\` is missing from providers`);
153
+ return {
154
+ project: raw.project,
155
+ providers,
156
+ apps: raw.apps ?? [],
157
+ doctor: raw.doctor ?? {}
158
+ };
159
+ }
160
+ //#endregion
161
+ //#region src/keyring.ts
162
+ /**
163
+ * Keyring-backed bootstrap credential store.
164
+ *
165
+ * Every holocron plugin's bootstrap token (the one it needs before it
166
+ * can talk to its vendor's API) can be stored in the OS keyring under
167
+ * a single reverse-DNS service scope. Managed via `holocron auth`
168
+ * subcommands; consulted at position 4 in every plugin's auth
169
+ * precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
170
+ *
171
+ * See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
172
+ *
173
+ * Failure model: keyring access is best-effort. Platforms without a
174
+ * supported credential store (some Linux CI images, sandboxed
175
+ * environments) will throw from the underlying library. Every export
176
+ * here catches and returns a null/empty result rather than propagating
177
+ * — the plugin's precedence chain then falls through to
178
+ * env-var-only paths, which is exactly how CI is meant to work.
179
+ */
180
+ const SERVICE = "com.theholocron.cli";
181
+ /**
182
+ * Store or overwrite a bootstrap token for a provider. Returns true on
183
+ * success, false when the underlying keyring is unsupported or errored.
184
+ */
185
+ function setToken(provider, token) {
186
+ try {
187
+ new Entry(SERVICE, provider).setPassword(token);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+ /**
194
+ * Read the bootstrap token for a provider. Returns `null` for both
195
+ * "not stored" and "keyring unavailable" — callers can treat them the
196
+ * same way (fall through to env-var precedence).
197
+ */
198
+ function getToken(provider) {
199
+ try {
200
+ return new Entry(SERVICE, provider).getPassword();
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+ /**
206
+ * Delete a stored token. Returns true when a token was removed, false
207
+ * when there was nothing to delete or the keyring is unavailable.
208
+ * Distinguishing the two cases isn't worth the surface area — the
209
+ * command output makes the situation clear either way.
210
+ */
211
+ function deleteToken(provider) {
212
+ try {
213
+ return new Entry(SERVICE, provider).deletePassword();
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+ /**
219
+ * List provider slugs with a stored token in this service scope.
220
+ * Uses the library's `findCredentials(service)` — supported on all
221
+ * platforms the underlying credential store supports.
222
+ */
223
+ function listStoredProviders() {
224
+ try {
225
+ return findCredentials(SERVICE).map((c) => c.account);
226
+ } catch {
227
+ return [];
228
+ }
229
+ }
230
+ //#endregion
231
+ //#region src/commands/auth.ts
232
+ /**
233
+ * `holocron auth <subcommand>` — manage bootstrap credentials in the
234
+ * OS keyring.
235
+ *
236
+ * Subcommands:
237
+ * auth set <provider> [token] verify + store
238
+ * auth unset <provider> remove
239
+ * auth check <provider> re-verify a stored token
240
+ * auth list every provider with a stored entry
241
+ *
242
+ * See `.notes/tech-auth-bootstrap.spec.md`.
243
+ *
244
+ * Verification lives in each plugin as a top-level `verifyToken(token)`
245
+ * export. The auth command dynamically imports
246
+ * `@theholocron/holocron-plugin-<provider>` and calls it. Plugins that
247
+ * don't export `verifyToken` can still store — with a warning — because
248
+ * "no verify path" shouldn't block credential storage.
249
+ */
250
+ const defaultImporter$1 = async (pkg) => await import(pkg);
251
+ /**
252
+ * Resolve a token from (positional → HOLOCRON_<X> → vendor-native env).
253
+ * Keyring is NOT consulted here — `auth set` writes TO the keyring, so
254
+ * pulling FROM it would just re-store the same value.
255
+ */
256
+ function resolveAuthSetToken(input) {
257
+ const env = input.env ?? process.env;
258
+ const upper = input.provider.toUpperCase();
259
+ const holocronKey = `HOLOCRON_${upper}_TOKEN`;
260
+ return input.positional || env[holocronKey] || env[upper + "_TOKEN"] || null;
261
+ }
262
+ async function runAuthSet(input) {
263
+ const print = input.print ?? ((l) => console.log(l));
264
+ const importer = input.importer ?? defaultImporter$1;
265
+ const { provider } = input;
266
+ const token = resolveAuthSetToken({
267
+ provider,
268
+ positional: input.positional,
269
+ env: input.env
270
+ });
271
+ const packageName = resolvePluginPackage(provider);
272
+ if (!token) {
273
+ print(`no token supplied for \`${provider}\`.`);
274
+ print(` pass as positional arg: holocron auth set ${provider} <token>`);
275
+ print(` or via env: HOLOCRON_${provider.toUpperCase()}_TOKEN / ${provider.toUpperCase()}_TOKEN`);
276
+ const hint = await tryLoadHint(importer, packageName);
277
+ if (hint) print(` hint: ${hint}`);
278
+ return {
279
+ status: "fail",
280
+ message: "no token supplied"
281
+ };
282
+ }
283
+ let subject;
284
+ try {
285
+ const module = await importer(packageName);
286
+ if (typeof module.verifyToken === "function") {
287
+ const verified = await module.verifyToken(token);
288
+ if (!verified.ok) {
289
+ print(`token rejected by ${provider}: ${verified.message}`);
290
+ if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
291
+ return {
292
+ status: "fail",
293
+ message: verified.message
294
+ };
295
+ }
296
+ subject = verified.subject;
297
+ } else print(`(${provider} plugin has no verifyToken; storing without verification)`);
298
+ } catch (err) {
299
+ print(`cannot verify token — failed to load ${packageName}: ${err instanceof Error ? err.message : String(err)}`);
300
+ print(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`);
301
+ }
302
+ if (!setToken(provider, token)) {
303
+ print(`keyring unavailable — token not stored. Use env vars instead.`);
304
+ return {
305
+ status: "fail",
306
+ message: "keyring unavailable"
307
+ };
308
+ }
309
+ print(`stored ${provider} token${subject ? ` (${subject})` : ""}`);
310
+ return {
311
+ status: "ok",
312
+ ...subject ? { message: subject } : {}
313
+ };
314
+ }
315
+ function runAuthUnset(input) {
316
+ const print = input.print ?? ((l) => console.log(l));
317
+ if (deleteToken(input.provider)) {
318
+ print(`removed ${input.provider} token`);
319
+ return { status: "ok" };
320
+ }
321
+ print(`no stored token for ${input.provider}`);
322
+ return {
323
+ status: "skip",
324
+ message: "nothing to remove"
325
+ };
326
+ }
327
+ async function runAuthCheck(input) {
328
+ const print = input.print ?? ((l) => console.log(l));
329
+ const importer = input.importer ?? defaultImporter$1;
330
+ const { provider } = input;
331
+ const token = getToken(provider);
332
+ if (!token) {
333
+ print(`no stored token for ${provider}`);
334
+ return {
335
+ status: "skip",
336
+ message: "no stored token"
337
+ };
338
+ }
339
+ const packageName = resolvePluginPackage(provider);
340
+ try {
341
+ const module = await importer(packageName);
342
+ if (typeof module.verifyToken !== "function") {
343
+ print(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`);
344
+ return {
345
+ status: "ok",
346
+ message: "stored, unverified"
347
+ };
348
+ }
349
+ const verified = await module.verifyToken(token);
350
+ if (verified.ok) {
351
+ print(`${provider}: ok — ${verified.subject}`);
352
+ return {
353
+ status: "ok",
354
+ message: verified.subject
355
+ };
356
+ }
357
+ print(`${provider}: rejected — ${verified.message}`);
358
+ if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
359
+ return {
360
+ status: "fail",
361
+ message: verified.message
362
+ };
363
+ } catch (err) {
364
+ const msg = err instanceof Error ? err.message : String(err);
365
+ print(`${provider}: cannot verify — ${msg}`);
366
+ return {
367
+ status: "fail",
368
+ message: msg
369
+ };
370
+ }
371
+ }
372
+ async function runAuthList(input = {}) {
373
+ const print = input.print ?? ((l) => console.log(l));
374
+ const importer = input.importer ?? defaultImporter$1;
375
+ const providers = listStoredProviders();
376
+ if (providers.length === 0) {
377
+ print("no stored tokens.");
378
+ print("run: holocron auth set <provider> <token>");
379
+ return {
380
+ status: "ok",
381
+ message: "none"
382
+ };
383
+ }
384
+ for (const provider of providers.sort()) {
385
+ const check = await runAuthCheck({
386
+ provider,
387
+ importer,
388
+ print: () => {}
389
+ });
390
+ print(` ${check.status === "ok" ? "✓" : check.status === "fail" ? "✗" : "·"} ${provider}${check.message ? ` — ${check.message}` : ""}`);
391
+ }
392
+ return { status: "ok" };
393
+ }
394
+ async function tryLoadHint(importer, packageName) {
395
+ try {
396
+ return (await importer(packageName)).AUTH_HINT ?? null;
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+ //#endregion
9
402
  //#region src/loader.ts
10
403
  var LoaderError = class extends Error {
11
404
  name = "LoaderError";
@@ -58,12 +451,23 @@ var PluginLoader = class {
58
451
  });
59
452
  if (typeof module.createPlugin !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\``);
60
453
  const factory = module.createPlugin({
454
+ ...this.projectDefaults(),
61
455
  ...this.context,
62
456
  ...tuple.options
63
457
  }).capabilities[key];
64
458
  if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
65
459
  return factory();
66
460
  }
461
+ /**
462
+ * Project-level defaults that get merged into every plugin's options
463
+ * unless overridden by the CLI context or per-plugin tuple options.
464
+ * See `.notes/tech-setup-and-config.spec.md` §Design.
465
+ */
466
+ projectDefaults() {
467
+ const defaults = {};
468
+ if (this.config.project.repo) defaults.repo = this.config.project.repo;
469
+ return defaults;
470
+ }
67
471
  };
68
472
  /** Default importer — native dynamic import. */
69
473
  const defaultImporter = async (pkg) => {
@@ -189,144 +593,2688 @@ function mk(capability, provider, status, message) {
189
593
  message
190
594
  };
191
595
  }
192
- function pad(s, width) {
193
- return s.length >= width ? s : s + " ".repeat(width - s.length);
596
+ function pad(s, width) {
597
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
598
+ }
599
+ //#endregion
600
+ //#region src/commands/npm-bump-versions.ts
601
+ async function runNpmBumpVersions(input) {
602
+ const print = input.print ?? ((line) => console.log(line));
603
+ const cwd = input.cwd ?? process.cwd();
604
+ const { version, dryRun = false } = input;
605
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
606
+ const writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
607
+ const listDir = input.listDir ?? ((p) => readdirSync(p));
608
+ const isDir = input.isDir ?? ((p) => statSync(p).isDirectory());
609
+ const bumped = [];
610
+ const skipped = [];
611
+ print(`Bumping monorepo to ${version}${dryRun ? " (dry-run)" : ""}…`);
612
+ function bumpFile(absPath, label) {
613
+ let pkg;
614
+ try {
615
+ pkg = JSON.parse(readFile(absPath));
616
+ } catch {
617
+ print(` ✗ could not parse ${label}`);
618
+ return;
619
+ }
620
+ const old = pkg.version;
621
+ if (!dryRun) {
622
+ pkg.version = version;
623
+ writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
624
+ }
625
+ print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
626
+ bumped.push(label);
627
+ }
628
+ bumpFile(join(cwd, "package.json"), "root");
629
+ const packagesDir = join(cwd, "packages");
630
+ let entries;
631
+ try {
632
+ entries = listDir(packagesDir);
633
+ } catch {
634
+ return {
635
+ status: "fail",
636
+ bumped,
637
+ skipped,
638
+ message: `packages/ directory not found in ${cwd}`
639
+ };
640
+ }
641
+ for (const entry of entries) {
642
+ const pkgDir = join(packagesDir, entry);
643
+ try {
644
+ if (!isDir(pkgDir)) continue;
645
+ } catch {
646
+ continue;
647
+ }
648
+ const pkgFile = join(pkgDir, "package.json");
649
+ let pkg;
650
+ try {
651
+ pkg = JSON.parse(readFile(pkgFile));
652
+ } catch {
653
+ print(` ! skipping packages/${entry}: no package.json or malformed JSON`);
654
+ continue;
655
+ }
656
+ if (pkg.private) {
657
+ print(` · skipping private package packages/${entry}`);
658
+ skipped.push(`packages/${entry}`);
659
+ continue;
660
+ }
661
+ bumpFile(pkgFile, `packages/${entry}`);
662
+ }
663
+ return {
664
+ status: dryRun ? "dry-run" : "ok",
665
+ bumped,
666
+ skipped
667
+ };
668
+ }
669
+ //#endregion
670
+ //#region src/templates/index.ts
671
+ /**
672
+ * All reusable workflow and composite action content bundled as string
673
+ * constants so the CLI can push them to theholocron/.github without
674
+ * needing filesystem access at runtime.
675
+ *
676
+ * These are the sources of truth — the YAML files in this directory
677
+ * have been removed in favour of these constants.
678
+ */
679
+ const ACTIONS = {
680
+ "setup/action": `\
681
+ name: Setup
682
+ description: Prepare the environment and install project dependencies.
683
+
684
+ inputs:
685
+ node-version:
686
+ description: Node.js version
687
+ required: false
688
+ default: "22.x"
689
+
690
+ runs:
691
+ using: composite
692
+
693
+ steps:
694
+ - uses: theholocron/.github/.github/actions/setup-node@main
695
+ with:
696
+ node-version: \${{ inputs.node-version }}
697
+
698
+ - uses: theholocron/.github/.github/actions/install@main
699
+ `,
700
+ "install/action": `\
701
+ name: Install dependencies
702
+ description: Install project dependencies with pnpm frozen lockfile.
703
+
704
+ runs:
705
+ using: composite
706
+
707
+ steps:
708
+ - name: Install dependencies
709
+ shell: bash
710
+ run: pnpm install --frozen-lockfile
711
+ `,
712
+ "setup-node/action": `\
713
+ name: Setup Node
714
+ description: Install pnpm and Node.js with pnpm dependency caching.
715
+
716
+ inputs:
717
+ node-version:
718
+ description: Node.js version
719
+ required: false
720
+ default: "22.x"
721
+
722
+ runs:
723
+ using: composite
724
+
725
+ steps:
726
+ - name: Setup pnpm
727
+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
728
+
729
+ - name: Setup Node.js
730
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
731
+ with:
732
+ node-version: \${{ inputs.node-version }}
733
+ cache: pnpm
734
+
735
+ - name: Add node_modules/.bin to PATH
736
+ shell: bash
737
+ run: echo "$GITHUB_WORKSPACE/node_modules/.bin" >> $GITHUB_PATH
738
+ `
739
+ };
740
+ const REUSABLE_WORKFLOWS = {
741
+ audit: `\
742
+ name: Audit
743
+
744
+ on: # yamllint disable-line rule:truthy
745
+ workflow_call:
746
+ inputs:
747
+ build-script:
748
+ description: Script to build before analyzing bundle size
749
+ type: string
750
+ required: false
751
+ default: pnpm build
752
+ secrets:
753
+ BUNDLEWATCH_GITHUB_TOKEN:
754
+ required: true
755
+
756
+ jobs:
757
+ bundle-size:
758
+ name: Audit the bundle size
759
+ permissions:
760
+ contents: read
761
+ runs-on: ubuntu-latest
762
+ timeout-minutes: 15
763
+ concurrency:
764
+ group: audit-\${{ github.ref }}
765
+ cancel-in-progress: true
766
+ steps:
767
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
768
+ name: Checkout repository
769
+ with:
770
+ fetch-depth: 0
771
+
772
+ - uses: theholocron/.github/.github/actions/setup@main
773
+ name: Setup
774
+
775
+ - uses: jackyef/bundlewatch-gh-action@01f51133d3580a6daa046ca83eb233d79735e1c1 # 0.3.0
776
+ name: Analyze using BundleWatch
777
+ with:
778
+ build-script: \${{ inputs.build-script }}
779
+ bundlewatch-github-token: \${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
780
+ `,
781
+ "bookkeeping-pr": `\
782
+ name: PR Bookkeeping
783
+
784
+ on: # yamllint disable-line rule:truthy
785
+ workflow_call:
786
+ inputs:
787
+ configuration-path:
788
+ description: Path to the labeler configuration file in the calling repo
789
+ type: string
790
+ required: false
791
+ default: .github/labeler.yml
792
+
793
+ jobs:
794
+ label:
795
+ name: Add Labels to PRs
796
+ permissions:
797
+ contents: read
798
+ pull-requests: write
799
+ runs-on: ubuntu-latest
800
+ timeout-minutes: 5
801
+ concurrency:
802
+ group: bookkeeping-pr-\${{ github.event.pull_request.number }}
803
+ cancel-in-progress: true
804
+ steps:
805
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
806
+ with:
807
+ sparse-checkout: \${{ inputs.configuration-path || '.github/labeler.yml' }}
808
+ sparse-checkout-cone-mode: false
809
+
810
+ - uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
811
+ if: \${{ hashFiles(inputs.configuration-path || '.github/labeler.yml') != '' }}
812
+ with:
813
+ # Fall back to default path when triggered directly (not via workflow_call)
814
+ # because inputs.* defaults only apply on workflow_call events.
815
+ configuration-path: \${{ inputs.configuration-path || '.github/labeler.yml' }}
816
+ include-title: 1
817
+ include-body: 0
818
+ sync-labels: 1
819
+ enable-versioned-regex: 0
820
+ repo-token: \${{ github.token }}
821
+ `,
822
+ codeql: `\
823
+ name: CodeQL
824
+
825
+ on: # yamllint disable-line rule:truthy
826
+ workflow_call:
827
+ inputs:
828
+ language:
829
+ description: CodeQL language to analyze
830
+ type: string
831
+ required: false
832
+ default: javascript-typescript
833
+
834
+ jobs:
835
+ analyze:
836
+ name: Analyze (\${{ inputs.language }})
837
+ permissions:
838
+ actions: read
839
+ contents: read
840
+ security-events: write
841
+ runs-on: ubuntu-latest
842
+ timeout-minutes: 45
843
+ # Do not cancel in-progress security scans.
844
+ concurrency:
845
+ group: codeql-\${{ github.ref }}
846
+ cancel-in-progress: false
847
+ steps:
848
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
849
+ name: Checkout repository
850
+
851
+ - uses: github/codeql-action/init@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
852
+ name: Initialize CodeQL
853
+ with:
854
+ languages: \${{ inputs.language }}
855
+
856
+ - uses: github/codeql-action/autobuild@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
857
+ name: Autobuild
858
+
859
+ - uses: github/codeql-action/analyze@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
860
+ name: Analyze
861
+ with:
862
+ category: /language:\${{ inputs.language }}
863
+ `,
864
+ dependencies: `\
865
+ name: Dependencies
866
+
867
+ on: # yamllint disable-line rule:truthy
868
+ workflow_call:
869
+ secrets:
870
+ merge-token:
871
+ description: >
872
+ Optional privileged token for auto-merge. Falls back to GITHUB_TOKEN.
873
+ Required when branch protection enforces required reviews — GITHUB_TOKEN
874
+ cannot approve its own PRs.
875
+ required: false
876
+
877
+ jobs:
878
+ dependabot:
879
+ name: Update the dependencies
880
+ permissions:
881
+ contents: write
882
+ pull-requests: write
883
+ runs-on: ubuntu-latest
884
+ timeout-minutes: 5
885
+ concurrency:
886
+ group: dependencies-\${{ github.event.pull_request.number }}
887
+ cancel-in-progress: true
888
+ if: github.event.pull_request.user.login == 'dependabot[bot]'
889
+ steps:
890
+ - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2
891
+ name: Fetch Dependabot metadata
892
+ id: metadata
893
+
894
+ - run: gh pr merge --auto --squash "$PR_URL"
895
+ # --squash is intentional: repoPolicy sets allow_merge_commit: false,
896
+ # so --merge would fail on any repo using the standard preset.
897
+ name: Enable auto-merge for Dependabot PRs
898
+ if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
899
+ env:
900
+ PR_URL: \${{ github.event.pull_request.html_url }}
901
+ GH_TOKEN: \${{ secrets.merge-token || github.token }}
902
+ `,
903
+ greetings: `\
904
+ name: Greetings
905
+
906
+ on: # yamllint disable-line rule:truthy
907
+ workflow_call:
908
+
909
+ jobs:
910
+ greeting:
911
+ name: Greet first-time contributors
912
+ permissions:
913
+ issues: write
914
+ pull-requests: write
915
+ runs-on: ubuntu-latest
916
+ timeout-minutes: 5
917
+ # Group by the issue/PR number so duplicate events don't race each other.
918
+ concurrency:
919
+ group: greetings-\${{ github.event.issue.number || github.event.pull_request.number }}
920
+ cancel-in-progress: false
921
+ steps:
922
+ - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
923
+ name: Greet on first contribution
924
+ with:
925
+ script: |
926
+ // Only greet on the initial open — ignore synchronize, reopened, etc.
927
+ if (context.payload.action !== 'opened') return;
928
+
929
+ const actor = context.actor;
930
+ const { owner, repo } = context.repo;
931
+
932
+ // Payload inspection is more reliable than context.eventName for detecting
933
+ // whether this is an issue vs. PR event — works regardless of how GitHub
934
+ // propagates event names through workflow_call chains.
935
+ const isIssue = !!context.payload.issue && !context.payload.pull_request;
936
+ // listForRepo returns both issues and PRs (GitHub treats PRs as issues),
937
+ // sorted newest-first. Filter by type to track first-issue and first-PR
938
+ // independently, and avoid search-index eventual-consistency lag.
939
+ const { data: recent } = await github.rest.issues.listForRepo({
940
+ owner, repo,
941
+ creator: actor,
942
+ state: 'all',
943
+ per_page: 100
944
+ });
945
+
946
+ const sameType = recent.filter(item =>
947
+ isIssue ? !item.pull_request : !!item.pull_request
948
+ );
949
+
950
+ if (sameType.length !== 1) return;
951
+ const body = isIssue
952
+ ? \`Hey @\${actor}!\\n\\nWe really appreciate you taking the time to report an issue. The collaborators on this project attempt to help as many people as possible, but we are a limited number of volunteers, so it is possible that this will not be addressed as swiftly.\\n\\nYour patience is much appreciated and we will get back to you as quickly as possible.\`
953
+ : \`Hey @\${actor}!\\n\\nWe really appreciate you taking the time to help out with this PR. The collaborators on this project attempt to help as many people as possible, but we are a limited number of volunteers, so it is possible that this will not be addressed as swiftly.\\n\\nYour patience is much appreciated and we will get back to you as quickly as possible.\`;
954
+
955
+ await github.rest.issues.createComment({
956
+ owner,
957
+ repo,
958
+ issue_number: context.issue.number,
959
+ body
960
+ });
961
+ `,
962
+ lint: `\
963
+ name: Lint
964
+
965
+ on: # yamllint disable-line rule:truthy
966
+ workflow_call:
967
+ inputs:
968
+ prettier-config:
969
+ type: string
970
+ required: false
971
+ default: prettier.config.js
972
+ yaml-config:
973
+ type: string
974
+ required: false
975
+ default: yamllint.config.yml
976
+ enable-auto-commit:
977
+ description: Auto-commit super-linter fixes via GPG-signed commit
978
+ type: boolean
979
+ required: false
980
+ default: false
981
+ secrets:
982
+ SUPER_LINTER_GPG_PRIVATE_KEY:
983
+ required: false
984
+ SUPER_LINTER_GPG_PASSPHRASE:
985
+ required: false
986
+
987
+ jobs:
988
+ super-lint:
989
+ name: Lint entire codebase
990
+ permissions:
991
+ contents: write
992
+ statuses: write
993
+ runs-on: ubuntu-latest
994
+ timeout-minutes: 30
995
+ steps:
996
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
997
+ name: Checkout repository
998
+ with:
999
+ fetch-depth: 0
1000
+ token: \${{ github.token }}
1001
+
1002
+ - uses: theholocron/.github/.github/actions/setup@main
1003
+ name: Setup
1004
+
1005
+ - uses: super-linter/super-linter/slim@b92721f792f381cedc002ecdbb9847a15ece5bb8 # v7.1.0
1006
+ name: Run Super Linter
1007
+ env:
1008
+ GITHUB_TOKEN: \${{ github.token }}
1009
+ ANNOTATE_ONLY: true
1010
+ DISABLE_COMMENTS: false
1011
+ IGNORE_GITIGNORED_FILES: true
1012
+ LINTER_RULES_PATH: /
1013
+ EDITORCONFIG_FILE_NAME: ".editorconfig-checker.json"
1014
+ FIX_ENV: true
1015
+ FIX_GRAPHQL_PRETTIER: true
1016
+ FIX_HTML_PRETTIER: true
1017
+ FIX_JAVASCRIPT_PRETTIER: true
1018
+ FIX_JSX_PRETTIER: true
1019
+ FIX_MARKDOWN_PRETTIER: true
1020
+ FIX_TSX: true
1021
+ FIX_TYPESCRIPT_PRETTIER: true
1022
+ PRETTIER_CONFIG: \${{ inputs.prettier-config }}
1023
+ VALIDATE_DOCKERFILE: true
1024
+ VALIDATE_EDITORCONFIG: true
1025
+ VALIDATE_ENV: true
1026
+ VALIDATE_GIT_COMMITLINT: true
1027
+ VALIDATE_GIT_MERGE_CONFLICT_MARKERS: true
1028
+ VALIDATE_GITHUB_ACTIONS: true
1029
+ VALIDATE_GITLEAKS: true
1030
+ VALIDATE_GRAPHQL_PRETTIER: true
1031
+ VALIDATE_HTML_PRETTIER: true
1032
+ VALIDATE_JAVASCRIPT_PRETTIER: true
1033
+ VALIDATE_JSX_PRETTIER: true
1034
+ VALIDATE_MARKDOWN_PRETTIER: true
1035
+ VALIDATE_TSX: true
1036
+ VALIDATE_TYPESCRIPT_PRETTIER: true
1037
+ VALIDATE_YAML: true
1038
+ YAML_CONFIG_FILE: \${{ inputs.yaml-config }}
1039
+
1040
+ - uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
1041
+ name: Import GPG Key
1042
+ # Conditions mirror auto-commit exactly — no point importing GPG if the
1043
+ # commit step will be skipped (fork PR, default branch, or secret unset).
1044
+ if: >
1045
+ inputs.enable-auto-commit == true &&
1046
+ github.event.pull_request != null &&
1047
+ github.event.pull_request.head.repo.full_name == github.repository &&
1048
+ github.ref_name != github.event.repository.default_branch &&
1049
+ secrets.SUPER_LINTER_GPG_PRIVATE_KEY != ''
1050
+ with:
1051
+ git_user_signingkey: true
1052
+ git_commit_gpgsign: true
1053
+ GPG_PRIVATE_KEY: \${{ secrets.SUPER_LINTER_GPG_PRIVATE_KEY }}
1054
+ PASSPHRASE: \${{ secrets.SUPER_LINTER_GPG_PASSPHRASE }}
1055
+
1056
+ - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5
1057
+ name: Commit and push linting fixes
1058
+ if: >
1059
+ inputs.enable-auto-commit == true &&
1060
+ github.event.pull_request != null &&
1061
+ github.event.pull_request.head.repo.full_name == github.repository &&
1062
+ github.ref_name != github.event.repository.default_branch &&
1063
+ secrets.SUPER_LINTER_GPG_PRIVATE_KEY != ''
1064
+ with:
1065
+ branch: \${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}
1066
+ commit_message: "chore: fix linting issues"
1067
+ commit_options: "--no-verify --signoff"
1068
+ commit_user_name: super-linter
1069
+ commit_user_email: super-linter@super-linter.dev
1070
+ `,
1071
+ release: `\
1072
+ name: Release
1073
+
1074
+ # Semantic-release with OIDC Trusted Publishing — no NPM_TOKEN required.
1075
+ # The calling repo must have a .releaserc.json that configures branches,
1076
+ # plugins, and any publish options. npm@11+ is installed to support OIDC.
1077
+
1078
+ on: # yamllint disable-line rule:truthy
1079
+ workflow_call:
1080
+ inputs:
1081
+ run-build:
1082
+ description: Run \`pnpm build\` before releasing
1083
+ type: boolean
1084
+ required: false
1085
+ default: true
1086
+
1087
+ jobs:
1088
+ release:
1089
+ name: Semantic release
1090
+ permissions:
1091
+ contents: write
1092
+ id-token: write
1093
+ issues: write
1094
+ pull-requests: write
1095
+ runs-on: ubuntu-latest
1096
+ timeout-minutes: 30
1097
+ # Do not cancel in-progress releases — a partial release is worse than a slow one.
1098
+ concurrency:
1099
+ group: release-\${{ github.ref }}
1100
+ cancel-in-progress: false
1101
+ steps:
1102
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1103
+ name: Checkout repository
1104
+ with:
1105
+ fetch-depth: 0
1106
+ token: \${{ github.token }}
1107
+
1108
+ - uses: theholocron/.github/.github/actions/setup@main
1109
+ name: Setup
1110
+
1111
+ - run: npm install -g npm@11 sigstore
1112
+ name: Upgrade npm for OIDC support
1113
+ # sigstore is required by libnpmpublish/provenance.js at module parse
1114
+ # time — before any config takes effect. Some npm 11.x builds stopped
1115
+ # bundling it; installing it globally into the same prefix ensures it
1116
+ # resolves regardless of npm version. (Discovered 2026-07-09.)
1117
+
1118
+ - run: pnpm build
1119
+ name: Build
1120
+ if: \${{ inputs.run-build == true }}
1121
+
1122
+ - run: npx semantic-release
1123
+ name: Release
1124
+ env:
1125
+ GITHUB_TOKEN: \${{ github.token }}
1126
+ NPM_CONFIG_PROVENANCE: true
1127
+ `,
1128
+ review: `\
1129
+ name: Review
1130
+
1131
+ # ReviewDog is the annotation layer — posts inline PR diff annotations.
1132
+ # Runs on pull_request only: inline annotations require PR context,
1133
+ # and branch protection ensures all changes go through PRs anyway.
1134
+ # super-linter (lint.yml) is the CI gate covering push + PR events.
1135
+ # Gitleaks and YAML are intentionally duplicated: super-linter gates
1136
+ # merges; ReviewDog surfaces exact line annotations in the PR diff.
1137
+
1138
+ on: # yamllint disable-line rule:truthy
1139
+ workflow_call:
1140
+
1141
+ concurrency:
1142
+ group: review-\${{ github.workflow }}-\${{ github.ref }}
1143
+ cancel-in-progress: true
1144
+
1145
+ jobs:
1146
+ reviewdog:
1147
+ name: Review PRs
1148
+ runs-on: ubuntu-latest
1149
+ timeout-minutes: 20
1150
+ permissions:
1151
+ contents: read
1152
+ checks: write
1153
+ pull-requests: write
1154
+
1155
+ steps:
1156
+ - name: Checkout repository
1157
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1158
+
1159
+ - name: Setup
1160
+ if: \${{ hashFiles('pnpm-lock.yaml') != '' }}
1161
+ uses: theholocron/.github/.github/actions/setup@main
1162
+
1163
+ - name: Install ReviewDog
1164
+ uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1
1165
+ with:
1166
+ reviewdog_version: latest
1167
+
1168
+ # Detect which tools are relevant for this repo, excluding node_modules.
1169
+ # hashFiles('**/*') recurses into node_modules/.pnpm and produces false
1170
+ # positives for repos that don't own those file types.
1171
+ # -print -quit stops find after the first match without a pipe, avoiding
1172
+ # the SIGPIPE/pipefail exit-141 that find|head-1 triggers under
1173
+ # GitHub Actions' default bash --noprofile --norc -e -o pipefail mode.
1174
+ - name: Detect project features
1175
+ id: detect
1176
+ shell: bash
1177
+ run: |
1178
+ has() { find . -not -path '*/node_modules/*' -name "$1" -print -quit 2>/dev/null | grep -q .; }
1179
+ has_ext() { find . -not -path '*/node_modules/*' -name "$1" -print -quit 2>/dev/null | grep -q .; }
1180
+ { { has 'eslint.config.js' || has 'eslint.config.mjs' || has 'eslint.config.cjs' || \\
1181
+ has 'eslint.config.ts' || has '.eslintrc' || has '.eslintrc.js' || \\
1182
+ has '.eslintrc.cjs' || has '.eslintrc.json' || has '.eslintrc.yaml' || \\
1183
+ has '.eslintrc.yml'; } && grep -qF '"eslint":' package.json 2>/dev/null; } && echo "eslint=true" >> "$GITHUB_OUTPUT" || echo "eslint=false" >> "$GITHUB_OUTPUT"
1184
+ { has 'tsconfig.json' && grep -qF '"typescript":' package.json 2>/dev/null; } && echo "tsconfig=true" >> "$GITHUB_OUTPUT" || echo "tsconfig=false" >> "$GITHUB_OUTPUT"
1185
+ has_ext '*.sh' && echo "shell=true" >> "$GITHUB_OUTPUT" || echo "shell=false" >> "$GITHUB_OUTPUT"
1186
+ has 'Dockerfile' || has_ext '*.Dockerfile' || has 'Containerfile' && \\
1187
+ echo "docker=true" >> "$GITHUB_OUTPUT" || echo "docker=false" >> "$GITHUB_OUTPUT"
1188
+ has_ext '.env*' && echo "dotenv=true" >> "$GITHUB_OUTPUT" || echo "dotenv=false" >> "$GITHUB_OUTPUT"
1189
+ has_ext '*.md' && echo "markdown=true" >> "$GITHUB_OUTPUT" || echo "markdown=false" >> "$GITHUB_OUTPUT"
1190
+
1191
+ #
1192
+ # Always applicable
1193
+ #
1194
+
1195
+ - name: Gitleaks (secrets)
1196
+ uses: reviewdog/action-gitleaks@2b7b5685e3e3eecddab5d30cfa04f18123031421 # v1
1197
+ with:
1198
+ reporter: github-pr-check
1199
+
1200
+ - name: YamlLint
1201
+ uses: reviewdog/action-yamllint@b5f7217d8c815ae374d1d55840d5e569d82f01f0 # v1
1202
+ with:
1203
+ reporter: github-pr-check
1204
+ yamllint_flags: >-
1205
+ \${{ hashFiles('yamllint.config.yml') != ''
1206
+ && format('-c {0}/yamllint.config.yml {0}', github.workspace)
1207
+ || github.workspace }}
1208
+
1209
+ - name: ActionLint (GitHub Actions)
1210
+ if: \${{ hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}
1211
+ uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1
1212
+ with:
1213
+ reporter: github-pr-check
1214
+
1215
+ #
1216
+ # TypeScript / JavaScript
1217
+ #
1218
+
1219
+ - name: ESLint
1220
+ if: steps.detect.outputs.eslint == 'true'
1221
+ uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0
1222
+ with:
1223
+ reporter: github-pr-check
1224
+ eslint_flags: .
1225
+
1226
+ - name: TypeScript
1227
+ if: steps.detect.outputs.tsconfig == 'true'
1228
+ uses: EPMatt/reviewdog-action-tsc@63d923a3c5b4497671940b8874f58a404e2351b5 # v1
1229
+ with:
1230
+ reporter: github-pr-check
1231
+
1232
+ #
1233
+ # Shell
1234
+ #
1235
+
1236
+ - name: ShellCheck
1237
+ if: steps.detect.outputs.shell == 'true'
1238
+ uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1
1239
+ with:
1240
+ reporter: github-pr-check
1241
+ fail_level: none
1242
+
1243
+ #
1244
+ # Docker
1245
+ #
1246
+
1247
+ - name: Hadolint
1248
+ if: steps.detect.outputs.docker == 'true'
1249
+ uses: reviewdog/action-hadolint@1b2cfa6ba72072ad35158d7ff3aa49bbdc03506d # v1
1250
+ with:
1251
+ reporter: github-pr-check
1252
+ fail_level: none
1253
+
1254
+ #
1255
+ # Environment files
1256
+ #
1257
+
1258
+ - name: dotenv-linter
1259
+ if: steps.detect.outputs.dotenv == 'true'
1260
+ uses: dotenv-linter/action-dotenv-linter@21287e2624aaf2dc8da5dd8ccfe8e49c63501116 # v2
1261
+ with:
1262
+ reporter: github-code-suggestions
1263
+
1264
+ #
1265
+ # Documentation
1266
+ #
1267
+
1268
+ - name: Alex (inclusive language)
1269
+ if: steps.detect.outputs.markdown == 'true'
1270
+ uses: reviewdog/action-alex@347481655add010a2ae302df34b57c9bcfa0d6e4 # v1
1271
+ with:
1272
+ reporter: github-pr-check
1273
+ `,
1274
+ stale: `\
1275
+ name: Stale
1276
+
1277
+ on: # yamllint disable-line rule:truthy
1278
+ workflow_call:
1279
+ inputs:
1280
+ days-before-stale:
1281
+ description: Days of inactivity before an issue is marked stale
1282
+ type: number
1283
+ required: false
1284
+ default: 30
1285
+ days-before-close:
1286
+ description: Days of inactivity after stale label before closing
1287
+ type: number
1288
+ required: false
1289
+ default: 5
1290
+
1291
+ jobs:
1292
+ stale:
1293
+ name: Mark stale issues and pull requests
1294
+ permissions:
1295
+ contents: write
1296
+ issues: write
1297
+ pull-requests: write
1298
+ runs-on: ubuntu-latest
1299
+ timeout-minutes: 10
1300
+ steps:
1301
+ - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
1302
+ name: Run Stale
1303
+ with:
1304
+ close-issue-message: >
1305
+ This issue was closed because it has been stalled for
1306
+ \${{ inputs.days-before-close }} days with no activity.
1307
+ days-before-close: \${{ inputs.days-before-close }}
1308
+ days-before-stale: \${{ inputs.days-before-stale }}
1309
+ exempt-all-pr-milestones: true
1310
+ stale-issue-label: wontfix
1311
+ stale-issue-message: >
1312
+ This issue is stale because it has been open \${{ inputs.days-before-stale }}
1313
+ days with no activity. Remove the stale label or comment, or this will be
1314
+ closed in \${{ inputs.days-before-close }} days.
1315
+ stale-pr-label: wontfix
1316
+ stale-pr-message: >
1317
+ This PR is stale because it has been open \${{ inputs.days-before-stale }}
1318
+ days with no activity. Remove the stale label or comment, or this will be
1319
+ closed in \${{ inputs.days-before-close }} days.
1320
+ `,
1321
+ test: `\
1322
+ name: Test
1323
+
1324
+ on: # yamllint disable-line rule:truthy
1325
+ workflow_call:
1326
+ secrets:
1327
+ CODECOV_TOKEN:
1328
+ required: false
1329
+
1330
+ jobs:
1331
+ unit:
1332
+ name: Run tests and collect coverage
1333
+ permissions:
1334
+ contents: read
1335
+ runs-on: ubuntu-latest
1336
+ timeout-minutes: 15
1337
+ steps:
1338
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1339
+ name: Checkout repository
1340
+ with:
1341
+ fetch-depth: 0
1342
+
1343
+ - uses: theholocron/.github/.github/actions/setup@main
1344
+ name: Setup
1345
+
1346
+ - run: pnpm test -- --coverage
1347
+ name: Run tests with coverage
1348
+
1349
+ - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
1350
+ name: Upload results to Codecov
1351
+ with:
1352
+ token: \${{ secrets.CODECOV_TOKEN }}
1353
+ `,
1354
+ "sync-github": `\
1355
+ name: Sync GitHub Templates
1356
+
1357
+ # Builds the holocron CLI from source and pushes updated workflow templates
1358
+ # and composite actions to downstream .github repos. Runs whenever the
1359
+ # template source files change on main or alpha.
1360
+ #
1361
+ # Secrets required:
1362
+ # SYNC_TOKEN — fine-grained PAT or GitHub App token with Contents write
1363
+ # access to the primary and secondary repos.
1364
+
1365
+ on: # yamllint disable-line rule:truthy
1366
+ workflow_call:
1367
+ inputs:
1368
+ primary-repo:
1369
+ description: >
1370
+ Primary .github repo — receives composite actions, reusable workflows,
1371
+ and thin-caller templates. Requires a PR (branch protection assumed).
1372
+ type: string
1373
+ required: false
1374
+ default: theholocron/.github
1375
+ secondary-repos:
1376
+ description: >
1377
+ Space-separated list of secondary repos (reusable workflows + thin
1378
+ callers only, no composite actions). Pushed directly to main.
1379
+ type: string
1380
+ required: false
1381
+ default: ""
1382
+ sync-branch:
1383
+ description: Branch name used for the primary-repo PR
1384
+ type: string
1385
+ required: false
1386
+ default: chore/sync-templates
1387
+ secrets:
1388
+ SYNC_TOKEN:
1389
+ required: true
1390
+
1391
+ jobs:
1392
+ sync:
1393
+ name: Sync templates
1394
+ runs-on: ubuntu-latest
1395
+ timeout-minutes: 15
1396
+ permissions:
1397
+ contents: read
1398
+ steps:
1399
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1400
+ name: Checkout repository
1401
+
1402
+ - uses: theholocron/.github/.github/actions/setup@main
1403
+ name: Setup
1404
+
1405
+ - run: pnpm build
1406
+ name: Build CLI
1407
+
1408
+ - name: Validate generated workflows
1409
+ run: |
1410
+ node packages/cli/dist/cli.mjs sync-github \\
1411
+ --repo "$PRIMARY_REPO" \\
1412
+ --output-dir /tmp/sync-validate
1413
+ curl -fsSL https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz \\
1414
+ | tar -xz -C /tmp actionlint
1415
+ /tmp/actionlint /tmp/sync-validate/.github/workflows/*.yml
1416
+ env:
1417
+ PRIMARY_REPO: \${{ inputs.primary-repo }}
1418
+
1419
+ - name: Sync primary repo (PR)
1420
+ run: >
1421
+ node packages/cli/dist/cli.mjs sync-github
1422
+ --repo "$PRIMARY_REPO"
1423
+ --branch "$SYNC_BRANCH"
1424
+ --pr
1425
+ env:
1426
+ GITHUB_TOKEN: \${{ secrets.SYNC_TOKEN }}
1427
+ PRIMARY_REPO: \${{ inputs.primary-repo }}
1428
+ SYNC_BRANCH: \${{ inputs.sync-branch }}
1429
+
1430
+ - name: Sync secondary repos (direct push)
1431
+ if: \${{ inputs.secondary-repos != '' }}
1432
+ run: |
1433
+ for repo in $SECONDARY_REPOS; do
1434
+ node packages/cli/dist/cli.mjs sync-github --repo "$repo"
1435
+ done
1436
+ env:
1437
+ GITHUB_TOKEN: \${{ secrets.SYNC_TOKEN }}
1438
+ SECONDARY_REPOS: \${{ inputs.secondary-repos }}
1439
+ `,
1440
+ typecheck: `\
1441
+ name: Typecheck
1442
+
1443
+ on: # yamllint disable-line rule:truthy
1444
+ workflow_call:
1445
+
1446
+ jobs:
1447
+ typecheck:
1448
+ name: tsc --noEmit
1449
+ permissions:
1450
+ contents: read
1451
+ runs-on: ubuntu-latest
1452
+ timeout-minutes: 10
1453
+ steps:
1454
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1455
+ name: Checkout repository
1456
+
1457
+ - uses: theholocron/.github/.github/actions/setup@main
1458
+ name: Setup
1459
+
1460
+ - run: pnpm typecheck
1461
+ name: Type check
1462
+ `
1463
+ };
1464
+ const WORKFLOW_TEMPLATE_PROPERTIES = { "sync-github": JSON.stringify({
1465
+ name: "Sync GitHub Templates",
1466
+ description: "Sync workflow templates and composite actions from the holocron CLI.",
1467
+ iconName: "octicon sync"
1468
+ }, null, 2) };
1469
+ //#endregion
1470
+ //#region src/commands/setup-workflows.ts
1471
+ /**
1472
+ * Thin workflow wrapper templates for `holocron setup`.
1473
+ *
1474
+ * Each entry is a complete `.github/workflows/<name>.yml` that delegates
1475
+ * to the corresponding reusable `ci-<name>.yml` in `theholocron/.github`.
1476
+ * Files are overwritten on each setup run — they are generated artifacts.
1477
+ */
1478
+ const WORKFLOW_REPO = "theholocron/.github";
1479
+ const WORKFLOW_REF = "main";
1480
+ function ref(name) {
1481
+ return `${WORKFLOW_REPO}/.github/workflows/${name}.yml@${WORKFLOW_REF}`;
1482
+ }
1483
+ /** Header prepended when holocron setup writes a thin caller to a repo. */
1484
+ function workflowHeader() {
1485
+ return [
1486
+ `# AUTO-GENERATED — do not edit directly.`,
1487
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
1488
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1489
+ `# Tool: holocron setup`,
1490
+ `# Changes: run \`holocron setup\` to regenerate.`,
1491
+ ``
1492
+ ].join("\n");
1493
+ }
1494
+ const WORKFLOW_TEMPLATES = {
1495
+ lint: `\
1496
+ name: Lint
1497
+
1498
+ on: # yamllint disable-line rule:truthy
1499
+ push:
1500
+ branches: [main, alpha]
1501
+ pull_request:
1502
+
1503
+ concurrency:
1504
+ group: lint-\${{ github.ref }}
1505
+ cancel-in-progress: true
1506
+
1507
+ permissions:
1508
+ contents: write
1509
+ statuses: write
1510
+
1511
+ jobs:
1512
+ lint:
1513
+ name: Lint
1514
+ uses: ${ref("lint")}
1515
+ secrets: inherit
1516
+ with:
1517
+ enable-auto-commit: true
1518
+ `,
1519
+ test: `\
1520
+ name: Test
1521
+
1522
+ on: # yamllint disable-line rule:truthy
1523
+ push:
1524
+ branches: [main, alpha]
1525
+ pull_request:
1526
+
1527
+ concurrency:
1528
+ group: test-\${{ github.ref }}
1529
+ cancel-in-progress: true
1530
+
1531
+ permissions:
1532
+ contents: read
1533
+
1534
+ jobs:
1535
+ test:
1536
+ name: Test
1537
+ uses: ${ref("test")}
1538
+ secrets: inherit
1539
+ `,
1540
+ typecheck: `\
1541
+ name: Typecheck
1542
+
1543
+ on: # yamllint disable-line rule:truthy
1544
+ push:
1545
+ branches: [main, alpha]
1546
+ pull_request:
1547
+
1548
+ concurrency:
1549
+ group: typecheck-\${{ github.ref }}
1550
+ cancel-in-progress: true
1551
+
1552
+ permissions:
1553
+ contents: read
1554
+
1555
+ jobs:
1556
+ typecheck:
1557
+ name: Typecheck
1558
+ uses: ${ref("typecheck")}
1559
+ secrets: inherit
1560
+ `,
1561
+ codeql: `\
1562
+ name: CodeQL
1563
+
1564
+ on: # yamllint disable-line rule:truthy
1565
+ push:
1566
+ branches:
1567
+ - main
1568
+ pull_request:
1569
+ branches:
1570
+ - main
1571
+ schedule:
1572
+ - cron: "0 0 * * 1"
1573
+
1574
+ permissions:
1575
+ actions: read
1576
+ contents: read
1577
+ security-events: write
1578
+
1579
+ jobs:
1580
+ codeql:
1581
+ uses: ${ref("codeql")}
1582
+ secrets: inherit
1583
+ `,
1584
+ review: `\
1585
+ name: Review
1586
+
1587
+ on: # yamllint disable-line rule:truthy
1588
+ pull_request:
1589
+
1590
+ concurrency:
1591
+ group: review-\${{ github.ref }}
1592
+ cancel-in-progress: true
1593
+
1594
+ permissions:
1595
+ contents: read
1596
+ checks: write
1597
+ pull-requests: write
1598
+
1599
+ jobs:
1600
+ review:
1601
+ name: Review
1602
+ uses: ${ref("review")}
1603
+ secrets: inherit
1604
+ `,
1605
+ release: `\
1606
+ name: Release
1607
+
1608
+ on: # yamllint disable-line rule:truthy
1609
+ push:
1610
+ branches:
1611
+ - main
1612
+ - alpha
1613
+ workflow_dispatch:
1614
+
1615
+ permissions:
1616
+ contents: write
1617
+ id-token: write
1618
+ issues: write
1619
+ pull-requests: write
1620
+
1621
+ concurrency:
1622
+ group: \${{ github.workflow }}-\${{ github.ref }}
1623
+ cancel-in-progress: false
1624
+
1625
+ jobs:
1626
+ release:
1627
+ uses: ${ref("release")}
1628
+ secrets: inherit
1629
+ `,
1630
+ stale: `\
1631
+ name: Stale
1632
+
1633
+ on: # yamllint disable-line rule:truthy
1634
+ schedule:
1635
+ - cron: "30 1 * * *"
1636
+
1637
+ permissions:
1638
+ contents: write
1639
+ issues: write
1640
+ pull-requests: write
1641
+
1642
+ jobs:
1643
+ stale:
1644
+ uses: ${ref("stale")}
1645
+ secrets: inherit
1646
+ `,
1647
+ greetings: `\
1648
+ name: Greetings
1649
+
1650
+ on: # yamllint disable-line rule:truthy
1651
+ pull_request:
1652
+ issues:
1653
+
1654
+ permissions:
1655
+ issues: write
1656
+ pull-requests: write
1657
+
1658
+ jobs:
1659
+ greetings:
1660
+ uses: ${ref("greetings")}
1661
+ secrets: inherit
1662
+ `,
1663
+ dependencies: `\
1664
+ name: Dependencies
1665
+
1666
+ on: # yamllint disable-line rule:truthy
1667
+ pull_request:
1668
+
1669
+ permissions:
1670
+ contents: write
1671
+ pull-requests: write
1672
+
1673
+ jobs:
1674
+ dependencies:
1675
+ uses: ${ref("dependencies")}
1676
+ secrets: inherit
1677
+ `,
1678
+ "bookkeeping-pr": `\
1679
+ name: PR Bookkeeping
1680
+
1681
+ on: # yamllint disable-line rule:truthy
1682
+ pull_request:
1683
+ types:
1684
+ - opened
1685
+ - edited
1686
+
1687
+ permissions:
1688
+ contents: read
1689
+ pull-requests: write
1690
+
1691
+ jobs:
1692
+ bookkeeping:
1693
+ uses: ${ref("bookkeeping-pr")}
1694
+ secrets: inherit
1695
+ `,
1696
+ audit: `\
1697
+ name: Audit
1698
+
1699
+ on: # yamllint disable-line rule:truthy
1700
+ push:
1701
+ branches: [main, alpha]
1702
+ pull_request:
1703
+
1704
+ permissions:
1705
+ contents: read
1706
+
1707
+ jobs:
1708
+ audit:
1709
+ uses: ${ref("audit")}
1710
+ secrets: inherit
1711
+ `,
1712
+ "sync-github": `\
1713
+ name: Sync GitHub Templates
1714
+
1715
+ on: # yamllint disable-line rule:truthy
1716
+ push:
1717
+ branches: [main, alpha]
1718
+ paths:
1719
+ - packages/cli/src/templates/index.ts
1720
+ - packages/cli/src/commands/setup-workflows.ts
1721
+
1722
+ concurrency:
1723
+ group: sync-github-\${{ github.ref }}
1724
+ cancel-in-progress: true
1725
+
1726
+ permissions:
1727
+ contents: read
1728
+
1729
+ jobs:
1730
+ sync:
1731
+ name: Sync
1732
+ uses: ${ref("sync-github")}
1733
+ with:
1734
+ secondary-repos: theholocron/.github-private
1735
+ secrets:
1736
+ SYNC_TOKEN: \${{ secrets.SYNC_TOKEN }}
1737
+ `
1738
+ };
1739
+ const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
1740
+ /**
1741
+ * Generate the thin caller content for a workflow, optionally injecting
1742
+ * `with:` inputs before `secrets: inherit` in the jobs block.
1743
+ */
1744
+ function generateThinCallerContent(name, withOverrides) {
1745
+ const base = WORKFLOW_TEMPLATES[name];
1746
+ if (!base) return "";
1747
+ if (!withOverrides || Object.keys(withOverrides).length === 0) return base;
1748
+ const withBlock = Object.entries(withOverrides).map(([k, v]) => ` ${k}: ${v === true ? "true" : v === false ? "false" : String(v)}`).join("\n");
1749
+ return base.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
1750
+ }
1751
+ //#endregion
1752
+ //#region src/commands/sync-github.ts
1753
+ const DEFAULT_REPO = "theholocron/.github";
1754
+ const API_BASE = "https://api.github.com";
1755
+ function reusableHeader(source) {
1756
+ return [
1757
+ `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
1758
+ `# Source: theholocron/holocron · ${source}`,
1759
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1760
+ `# Tool: holocron sync-github`,
1761
+ `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1762
+ ``
1763
+ ].join("\n");
1764
+ }
1765
+ function thinCallerHeader() {
1766
+ return [
1767
+ `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
1768
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
1769
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1770
+ `# Tool: holocron sync-github`,
1771
+ `# Changes: edit setup-workflows.ts in theholocron/holocron and push.`,
1772
+ ``
1773
+ ].join("\n");
1774
+ }
1775
+ function buildBatch(repo, allowedWorkflows) {
1776
+ const files = [];
1777
+ const isPrimaryGithubRepo = repo === DEFAULT_REPO;
1778
+ if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(ACTIONS)) files.push({
1779
+ path: `.github/actions/${name}.yml`,
1780
+ content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1781
+ });
1782
+ for (const [name, content] of Object.entries(REUSABLE_WORKFLOWS)) {
1783
+ if (allowedWorkflows && !allowedWorkflows.has(name)) continue;
1784
+ files.push({
1785
+ path: `.github/workflows/${name}.yml`,
1786
+ content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1787
+ });
1788
+ }
1789
+ if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(WORKFLOW_TEMPLATES)) {
1790
+ files.push({
1791
+ path: `workflow-templates/${name}.yml`,
1792
+ content: thinCallerHeader() + content
1793
+ });
1794
+ const props = WORKFLOW_TEMPLATE_PROPERTIES[name];
1795
+ if (props) files.push({
1796
+ path: `workflow-templates/${name}.properties.json`,
1797
+ content: props
1798
+ });
1799
+ }
1800
+ return files;
1801
+ }
1802
+ /** Git blob SHA: sha1("blob {len}\0{content}") — used to detect unchanged files. */
1803
+ function gitBlobSha(content) {
1804
+ const buf = Buffer.from(content, "utf8");
1805
+ return createHash("sha1").update(`blob ${buf.length}\0`).update(buf).digest("hex");
1806
+ }
1807
+ async function runSyncGithub(input) {
1808
+ const print = input.print ?? ((line) => console.log(line));
1809
+ const repo = input.repo ?? DEFAULT_REPO;
1810
+ const [owner, repoName] = repo.split("/");
1811
+ const { token, dryRun = false, branch, createPr = false } = input;
1812
+ const message = input.message ?? `chore: sync from theholocron/holocron`;
1813
+ const fetchFn = input.fetch ?? globalThis.fetch;
1814
+ const headers = {
1815
+ Authorization: `Bearer ${token}`,
1816
+ Accept: "application/vnd.github+json",
1817
+ "Content-Type": "application/json",
1818
+ "X-GitHub-Api-Version": "2022-11-28"
1819
+ };
1820
+ print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
1821
+ print(` repo: ${repo}`);
1822
+ if (branch) print(` branch: ${branch}`);
1823
+ print("");
1824
+ if (input.outputDir) {
1825
+ const batch = buildBatch(repo);
1826
+ for (const file of batch) {
1827
+ const dest = join(input.outputDir, file.path);
1828
+ mkdirSync(dirname(dest), { recursive: true });
1829
+ writeFileSync(dest, file.content, "utf8");
1830
+ }
1831
+ print(` ${batch.length} files written to ${input.outputDir}`);
1832
+ return {
1833
+ status: "ok",
1834
+ created: batch.length,
1835
+ updated: 0,
1836
+ unchanged: 0
1837
+ };
1838
+ }
1839
+ let targetBranch = branch;
1840
+ if (!targetBranch) {
1841
+ const repoRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}`, { headers });
1842
+ if (!repoRes.ok) {
1843
+ const msg = "failed to fetch repo metadata";
1844
+ print(` ✗ ${msg}`);
1845
+ return {
1846
+ status: "fail",
1847
+ created: 0,
1848
+ updated: 0,
1849
+ unchanged: 0,
1850
+ message: msg
1851
+ };
1852
+ }
1853
+ targetBranch = (await repoRes.json()).default_branch;
1854
+ }
1855
+ const refRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/ref/heads/${targetBranch}`, { headers });
1856
+ if (!refRes.ok) {
1857
+ const msg = `Branch ${targetBranch} not found`;
1858
+ print(` ✗ ${msg}`);
1859
+ return {
1860
+ status: "fail",
1861
+ created: 0,
1862
+ updated: 0,
1863
+ unchanged: 0,
1864
+ message: msg
1865
+ };
1866
+ }
1867
+ const { object: { sha: headSha } } = await refRes.json();
1868
+ const { tree: { sha: baseTreeSha } } = await (await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/commits/${headSha}`, { headers })).json();
1869
+ const { tree: existingTree } = await (await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/trees/${baseTreeSha}?recursive=1`, { headers })).json();
1870
+ const existingBlobs = new Map(existingTree.filter((i) => i.type === "blob").map((i) => [i.path, i.sha]));
1871
+ let allowedWorkflows;
1872
+ if (repo !== DEFAULT_REPO) try {
1873
+ const configRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/contents/holocron.config.json`, { headers });
1874
+ if (configRes.ok) {
1875
+ const configData = await configRes.json();
1876
+ const workflows = JSON.parse(Buffer.from(configData.content.replace(/\n/g, ""), "base64").toString("utf8"))?.project?.workflows ?? [];
1877
+ if (workflows.length > 0) allowedWorkflows = new Set(workflows.map((w) => typeof w === "string" ? w : w.name));
1878
+ }
1879
+ } catch {}
1880
+ const batch = buildBatch(repo, allowedWorkflows);
1881
+ let created = 0;
1882
+ let updated = 0;
1883
+ let unchanged = 0;
1884
+ const changedFiles = [];
1885
+ for (const file of batch) {
1886
+ const localSha = gitBlobSha(file.content);
1887
+ const existingSha = existingBlobs.get(file.path);
1888
+ if (existingSha === localSha) {
1889
+ print(` · unchanged ${file.path}`);
1890
+ unchanged++;
1891
+ } else if (existingSha) {
1892
+ print(` ${dryRun ? "~" : "✓"} updated ${file.path}`);
1893
+ updated++;
1894
+ if (!dryRun) changedFiles.push(file);
1895
+ } else {
1896
+ print(` ${dryRun ? "~" : "✓"} created ${file.path}`);
1897
+ created++;
1898
+ if (!dryRun) changedFiles.push(file);
1899
+ }
1900
+ }
1901
+ print("");
1902
+ print(` ${created} created, ${updated} updated, ${unchanged} unchanged`);
1903
+ if (dryRun || changedFiles.length === 0) return {
1904
+ status: dryRun ? "dry-run" : "ok",
1905
+ created,
1906
+ updated,
1907
+ unchanged
1908
+ };
1909
+ const treeEntries = [];
1910
+ for (const file of changedFiles) {
1911
+ const blobRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/blobs`, {
1912
+ method: "POST",
1913
+ headers,
1914
+ body: JSON.stringify({
1915
+ content: file.content,
1916
+ encoding: "utf-8"
1917
+ })
1918
+ });
1919
+ if (!blobRes.ok) {
1920
+ const err = await blobRes.json();
1921
+ const msg = `failed to create blob for ${file.path}: ${err.message ?? blobRes.status}`;
1922
+ print(` ✗ ${msg}`);
1923
+ return {
1924
+ status: "fail",
1925
+ created,
1926
+ updated,
1927
+ unchanged,
1928
+ message: msg
1929
+ };
1930
+ }
1931
+ const { sha: blobSha } = await blobRes.json();
1932
+ treeEntries.push({
1933
+ path: file.path,
1934
+ mode: "100644",
1935
+ type: "blob",
1936
+ sha: blobSha
1937
+ });
1938
+ }
1939
+ const newTreeRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/trees`, {
1940
+ method: "POST",
1941
+ headers,
1942
+ body: JSON.stringify({
1943
+ base_tree: baseTreeSha,
1944
+ tree: treeEntries
1945
+ })
1946
+ });
1947
+ if (!newTreeRes.ok) {
1948
+ const msg = `failed to create tree: ${(await newTreeRes.json()).message ?? newTreeRes.status}`;
1949
+ print(` ✗ ${msg}`);
1950
+ return {
1951
+ status: "fail",
1952
+ created,
1953
+ updated,
1954
+ unchanged,
1955
+ message: msg
1956
+ };
1957
+ }
1958
+ const { sha: newTreeSha } = await newTreeRes.json();
1959
+ const newCommitRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/commits`, {
1960
+ method: "POST",
1961
+ headers,
1962
+ body: JSON.stringify({
1963
+ message,
1964
+ tree: newTreeSha,
1965
+ parents: [headSha]
1966
+ })
1967
+ });
1968
+ if (!newCommitRes.ok) {
1969
+ const msg = `failed to create commit: ${(await newCommitRes.json()).message ?? newCommitRes.status}`;
1970
+ print(` ✗ ${msg}`);
1971
+ return {
1972
+ status: "fail",
1973
+ created,
1974
+ updated,
1975
+ unchanged,
1976
+ message: msg
1977
+ };
1978
+ }
1979
+ const { sha: newCommitSha } = await newCommitRes.json();
1980
+ const updateRefRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/refs/heads/${targetBranch}`, {
1981
+ method: "PATCH",
1982
+ headers,
1983
+ body: JSON.stringify({ sha: newCommitSha })
1984
+ });
1985
+ if (!updateRefRes.ok) {
1986
+ const msg = `failed to update ref: ${(await updateRefRes.json()).message ?? updateRefRes.status}`;
1987
+ print(` ✗ ${msg}`);
1988
+ return {
1989
+ status: "fail",
1990
+ created,
1991
+ updated,
1992
+ unchanged,
1993
+ message: msg
1994
+ };
1995
+ }
1996
+ let prUrl;
1997
+ if (branch && createPr && !dryRun) {
1998
+ const prRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/pulls`, {
1999
+ method: "POST",
2000
+ headers,
2001
+ body: JSON.stringify({
2002
+ title: message,
2003
+ head: branch,
2004
+ base: "main",
2005
+ body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
2006
+ })
2007
+ });
2008
+ if (prRes.ok) {
2009
+ prUrl = (await prRes.json()).html_url;
2010
+ print(` → PR opened: ${prUrl}`);
2011
+ } else {
2012
+ const err = await prRes.json();
2013
+ if (err.errors?.some((e) => e.message.includes("already exists"))) print(` → PR already open for ${branch} — branch updated, ready to merge`);
2014
+ else print(` ⚠ PR creation failed: ${err.message ?? prRes.status}`);
2015
+ }
2016
+ }
2017
+ return {
2018
+ status: "ok",
2019
+ created,
2020
+ updated,
2021
+ unchanged,
2022
+ prUrl
2023
+ };
2024
+ }
2025
+ //#endregion
2026
+ //#region src/commands/npm-publish-initial.ts
2027
+ /**
2028
+ * `holocron npm publish-initial` — bottles up the chicken-and-egg
2029
+ * bootstrap that every new npm-published holocron monorepo hits.
2030
+ *
2031
+ * npm requires a package to exist before Trusted Publishing can be
2032
+ * configured on it. So the first publish has to happen outside the
2033
+ * OIDC flow — using either a browser-auth session (`npm login
2034
+ * --auth-type=web`) or an ephemeral automation token. This command
2035
+ * runs the publish step + tells you exactly what to do next.
2036
+ *
2037
+ * Workflow:
2038
+ *
2039
+ * $ npm login --auth-type=web # one-time, browser-based
2040
+ * $ pnpm install --frozen-lockfile
2041
+ * $ pnpm build
2042
+ * $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
2043
+ *
2044
+ * The command itself only handles the publish step + the post-publish
2045
+ * Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
2046
+ * stay outside the command (no pnpm-inside-pnpm).
2047
+ *
2048
+ * If `NPM_TOKEN` is detected in env, the command prints a final
2049
+ * "revoke this token at <url>" reminder — same pattern as `rando vc
2050
+ * setup` for the ephemeral GH admin PAT.
2051
+ */
2052
+ const PUBLISHABLE_PACKAGES = [
2053
+ "@theholocron/cli",
2054
+ "@theholocron/holocron-plugin-github",
2055
+ "@theholocron/holocron-plugin-vercel",
2056
+ "@theholocron/holocron-plugin-neon",
2057
+ "@theholocron/holocron-plugin-clerk",
2058
+ "@theholocron/holocron-plugin-1password",
2059
+ "@theholocron/holocron-plugin-postman"
2060
+ ];
2061
+ async function runNpmPublishInitial(input = {}) {
2062
+ const print = input.print ?? ((line) => console.log(line));
2063
+ const cwd = input.cwd ?? process.cwd();
2064
+ const tag = input.tag ?? "alpha";
2065
+ const dryRun = input.dryRun ?? false;
2066
+ const otp = input.otp;
2067
+ const env = input.env ?? process.env;
2068
+ const exec = input.exec ?? defaultExec;
2069
+ const publishArgs = [
2070
+ "-r",
2071
+ "--filter=./packages/*",
2072
+ "--filter=!@theholocron/cli-utils",
2073
+ "publish",
2074
+ "--access",
2075
+ "public",
2076
+ "--no-git-checks",
2077
+ "--tag",
2078
+ tag,
2079
+ ...otp ? ["--otp", otp] : []
2080
+ ];
2081
+ print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
2082
+ print(` cwd: ${cwd}`);
2083
+ print(` tag: ${tag}`);
2084
+ if (otp) print(` otp: <${otp.length} chars>`);
2085
+ print("");
2086
+ print(" → verifying npm auth (`npm whoami`)…");
2087
+ const whoami = await exec("npm", ["whoami"], { cwd });
2088
+ if (whoami.exitCode !== 0) {
2089
+ 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.";
2090
+ print(` ✗ ${message}`);
2091
+ return {
2092
+ status: "fail",
2093
+ message,
2094
+ packageNames: PUBLISHABLE_PACKAGES
2095
+ };
2096
+ }
2097
+ print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
2098
+ if (dryRun) {
2099
+ print("");
2100
+ print(" … (dry-run) skipping actual publish");
2101
+ print(` would run: pnpm ${publishArgs.join(" ")}`);
2102
+ printNextSteps$1(print, env);
2103
+ return {
2104
+ status: "dry-run",
2105
+ message: "dry-run — no publish executed",
2106
+ packageNames: PUBLISHABLE_PACKAGES
2107
+ };
2108
+ }
2109
+ print("");
2110
+ print(" → publishing all public @theholocron/* packages…");
2111
+ const publish = await exec("pnpm", publishArgs, { cwd });
2112
+ if (publish.exitCode !== 0) {
2113
+ const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
2114
+ print(` ✗ ${message}`);
2115
+ if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
2116
+ print("");
2117
+ print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
2118
+ print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
2119
+ }
2120
+ return {
2121
+ status: "fail",
2122
+ message,
2123
+ packageNames: PUBLISHABLE_PACKAGES
2124
+ };
2125
+ }
2126
+ print(" ✓ publish complete");
2127
+ printNextSteps$1(print, env);
2128
+ return {
2129
+ status: "ok",
2130
+ packageNames: PUBLISHABLE_PACKAGES
2131
+ };
2132
+ }
2133
+ function printNextSteps$1(print, env) {
2134
+ print("");
2135
+ print(" → next: configure Trusted Publisher for each package on npm:");
2136
+ for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
2137
+ print(" Publisher: GitHub Actions Org: theholocron Repo: holocron Workflow: release.yml");
2138
+ if (env.NPM_TOKEN) {
2139
+ print("");
2140
+ print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
2141
+ print(" https://www.npmjs.com/settings/~/tokens");
2142
+ }
2143
+ }
2144
+ const defaultExec = async (cmd, args, opts) => {
2145
+ const result = spawnSync(cmd, args, {
2146
+ cwd: opts.cwd,
2147
+ encoding: "utf8",
2148
+ stdio: [
2149
+ "inherit",
2150
+ "pipe",
2151
+ "pipe"
2152
+ ]
2153
+ });
2154
+ return {
2155
+ exitCode: result.status ?? -1,
2156
+ stdout: result.stdout ?? "",
2157
+ stderr: result.stderr ?? ""
2158
+ };
2159
+ };
2160
+ //#endregion
2161
+ //#region src/commands/plugin-create/template-inputs.ts
2162
+ /** Derive the standard defaults from a slug + vendor name. */
2163
+ function deriveDefaults(input) {
2164
+ const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
2165
+ const capability = input.capability;
2166
+ return {
2167
+ vendorUpper,
2168
+ capabilityClass: `${input.vendorName}${capability.charAt(0).toUpperCase() + capability.slice(1)}`,
2169
+ tokenEnv: `HOLOCRON_${vendorUpper}_TOKEN`,
2170
+ transport: "rest"
2171
+ };
2172
+ }
2173
+ //#endregion
2174
+ //#region src/commands/plugin-create/templates/auth.ts
2175
+ function render$17(inputs) {
2176
+ return `/**
2177
+ * Token resolution for the ${inputs.vendorName} plugin.
2178
+ *
2179
+ * Resolution order (matches the standard 4-step precedence set by
2180
+ * \`.notes/tech-auth-bootstrap.spec.md\`):
2181
+ * 1. explicit \`cliToken\` argument (from \`--token\` flag)
2182
+ * 2. ${inputs.tokenEnv} env var (preferred — explicit intent)
2183
+ * 3. ${inputs.vendorEnv} env var (vendor-native)
2184
+ * 4. keyring (com.theholocron.cli / "${inputs.slug}")
2185
+ * 5. AuthError naming all four options + the bootstrap hint
2186
+ */
2187
+
2188
+ import { getToken as getKeyringToken } from "@theholocron/cli";
2189
+
2190
+ export class AuthError extends Error {
2191
+ override name = "AuthError";
2192
+ }
2193
+
2194
+ export interface ResolveTokenInput {
2195
+ /** From \`--token\` CLI flag. */
2196
+ cliToken?: string;
2197
+ /** Env vars; passed in for testability. Defaults to \`process.env\`. */
2198
+ env?: NodeJS.ProcessEnv;
2199
+ /** Keyring lookup fn; passed in for testability. Defaults to \`getToken(provider)\`. */
2200
+ keyring?: (provider: string) => string | null;
2201
+ }
2202
+
2203
+ export function resolveToken(input: ResolveTokenInput = {}): string {
2204
+ const env = input.env ?? process.env;
2205
+ const keyring = input.keyring ?? getKeyringToken;
2206
+ // Bracket access so numeric-prefixed slugs (e.g., env.HOLOCRON_1PASSWORD_TOKEN
2207
+ // which is invalid JS) still produce syntactically valid code.
2208
+ const token =
2209
+ input.cliToken || env["${inputs.tokenEnv}"] || env["${inputs.vendorEnv}"] || keyring("${inputs.slug}");
2210
+ if (!token) {
2211
+ throw new AuthError(
2212
+ "no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
2213
+ "or run: holocron auth set ${inputs.slug} <TOKEN>"
2214
+ );
2215
+ }
2216
+ return token;
2217
+ }
2218
+ `;
2219
+ }
2220
+ //#endregion
2221
+ //#region src/commands/plugin-create/templates/auth-test.ts
2222
+ function render$16(inputs) {
2223
+ return `import { describe, expect, it } from "vitest";
2224
+
2225
+ import { AuthError, resolveToken } from "../auth.js";
2226
+
2227
+ const noKeyring = () => null;
2228
+
2229
+ describe("resolveToken", () => {
2230
+ it("prefers --token over env vars + keyring", () => {
2231
+ expect(
2232
+ resolveToken({
2233
+ cliToken: "flag",
2234
+ env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
2235
+ keyring: () => "kr",
2236
+ })
2237
+ ).toBe("flag");
2238
+ });
2239
+
2240
+ it("prefers ${inputs.tokenEnv} over ${inputs.vendorEnv}", () => {
2241
+ expect(
2242
+ resolveToken({
2243
+ env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
2244
+ keyring: noKeyring,
2245
+ })
2246
+ ).toBe("hlc");
2247
+ });
2248
+
2249
+ it("falls back to ${inputs.vendorEnv} when ${inputs.tokenEnv} is unset", () => {
2250
+ expect(resolveToken({ env: { ${inputs.vendorEnv}: "vendor" }, keyring: noKeyring })).toBe("vendor");
2251
+ });
2252
+
2253
+ it("falls back to keyring when env vars are unset", () => {
2254
+ expect(resolveToken({ env: {}, keyring: (p) => (p === "${inputs.slug}" ? "kr" : null) })).toBe("kr");
2255
+ });
2256
+
2257
+ it("throws AuthError with a helpful message when nothing is set", () => {
2258
+ try {
2259
+ resolveToken({ env: {}, keyring: noKeyring });
2260
+ throw new Error("should have thrown");
2261
+ } catch (err) {
2262
+ expect(err).toBeInstanceOf(AuthError);
2263
+ expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
2264
+ expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
2265
+ }
2266
+ });
2267
+ });
2268
+ `;
2269
+ }
2270
+ //#endregion
2271
+ //#region src/commands/plugin-create/templates/capability.ts
2272
+ function render$15(inputs) {
2273
+ const clientClass = `${inputs.vendorName}RestClient`;
2274
+ const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
2275
+ return `/**
2276
+ * \`${inputs.capability}\` capability for ${inputs.vendorName}.
2277
+ *
2278
+ * Methods are STUBS — implement them against ${inputs.vendorName}'s
2279
+ * REST API per the \`${capabilityInterface}\` interface contract in
2280
+ * \`@theholocron/cli\`.
2281
+ *
2282
+ * TODO once you've stubbed the interface methods, restore the type
2283
+ * import + \`implements\` clause:
2284
+ * import type { ${capabilityInterface} } from "@theholocron/cli";
2285
+ * export class ${inputs.capabilityClass} implements ${capabilityInterface} { ... }
2286
+ */
2287
+
2288
+ import type { ${clientClass} } from "../rest.js";
2289
+
2290
+ export class ${inputs.capabilityClass} {
2291
+ readonly key = "${inputs.capability}" as const;
2292
+ readonly providerName = "${inputs.slug}";
2293
+
2294
+ constructor(private readonly rest: ${clientClass}) {}
2295
+
2296
+ // TODO: implement the ${capabilityInterface} interface methods
2297
+ // (see \`packages/cli/src/capabilities/index.ts\`). Each method
2298
+ // should hit a specific ${inputs.vendorName} REST endpoint via
2299
+ // \`this.rest.request(...)\`. Once methods are stubbed, add
2300
+ // \`implements ${capabilityInterface}\` to the class declaration
2301
+ // above and remove the \`as unknown as\` cast in src/index.ts.
2302
+ }
2303
+ `;
2304
+ }
2305
+ //#endregion
2306
+ //#region src/commands/plugin-create/templates/capability-test.ts
2307
+ function render$14(inputs) {
2308
+ const clientClass = `${inputs.vendorName}RestClient`;
2309
+ return `import { describe, it } from "vitest";
2310
+
2311
+ import { ${inputs.capabilityClass} } from "../capabilities/${inputs.capability}.js";
2312
+ import { ${clientClass} } from "../rest.js";
2313
+ import { stubFetch } from "./helpers.js";
2314
+
2315
+ // Stub client used by the constructor smoke test. Real capability
2316
+ // tests replace this with per-method stubs once implementations land.
2317
+ function makeCapability() {
2318
+ const stub = stubFetch([]);
2319
+ const rest = new ${clientClass}({ token: "t", fetch: stub.fetch });
2320
+ return new ${inputs.capabilityClass}(rest);
2321
+ }
2322
+
2323
+ describe("${inputs.capabilityClass}", () => {
2324
+ it("constructs with a REST client", () => {
2325
+ makeCapability();
2326
+ });
2327
+
2328
+ // TODO: implement one test per ${inputs.capability} capability method
2329
+ // as you fill in the class stubs. See other plugins for reference
2330
+ // patterns:
2331
+ // - REST behaviors (URL / method / body / query) via \`stub.calls\`
2332
+ // - Error paths via \`stubFetch([{ status: 4xx, body: {...} }])\`
2333
+ // - Idempotency (409 handling for ensure* methods, if applicable)
2334
+ it.todo("implement per-method tests");
2335
+ });
2336
+ `;
2337
+ }
2338
+ //#endregion
2339
+ //#region src/commands/plugin-create/templates/eslint-config.ts
2340
+ function render$13(_inputs) {
2341
+ return `import root from "../../eslint.config.js";
2342
+
2343
+ export default [
2344
+ ...root,
2345
+ {
2346
+ ignores: ["dist/**", "coverage/**"],
2347
+ },
2348
+ ];
2349
+ `;
2350
+ }
2351
+ //#endregion
2352
+ //#region src/commands/plugin-create/templates/helpers.ts
2353
+ function render$12(_inputs) {
2354
+ return `import { vi, type Mock } from "vitest";
2355
+
2356
+ export interface FetchCall {
2357
+ url: string;
2358
+ method: string;
2359
+ headers: Record<string, string>;
2360
+ body: unknown;
2361
+ }
2362
+
2363
+ export interface FetchStub {
2364
+ fetch: typeof fetch;
2365
+ calls: FetchCall[];
2366
+ mock: Mock;
2367
+ }
2368
+
2369
+ export function stubFetch(responses: Array<{ status?: number; body?: unknown; text?: string }>): FetchStub {
2370
+ const calls: FetchCall[] = [];
2371
+ let i = 0;
2372
+ const mock = vi.fn(async (input: string | URL, init?: RequestInit) => {
2373
+ const url = typeof input === "string" ? input : input.toString();
2374
+ const body = typeof init?.body === "string" ? safeJsonParse(init.body) : (init?.body ?? null);
2375
+ calls.push({
2376
+ url,
2377
+ method: (init?.method ?? "GET").toUpperCase(),
2378
+ headers: (init?.headers as Record<string, string>) ?? {},
2379
+ body,
2380
+ });
2381
+ const next = responses[i++] ?? { status: 200, body: {} };
2382
+ const status = next.status ?? 200;
2383
+ if (status === 204 || status === 205 || status === 304) {
2384
+ return new Response(null, { status });
2385
+ }
2386
+ const text = next.text ?? (typeof next.body === "string" ? next.body : JSON.stringify(next.body ?? {}));
2387
+ return new Response(text, { status });
2388
+ });
2389
+ return { fetch: mock as unknown as typeof fetch, calls, mock };
2390
+ }
2391
+
2392
+ function safeJsonParse(s: string): unknown {
2393
+ try {
2394
+ return JSON.parse(s);
2395
+ } catch {
2396
+ return s;
2397
+ }
2398
+ }
2399
+ `;
2400
+ }
2401
+ //#endregion
2402
+ //#region src/commands/plugin-create/templates/index-test.ts
2403
+ function render$11(inputs) {
2404
+ return `import { describe, expect, it } from "vitest";
2405
+
2406
+ import { AUTH_HINT, createPlugin } from "../index.js";
2407
+ import { stubFetch } from "./helpers.js";
2408
+
2409
+ describe("createPlugin", () => {
2410
+ it("wires the ${inputs.capability} capability against the given fetch + token", () => {
2411
+ const stub = stubFetch([]);
2412
+ const plugin = createPlugin({
2413
+ cliToken: "test-token",
2414
+ fetch: stub.fetch,
2415
+ });
2416
+ expect(plugin.name).toBe("@theholocron/holocron-plugin-${inputs.slug}");
2417
+ expect(typeof plugin.capabilities.${inputs.capability}).toBe("function");
2418
+ });
2419
+ });
2420
+
2421
+ describe("AUTH_HINT", () => {
2422
+ it("mentions the holocron auth set command", () => {
2423
+ expect(AUTH_HINT).toMatch(/holocron auth set ${inputs.slug}/);
2424
+ });
2425
+ });
2426
+ `;
2427
+ }
2428
+ //#endregion
2429
+ //#region src/commands/plugin-create/templates/package-json.ts
2430
+ function render$10(inputs) {
2431
+ return `{
2432
+ "name": "@theholocron/holocron-plugin-${inputs.slug}",
2433
+ "version": "2.0.0-alpha.1",
2434
+ "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\`.",
2435
+ "homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-${inputs.slug}#readme",
2436
+ "bugs": "https://github.com/theholocron/holocron/issues",
2437
+ "repository": {
2438
+ "type": "git",
2439
+ "url": "git+https://github.com/theholocron/holocron.git",
2440
+ "directory": "packages/holocron-plugin-${inputs.slug}"
2441
+ },
2442
+ "license": "MIT",
2443
+ "author": "Newton Koumantzelis",
2444
+ "type": "module",
2445
+ "main": "./src/index.ts",
2446
+ "exports": {
2447
+ ".": "./src/index.ts"
2448
+ },
2449
+ "scripts": {
2450
+ "build": "tsdown",
2451
+ "lint": "eslint .",
2452
+ "typecheck": "tsc --noEmit",
2453
+ "test": "vitest run",
2454
+ "test:watch": "vitest",
2455
+ "test:coverage": "vitest run --coverage",
2456
+ "validate": "tsx scripts/validate.mjs"
2457
+ },
2458
+ "peerDependencies": {
2459
+ "@theholocron/cli": "workspace:*"
2460
+ },
2461
+ "devDependencies": {
2462
+ "@theholocron/cli": "workspace:*",
2463
+ "@theholocron/tsconfig": "catalog:",
2464
+ "@tsconfig/node-lts": "catalog:",
2465
+ "@vitest/coverage-v8": "catalog:",
2466
+ "eslint": "catalog:",
2467
+ "globals": "catalog:",
2468
+ "typescript": "catalog:",
2469
+ "vitest": "catalog:",
2470
+ "tsdown": "catalog:",
2471
+ "tsx": "catalog:"
2472
+ },
2473
+ "publishConfig": {
2474
+ "access": "public",
2475
+ "main": "./dist/index.mjs",
2476
+ "types": "./dist/index.d.mts",
2477
+ "exports": {
2478
+ ".": {
2479
+ "types": "./dist/index.d.mts",
2480
+ "import": "./dist/index.mjs",
2481
+ "default": "./dist/index.mjs"
2482
+ }
2483
+ }
2484
+ },
2485
+ "files": [
2486
+ "dist",
2487
+ "README.md"
2488
+ ]
2489
+ }
2490
+ `;
2491
+ }
2492
+ //#endregion
2493
+ //#region src/commands/plugin-create/templates/plugin-index.ts
2494
+ function render$9(inputs) {
2495
+ const clientClass = `${inputs.vendorName}RestClient`;
2496
+ const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
2497
+ return `/**
2498
+ * \`@theholocron/holocron-plugin-${inputs.slug}\` — entrypoint.
2499
+ *
2500
+ * Implements the \`${inputs.capability}\` capability against ${inputs.vendorName}'s
2501
+ * REST API. Also exports \`verifyToken\` + \`AUTH_HINT\` for
2502
+ * \`holocron auth\`. See README for auth + config docs.
2503
+ *
2504
+ * TODO: once you fill in the ${inputs.capabilityClass} methods and add
2505
+ * \`implements ${capabilityInterface}\` to the class, add the type import:
2506
+ * import type { ${capabilityInterface} } from "@theholocron/cli";
2507
+ * and set \`: ${capabilityInterface}\` as the factory return type below.
2508
+ */
2509
+
2510
+ import { resolveToken, type ResolveTokenInput } from "./auth.js";
2511
+ import { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
2512
+ import { ${clientClass} } from "./rest.js";
2513
+
2514
+ export interface ${inputs.vendorName}PluginOptions extends ResolveTokenInput {
2515
+ /** Override base URL for tests. */
2516
+ baseUrl?: string;
2517
+ /** Override \`fetch\` for tests. */
2518
+ fetch?: typeof fetch;
2519
+ }
2520
+
2521
+ export interface PluginContext {
2522
+ options: ${inputs.vendorName}PluginOptions;
2523
+ rest: ${clientClass};
2524
+ }
2525
+
2526
+ export function createContext(options: ${inputs.vendorName}PluginOptions): PluginContext {
2527
+ const token = resolveToken(options);
2528
+ const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
2529
+ if (options.baseUrl !== undefined) restOpts.baseUrl = options.baseUrl;
2530
+ if (options.fetch !== undefined) restOpts.fetch = options.fetch;
2531
+ return {
2532
+ options,
2533
+ rest: new ${clientClass}(restOpts),
2534
+ };
2535
+ }
2536
+
2537
+ export function ${inputs.capability}(ctx: PluginContext) {
2538
+ // Return type inferred at scaffold time — the class doesn't yet
2539
+ // \`implements ${capabilityInterface}\`. Add the type import + the
2540
+ // \`: ${capabilityInterface}\` annotation once methods are stubbed.
2541
+ return new ${inputs.capabilityClass}(ctx.rest);
2542
+ }
2543
+
2544
+ export function createPlugin(options: ${inputs.vendorName}PluginOptions) {
2545
+ const ctx = createContext(options);
2546
+ return {
2547
+ name: "@theholocron/holocron-plugin-${inputs.slug}",
2548
+ capabilities: {
2549
+ ${inputs.capability}: () => ${inputs.capability}(ctx),
2550
+ },
2551
+ };
2552
+ }
2553
+
2554
+ /**
2555
+ * One-line hint printed by \`holocron auth set ${inputs.slug}\` when no
2556
+ * token is supplied or the supplied token is rejected. Edit this to
2557
+ * point operators at the specific ${inputs.vendorName} docs path for
2558
+ * generating a token.
2559
+ */
2560
+ export const AUTH_HINT =
2561
+ "generate a ${inputs.vendorName} API token, then run: holocron auth set ${inputs.slug} <TOKEN>";
2562
+
2563
+ // ── Public re-exports ────────────────────────────────────────────────
2564
+
2565
+ export * from "./auth.js";
2566
+ export { ${clientClass} } from "./rest.js";
2567
+ export { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
2568
+ export { verifyToken } from "./verify-token.js";
2569
+ export type { VerifyTokenResult, VerifyTokenSuccess, VerifyTokenFailure } from "./verify-token.js";
2570
+ `;
2571
+ }
2572
+ //#endregion
2573
+ //#region src/commands/plugin-create/templates/readme.ts
2574
+ function render$8(inputs) {
2575
+ return `<!-- editorconfig-checker-disable-file -->
2576
+
2577
+ # \`@theholocron/holocron-plugin-${inputs.slug}\`
2578
+
2579
+ ${inputs.vendorName} plugin for [Holocron](../cli). Implements the
2580
+ \`${inputs.capability}\` capability against [${inputs.vendorName}'s REST API](${inputs.baseUrl}),
2581
+ plus exports \`verifyToken\` + \`AUTH_HINT\` for use by \`holocron auth\`.
2582
+
2583
+ ## Install
2584
+
2585
+ \`\`\`bash
2586
+ pnpm add -D @theholocron/holocron-plugin-${inputs.slug}@alpha
2587
+ \`\`\`
2588
+
2589
+ ## Auth
2590
+
2591
+ Token resolution order (matches the standard 4-step precedence set by
2592
+ \`.notes/tech-auth-bootstrap.spec.md\`):
2593
+
2594
+ 1. \`--token <TOKEN>\` flag on the holocron invocation
2595
+ 2. \`${inputs.tokenEnv}\` env var (preferred — explicit intent)
2596
+ 3. \`${inputs.vendorEnv}\` env var (${inputs.vendorName}-native)
2597
+ 4. **Keyring** — \`com.theholocron.cli\` service, account \`${inputs.slug}\`
2598
+ 5. \`AuthError\` naming all four options + the bootstrap hint
2599
+
2600
+ ## Setup
2601
+
2602
+ \`\`\`bash
2603
+ # Generate a ${inputs.vendorName} API token (see vendor docs), then:
2604
+ holocron auth set ${inputs.slug} <TOKEN>
2605
+ holocron auth check ${inputs.slug} # verify
2606
+ \`\`\`
2607
+
2608
+ ## Config
2609
+
2610
+ \`\`\`jsonc
2611
+ {
2612
+ "providers": {
2613
+ "${inputs.capability}": "${inputs.slug}",
2614
+ },
2615
+ }
2616
+ \`\`\`
2617
+
2618
+ Plugin options extend \`ResolveTokenInput\` — add whatever ${inputs.vendorName}-
2619
+ specific options you need here (project id, workspace slug, etc.) via
2620
+ the tuple form:
2621
+
2622
+ \`\`\`jsonc
2623
+ {
2624
+ "providers": {
2625
+ "${inputs.capability}": ["${inputs.slug}", { "baseUrl": "${inputs.baseUrl}" }],
2626
+ },
2627
+ }
2628
+ \`\`\`
2629
+
2630
+ ## What's implemented
2631
+
2632
+ TODO: fill in as capability methods land.
2633
+
2634
+ ## Status
2635
+
2636
+ **\`v2.0.0-alpha.1\`** — scaffolded via \`holocron plugin create\`.
2637
+ Not yet published; capability methods are stubs.
2638
+ `;
2639
+ }
2640
+ //#endregion
2641
+ //#region src/commands/plugin-create/templates/rest.ts
2642
+ function render$7(inputs) {
2643
+ const clientClass = `${inputs.vendorName}RestClient`;
2644
+ return `/**
2645
+ * Thin REST wrapper around ${inputs.baseUrl}.
2646
+ *
2647
+ * Bearer auth, JSON-only bodies, transport-failure wrapping with
2648
+ * \`status: 0\` so orchestrator soft-skip paths see a clear message
2649
+ * instead of a generic \`TypeError: fetch failed\`.
2650
+ */
2651
+
2652
+ import { ProviderApiError } from "@theholocron/cli";
2653
+
2654
+ export interface RestClientOptions {
2655
+ token: string;
2656
+ fetch?: typeof fetch;
2657
+ baseUrl?: string;
2658
+ }
2659
+
2660
+ export interface RequestOptions {
2661
+ method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
2662
+ body?: unknown;
2663
+ query?: Record<string, string>;
2664
+ /** Treat this response as void even if 200 is returned. */
2665
+ expectNoContent?: boolean;
2666
+ }
2667
+
2668
+ export class ${clientClass} {
2669
+ private readonly token: string;
2670
+ private readonly fetchImpl: typeof fetch;
2671
+ readonly baseUrl: string;
2672
+
2673
+ constructor(opts: RestClientOptions) {
2674
+ this.token = opts.token;
2675
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
2676
+ // Manual trailing-slash trim — CodeQL flags regex on library
2677
+ // input as polynomial ReDoS. O(n) loop, no backtracking risk.
2678
+ let url = opts.baseUrl ?? "${inputs.baseUrl}";
2679
+ while (url.endsWith("/")) url = url.slice(0, -1);
2680
+ this.baseUrl = url;
2681
+ }
2682
+
2683
+ async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
2684
+ const url = new URL(\`\${this.baseUrl}\${path.startsWith("/") ? path : "/" + path}\`);
2685
+ for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
2686
+ const fullUrl = url.toString();
2687
+
2688
+ const headers: Record<string, string> = {
2689
+ authorization: \`Bearer \${this.token}\`,
2690
+ accept: "application/json",
2691
+ };
2692
+ const init: RequestInit = {
2693
+ method: opts.method ?? "GET",
2694
+ headers,
2695
+ };
2696
+ if (opts.body !== undefined) {
2697
+ headers["content-type"] = "application/json";
2698
+ init.body = JSON.stringify(opts.body);
2699
+ }
2700
+
2701
+ let res: Response;
2702
+ try {
2703
+ res = await this.fetchImpl(fullUrl, init);
2704
+ } catch (err) {
2705
+ const detail = err instanceof Error ? \`\${err.name}: \${err.message}\` : String(err);
2706
+ throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} failed: \${detail}\`, 0, undefined);
2707
+ }
2708
+ if (!res.ok) {
2709
+ const body = await res.text().catch(() => "");
2710
+ throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} → \${res.status}\`, res.status, body);
2711
+ }
2712
+ if (opts.expectNoContent || res.status === 204) return undefined as T;
2713
+ const text = await res.text();
2714
+ if (!text) return undefined as T;
2715
+ return JSON.parse(text) as T;
2716
+ }
2717
+ }
2718
+ `;
2719
+ }
2720
+ //#endregion
2721
+ //#region src/commands/plugin-create/templates/rest-test.ts
2722
+ function render$6(inputs) {
2723
+ const clientClass = `${inputs.vendorName}RestClient`;
2724
+ return `import { ProviderApiError } from "@theholocron/cli";
2725
+ import { describe, expect, it } from "vitest";
2726
+
2727
+ import { ${clientClass} } from "../rest.js";
2728
+ import { stubFetch } from "./helpers.js";
2729
+
2730
+ describe("${clientClass}", () => {
2731
+ it("sends bearer + accept headers and returns the parsed body", async () => {
2732
+ const stub = stubFetch([{ status: 200, body: { ok: true } }]);
2733
+ const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2734
+ const res = await client.request<{ ok: boolean }>("/me");
2735
+ expect(res.ok).toBe(true);
2736
+ expect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
2737
+ expect(stub.calls[0]?.headers["accept"]).toBe("application/json");
2738
+ });
2739
+
2740
+ it("serializes body as JSON and sets content-type when present", async () => {
2741
+ const stub = stubFetch([{ status: 200, body: {} }]);
2742
+ const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2743
+ await client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
2744
+ expect(stub.calls[0]?.method).toBe("POST");
2745
+ expect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
2746
+ expect(stub.calls[0]?.body).toEqual({ name: "demo" });
2747
+ });
2748
+
2749
+ it("returns undefined on 204", async () => {
2750
+ const stub = stubFetch([{ status: 204 }]);
2751
+ const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2752
+ expect(await client.request<unknown>("/whatever")).toBeUndefined();
2753
+ });
2754
+
2755
+ it("throws ProviderApiError with the HTTP status on non-2xx", async () => {
2756
+ const stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
2757
+ const client = new ${clientClass}({ token: "bad", fetch: stub.fetch });
2758
+ try {
2759
+ await client.request<unknown>("/me");
2760
+ throw new Error("should have thrown");
2761
+ } catch (err) {
2762
+ expect(err).toBeInstanceOf(ProviderApiError);
2763
+ expect((err as ProviderApiError).status).toBe(401);
2764
+ }
2765
+ });
2766
+
2767
+ it("wraps transport-level failures with status 0", async () => {
2768
+ const throwing: typeof fetch = async () => {
2769
+ throw new TypeError("fetch failed");
2770
+ };
2771
+ const client = new ${clientClass}({ token: "t", fetch: throwing });
2772
+ try {
2773
+ await client.request<unknown>("/me");
2774
+ throw new Error("should have thrown");
2775
+ } catch (err) {
2776
+ expect(err).toBeInstanceOf(ProviderApiError);
2777
+ expect((err as ProviderApiError).status).toBe(0);
2778
+ }
2779
+ });
2780
+
2781
+ it("trims trailing slashes from the base URL", () => {
2782
+ const client = new ${clientClass}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
2783
+ expect(client.baseUrl).toBe("${inputs.baseUrl}");
2784
+ });
2785
+ });
2786
+ `;
2787
+ }
2788
+ //#endregion
2789
+ //#region src/commands/plugin-create/templates/tsconfig-json.ts
2790
+ function render$5(inputs) {
2791
+ return `{
2792
+ "display": "Holocron Plugin: ${inputs.vendorName}",
2793
+ "extends": "@tsconfig/node-lts/tsconfig.json",
2794
+ "compilerOptions": {
2795
+ "baseUrl": "./",
2796
+ "outDir": "./dist",
2797
+ "paths": {
2798
+ "@/*": ["./src/*"]
2799
+ }
2800
+ },
2801
+ "include": ["src/**/*.ts"],
2802
+ "exclude": ["node_modules", "dist"]
2803
+ }
2804
+ `;
2805
+ }
2806
+ //#endregion
2807
+ //#region src/commands/plugin-create/templates/tsdown-config.ts
2808
+ function render$4(_inputs) {
2809
+ return `import { defineConfig } from "tsdown";
2810
+
2811
+ export default defineConfig({
2812
+ entry: ["src/index.ts"],
2813
+ format: "esm",
2814
+ dts: true,
2815
+ clean: true,
2816
+ deps: { neverBundle: [/^@theholocron\\//] },
2817
+ });
2818
+ `;
2819
+ }
2820
+ //#endregion
2821
+ //#region src/commands/plugin-create/templates/validate-script.ts
2822
+ /**
2823
+ * Scaffolds `scripts/validate.mjs` — a smoke-test the operator runs
2824
+ * against a live vendor account to verify the plugin's REST endpoints,
2825
+ * auth, and response parsing all work in reality (the unit tests only
2826
+ * exercise stubbed HTTP responses). READ-ONLY BY DESIGN.
2827
+ *
2828
+ * Convention: every plugin ships this script + a matching `validate`
2829
+ * entry in `package.json`'s scripts block, so operators run
2830
+ * `pnpm --filter @theholocron/holocron-plugin-<slug> validate`.
2831
+ * Capability-specific args (project id, workspace, etc.) come from
2832
+ * positional command-line arguments; the plugin author fills in the
2833
+ * exact shape per their vendor.
2834
+ *
2835
+ * Includes a `hintFor(message)` helper the operator customizes with
2836
+ * vendor-specific "here's the likely fix" guidance for common error
2837
+ * shapes (401 / 403 / 404 / network / 5xx). Points users at docs and
2838
+ * setup steps rather than leaving them staring at a raw stack trace.
2839
+ */
2840
+ function render$3(inputs) {
2841
+ return `#!/usr/bin/env node
2842
+ /**
2843
+ * Read-only smoke test for @theholocron/holocron-plugin-${inputs.slug} against
2844
+ * a live ${inputs.vendorName} account.
2845
+ *
2846
+ * READ-ONLY BY DESIGN. Never calls write(), or bootstrap methods
2847
+ * (\`ensureProject\`, \`ensureEnvironment\`, etc.). Any ERROR line means
2848
+ * the plugin needs adjusting — the \`hintFor\` helper below points at
2849
+ * the most likely fix per error shape.
2850
+ *
2851
+ * Auth: reads the ${inputs.vendorName} token from holocron's keyring —
2852
+ * you must have run \`pnpm holocron auth set ${inputs.slug} <TOKEN>\` first.
2853
+ *
2854
+ * Usage:
2855
+ * pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <arg1> [arg2] ...
2856
+ *
2857
+ * TODO: replace the positional args below with whatever your
2858
+ * capability's methods need (e.g., project id, environment slug,
2859
+ * secret name). Adapt the test steps to your capability's method
2860
+ * surface. Model on \`packages/holocron-plugin-infisical/scripts/validate.mjs\`.
2861
+ */
2862
+
2863
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- filled in by operator
2864
+ import { AuthError, createPlugin, resolveToken, verifyToken } from "../src/index.ts";
2865
+
2866
+ const args = process.argv.slice(2);
2867
+
2868
+ if (args.length === 0) {
2869
+ console.error("usage: pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <args>");
2870
+ process.exit(2);
2871
+ }
2872
+
2873
+ // Use the plugin's own auth resolution so the validate script honors
2874
+ // the same 4-step precedence as the plugin's runtime:
2875
+ // --token → ${inputs.tokenEnv} → ${inputs.vendorEnv} → keyring
2876
+ // (--token isn't available here since this is a bare Node script;
2877
+ // the other three all work.)
2878
+ let token;
2879
+ try {
2880
+ token = resolveToken();
2881
+ } catch (err) {
2882
+ if (err instanceof AuthError) {
2883
+ console.error(err.message);
2884
+ console.error(" see: packages/holocron-plugin-${inputs.slug}/README.md#setup");
2885
+ process.exit(2);
2886
+ }
2887
+ throw err;
2888
+ }
2889
+
2890
+ console.log("Validating @theholocron/holocron-plugin-${inputs.slug} (READ-ONLY)");
2891
+ console.log("");
2892
+
2893
+ // ── 1. verifyToken ─────────────────────────────────────────────────
2894
+ console.log("[1/N] verifyToken");
2895
+ const verifyResult = await verifyToken(token);
2896
+ console.log(\` \${verifyResult.ok ? "✓" : "✗"} \${JSON.stringify(verifyResult)}\`);
2897
+ if (!verifyResult.ok) {
2898
+ const hint = hintFor(verifyResult.message);
2899
+ if (hint) console.log(\` hint: \${hint}\`);
2900
+ }
2901
+ console.log("");
2902
+
2903
+ // ── 2..N. capability method calls ──────────────────────────────────
2904
+ // TODO: implement one \`runStep\`-wrapped call per meaningful read-side
2905
+ // capability method. Model on the infisical validate.mjs. Never call
2906
+ // write / ensure* / other mutating paths.
2907
+ //
2908
+ // Example:
2909
+ // console.log("[2/N] vault.list()");
2910
+ // await runStep(async () => {
2911
+ // const keys = await vault.list();
2912
+ // console.log(\` ✓ \${keys.length} secrets\`);
2913
+ // });
2914
+
2915
+ console.log("Done. Fill in capability method calls above before shipping.");
2916
+
2917
+ // ── helpers ────────────────────────────────────────────────────────
2918
+
2919
+ /** Wrap a capability call, print ERROR + hint on failure. */
2920
+ async function runStep(body) {
2921
+ try {
2922
+ await body();
2923
+ } catch (err) {
2924
+ const message = err instanceof Error ? err.message : String(err);
2925
+ console.log(\` ✗ ERROR: \${message}\`);
2926
+ const hint = hintFor(message);
2927
+ if (hint) console.log(\` hint: \${hint}\`);
2928
+ }
2929
+ }
2930
+
2931
+ /**
2932
+ * Point the operator at the most likely fix based on the error shape.
2933
+ * Generic defaults below — CUSTOMIZE per your vendor's docs URLs and
2934
+ * common permission/config gotchas.
2935
+ */
2936
+ function hintFor(message) {
2937
+ if (/→ 401/.test(message)) {
2938
+ return "token invalid — regenerate per ${inputs.vendorName}'s docs (see README §Setup)";
2939
+ }
2940
+ if (/→ 403/.test(message)) {
2941
+ return "token authenticates but lacks scope — check the token/identity has permissions on the resource";
2942
+ }
2943
+ if (/→ 404/.test(message)) {
2944
+ return "endpoint or resource not found — verify your positional args match a real resource";
2945
+ }
2946
+ if (/fetch failed|status: 0|network/i.test(message)) {
2947
+ return "network error — check the base URL (${inputs.baseUrl}) and connectivity";
2948
+ }
2949
+ if (/→ 5\\d\\d/.test(message)) {
2950
+ return "server error — vendor-side. Retry, and check the vendor's status page if persistent";
2951
+ }
2952
+ return null;
2953
+ }
2954
+ `;
2955
+ }
2956
+ //#endregion
2957
+ //#region src/commands/plugin-create/templates/verify-token.ts
2958
+ function render$2(inputs) {
2959
+ const clientClass = `${inputs.vendorName}RestClient`;
2960
+ return `/**
2961
+ * \`verifyToken\` — plugin-level export used by \`holocron auth set\` +
2962
+ * \`holocron auth check\`. Hits a lightweight whoami-style endpoint
2963
+ * and translates the response into the normalized \`VerifyTokenResult\`
2964
+ * shape.
2965
+ *
2966
+ * Kept as a standalone function (not a capability method) so the auth
2967
+ * command can call it without initializing the full plugin — plugin
2968
+ * construction requires an already-resolved token, which is exactly
2969
+ * what we don't have yet at bootstrap time.
2970
+ *
2971
+ * TODO: replace \`/me\` with the ${inputs.vendorName} equivalent of a
2972
+ * "check my token" endpoint. Common shapes: \`/user\`, \`/whoami\`,
2973
+ * \`/me\`, \`/account\`.
2974
+ */
2975
+
2976
+ import { ${clientClass} } from "./rest.js";
2977
+
2978
+ export interface VerifyTokenSuccess {
2979
+ ok: true;
2980
+ subject: string;
2981
+ }
2982
+
2983
+ export interface VerifyTokenFailure {
2984
+ ok: false;
2985
+ message: string;
2986
+ }
2987
+
2988
+ export type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
2989
+
2990
+ interface MeResponse {
2991
+ /** Adjust to whatever ${inputs.vendorName}'s whoami endpoint returns. */
2992
+ name?: string;
2993
+ email?: string;
2994
+ id?: string;
2995
+ }
2996
+
2997
+ export interface VerifyTokenOptions {
2998
+ baseUrl?: string;
2999
+ fetch?: typeof fetch;
3000
+ }
3001
+
3002
+ export async function verifyToken(token: string, opts: VerifyTokenOptions = {}): Promise<VerifyTokenResult> {
3003
+ const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
3004
+ if (opts.baseUrl !== undefined) restOpts.baseUrl = opts.baseUrl;
3005
+ if (opts.fetch !== undefined) restOpts.fetch = opts.fetch;
3006
+ const rest = new ${clientClass}(restOpts);
3007
+ try {
3008
+ const me = await rest.request<MeResponse>("/me");
3009
+ // Optional chaining because \`me\` is \`undefined\` on 204 / empty body.
3010
+ const subject = me?.email ?? me?.name ?? me?.id ?? "unknown";
3011
+ return { ok: true, subject: \`user @ \${subject}\` };
3012
+ } catch (err) {
3013
+ const message = err instanceof Error ? err.message : String(err);
3014
+ return { ok: false, message };
3015
+ }
3016
+ }
3017
+ `;
194
3018
  }
195
3019
  //#endregion
196
- //#region src/commands/npm-publish-initial.ts
3020
+ //#region src/commands/plugin-create/templates/verify-token-test.ts
3021
+ function render$1(_inputs) {
3022
+ return `import { describe, expect, it } from "vitest";
3023
+
3024
+ import { verifyToken } from "../verify-token.js";
3025
+ import { stubFetch } from "./helpers.js";
3026
+
3027
+ describe("verifyToken", () => {
3028
+ it("returns ok with a subject when /me returns 200", async () => {
3029
+ const stub = stubFetch([{ status: 200, body: { email: "user@example.com" } }]);
3030
+ const result = await verifyToken("token", { fetch: stub.fetch });
3031
+ expect(result.ok).toBe(true);
3032
+ if (result.ok) {
3033
+ expect(result.subject).toMatch(/user@example.com/);
3034
+ }
3035
+ });
3036
+
3037
+ it("returns ok:false with the error message on 401", async () => {
3038
+ const stub = stubFetch([{ status: 401, body: { messages: ["Invalid token"] } }]);
3039
+ const result = await verifyToken("bad", { fetch: stub.fetch });
3040
+ expect(result.ok).toBe(false);
3041
+ if (!result.ok) {
3042
+ expect(result.message).toMatch(/→ 401/);
3043
+ }
3044
+ });
3045
+
3046
+ it("returns ok:false when the network layer throws", async () => {
3047
+ const throwing: typeof fetch = async () => {
3048
+ throw new TypeError("network down");
3049
+ };
3050
+ const result = await verifyToken("t", { fetch: throwing });
3051
+ expect(result.ok).toBe(false);
3052
+ if (!result.ok) {
3053
+ expect(result.message).toMatch(/network down/);
3054
+ }
3055
+ });
3056
+ });
3057
+ `;
3058
+ }
3059
+ //#endregion
3060
+ //#region src/commands/plugin-create/templates/vitest-config.ts
3061
+ function render(_inputs) {
3062
+ return `import { defineConfig } from "vitest/config";
3063
+
3064
+ export default defineConfig({
3065
+ test: {
3066
+ environment: "node",
3067
+ globals: false,
3068
+ coverage: {
3069
+ provider: "v8",
3070
+ reporter: ["text", "html", "json-summary"],
3071
+ include: ["src/**/*.ts"],
3072
+ exclude: ["src/**/__tests__/**", "src/**/*.test.ts", "src/index.ts"],
3073
+ thresholds: { lines: 0, functions: 0, branches: 0, statements: 0 },
3074
+ },
3075
+ },
3076
+ });
3077
+ `;
3078
+ }
3079
+ //#endregion
3080
+ //#region src/commands/plugin-create/index.ts
197
3081
  /**
198
- * `holocron npm publish-initial`bottles up the chicken-and-egg
199
- * bootstrap that every new npm-published holocron monorepo hits.
200
- *
201
- * npm requires a package to exist before Trusted Publishing can be
202
- * configured on it. So the first publish has to happen outside the
203
- * OIDC flow — using either a browser-auth session (`npm login
204
- * --auth-type=web`) or an ephemeral automation token. This command
205
- * runs the publish step + tells you exactly what to do next.
206
- *
207
- * Workflow:
208
- *
209
- * $ npm login --auth-type=web # one-time, browser-based
210
- * $ pnpm install --frozen-lockfile
211
- * $ pnpm build
212
- * $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
3082
+ * `holocron plugin create <slug> <vendor>` scaffold a new plugin
3083
+ * package matching the proven template.
213
3084
  *
214
- * The command itself only handles the publish step + the post-publish
215
- * Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
216
- * stay outside the command (no pnpm-inside-pnpm).
3085
+ * Design: see `.notes/tool-plugin-create.spec.md`.
217
3086
  *
218
- * If `NPM_TOKEN` is detected in env, the command prints a final
219
- * "revoke this token at <url>" reminder same pattern as `rando vc
220
- * setup` for the ephemeral GH admin PAT.
3087
+ * Flow:
3088
+ * 1. Preflight verify CWD is a workspace root (pnpm-workspace.yaml
3089
+ * + packages/ dir present).
3090
+ * 2. Slug collision — packages/holocron-plugin-<slug>/ must not exist.
3091
+ * 3. Capability sanity — must be one of the 14 known keys; warn for
3092
+ * many-cardinality caps.
3093
+ * 4. Prompt — fill in any missing flags via cli-utils / inquirer
3094
+ * (Phase B; Phase A takes fully-populated input).
3095
+ * 5. Generate — for each template, write to
3096
+ * packages/holocron-plugin-<slug>/<path>.
3097
+ * 6. Verify (unless --no-verify) — Phase B; runs pnpm install +
3098
+ * pnpm --filter <pkg> typecheck lint test.
3099
+ * 7. Print next steps.
221
3100
  */
222
- const PUBLISHABLE_PACKAGES = [
223
- "@theholocron/cli",
224
- "@theholocron/holocron-plugin-github",
225
- "@theholocron/holocron-plugin-vercel",
226
- "@theholocron/holocron-plugin-neon",
227
- "@theholocron/holocron-plugin-clerk",
228
- "@theholocron/holocron-plugin-1password",
229
- "@theholocron/holocron-plugin-postman"
3101
+ var PluginCreateError = class extends Error {
3102
+ name = "PluginCreateError";
3103
+ };
3104
+ const TEMPLATES = [
3105
+ {
3106
+ path: "package.json",
3107
+ render: render$10
3108
+ },
3109
+ {
3110
+ path: "tsconfig.json",
3111
+ render: render$5
3112
+ },
3113
+ {
3114
+ path: "vitest.config.ts",
3115
+ render
3116
+ },
3117
+ {
3118
+ path: "eslint.config.js",
3119
+ render: render$13
3120
+ },
3121
+ {
3122
+ path: "tsdown.config.ts",
3123
+ render: render$4
3124
+ },
3125
+ {
3126
+ path: "README.md",
3127
+ render: render$8
3128
+ },
3129
+ {
3130
+ path: "src/auth.ts",
3131
+ render: render$17
3132
+ },
3133
+ {
3134
+ path: "src/rest.ts",
3135
+ render: render$7
3136
+ },
3137
+ {
3138
+ path: "src/verify-token.ts",
3139
+ render: render$2
3140
+ },
3141
+ {
3142
+ path: "src/index.ts",
3143
+ render: render$9
3144
+ },
3145
+ {
3146
+ path: "src/capabilities/{{capability}}.ts",
3147
+ render: render$15
3148
+ },
3149
+ {
3150
+ path: "src/__tests__/helpers.ts",
3151
+ render: render$12
3152
+ },
3153
+ {
3154
+ path: "src/__tests__/auth.test.ts",
3155
+ render: render$16
3156
+ },
3157
+ {
3158
+ path: "src/__tests__/rest.test.ts",
3159
+ render: render$6
3160
+ },
3161
+ {
3162
+ path: "src/__tests__/verify-token.test.ts",
3163
+ render: render$1
3164
+ },
3165
+ {
3166
+ path: "src/__tests__/{{capability}}.test.ts",
3167
+ render: render$14
3168
+ },
3169
+ {
3170
+ path: "src/__tests__/index.test.ts",
3171
+ render: render$11
3172
+ },
3173
+ {
3174
+ path: "scripts/validate.mjs",
3175
+ render: render$3
3176
+ }
230
3177
  ];
231
- async function runNpmPublishInitial(input = {}) {
232
- const print = input.print ?? ((line) => console.log(line));
3178
+ /** Replace `{{capability}}` in a template path with the actual capability key. */
3179
+ function resolvePath(template, inputs) {
3180
+ return template.replace(/\{\{capability\}\}/g, inputs.capability);
3181
+ }
3182
+ function runPluginCreate(input) {
233
3183
  const cwd = input.cwd ?? process.cwd();
234
- const tag = input.tag ?? "alpha";
235
- const dryRun = input.dryRun ?? false;
236
- const otp = input.otp;
237
- const env = input.env ?? process.env;
238
- const exec = input.exec ?? defaultExec;
239
- const publishArgs = [
240
- "-r",
241
- "--filter=./packages/*",
242
- "--filter=!@theholocron/cli-utils",
243
- "publish",
244
- "--access",
245
- "public",
246
- "--no-git-checks",
247
- "--tag",
248
- tag,
249
- ...otp ? ["--otp", otp] : []
250
- ];
251
- print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
252
- print(` cwd: ${cwd}`);
253
- print(` tag: ${tag}`);
254
- if (otp) print(` otp: <${otp.length} chars>`);
255
- print("");
256
- print(" → verifying npm auth (`npm whoami`)…");
257
- const whoami = await exec("npm", ["whoami"], { cwd });
258
- if (whoami.exitCode !== 0) {
259
- 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.";
260
- print(` ${message}`);
261
- return {
262
- status: "fail",
263
- message,
264
- packageNames: PUBLISHABLE_PACKAGES
265
- };
266
- }
267
- print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
268
- if (dryRun) {
269
- print("");
270
- print(" … (dry-run) skipping actual publish");
271
- print(` would run: pnpm ${publishArgs.join(" ")}`);
272
- printNextSteps(print, env);
273
- return {
274
- status: "dry-run",
275
- message: "dry-run — no publish executed",
276
- packageNames: PUBLISHABLE_PACKAGES
277
- };
278
- }
279
- print("");
280
- print(" → publishing all public @theholocron/* packages…");
281
- const publish = await exec("pnpm", publishArgs, { cwd });
282
- if (publish.exitCode !== 0) {
283
- const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
284
- print(` ✗ ${message}`);
285
- if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
286
- print("");
287
- print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
288
- print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
3184
+ const print = input.print ?? ((line) => console.log(line));
3185
+ const write = input.writeFile ?? defaultWrite;
3186
+ preflight(cwd);
3187
+ validateSlug(input.slug);
3188
+ validateVendorName(input.vendorName);
3189
+ const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
3190
+ if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
3191
+ validateCapability(input.capability, print);
3192
+ const derived = deriveDefaults({
3193
+ slug: input.slug,
3194
+ vendorName: input.vendorName,
3195
+ capability: input.capability
3196
+ });
3197
+ const inputs = {
3198
+ slug: input.slug,
3199
+ vendorName: input.vendorName,
3200
+ vendorUpper: derived.vendorUpper,
3201
+ capability: input.capability,
3202
+ capabilityClass: derived.capabilityClass,
3203
+ tokenEnv: input.tokenEnv ?? derived.tokenEnv,
3204
+ vendorEnv: input.vendorEnv,
3205
+ baseUrl: trimTrailingSlashes(input.baseUrl),
3206
+ transport: derived.transport
3207
+ };
3208
+ const filesWritten = [];
3209
+ print(`Scaffolding @theholocron/holocron-plugin-${inputs.slug}${input.dryRun ? " (dry-run)" : ""}`);
3210
+ print(` ${packageDir}`);
3211
+ for (const template of TEMPLATES) {
3212
+ const resolvedPath = resolvePath(template.path, inputs);
3213
+ const filepath = path.join(packageDir, resolvedPath);
3214
+ const content = template.render(inputs);
3215
+ if (input.dryRun) print(` … would write ${resolvedPath} (${content.length} bytes)`);
3216
+ else {
3217
+ write(filepath, content);
3218
+ print(` ✓ ${resolvedPath}`);
289
3219
  }
290
- return {
291
- status: "fail",
292
- message,
293
- packageNames: PUBLISHABLE_PACKAGES
294
- };
3220
+ filesWritten.push(resolvedPath);
295
3221
  }
296
- print(" ✓ publish complete");
297
- printNextSteps(print, env);
3222
+ if (!input.dryRun) printNextSteps(print, inputs);
298
3223
  return {
299
3224
  status: "ok",
300
- packageNames: PUBLISHABLE_PACKAGES
3225
+ packagePath: packageDir,
3226
+ filesWritten
301
3227
  };
302
3228
  }
303
- function printNextSteps(print, env) {
3229
+ function preflight(cwd) {
3230
+ 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.`);
3231
+ if (!existsSync(path.join(cwd, "packages"))) throw new PluginCreateError(`\`packages/\` directory not found in \`${cwd}\`.`);
3232
+ }
3233
+ /**
3234
+ * npm-package-name conventions: kebab-case, starts with lowercase
3235
+ * letter. Rejecting numeric-prefixed slugs also sidesteps the
3236
+ * `env.HOLOCRON_1FOO_TOKEN` invalid-identifier trap in the auth
3237
+ * template.
3238
+ */
3239
+ const SLUG_RE = /^[a-z][a-z0-9-]*$/;
3240
+ function validateSlug(slug) {
3241
+ 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".`);
3242
+ }
3243
+ /** PascalCase — starts with uppercase, alphanumeric only. */
3244
+ const VENDOR_NAME_RE = /^[A-Z][a-zA-Z0-9]*$/;
3245
+ function validateVendorName(name) {
3246
+ 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".`);
3247
+ }
3248
+ /**
3249
+ * Trim trailing slashes with a plain loop (no regex — matches the
3250
+ * rest.ts template's own trimming to stay ReDoS-safe under CodeQL).
3251
+ */
3252
+ function trimTrailingSlashes(url) {
3253
+ let out = url;
3254
+ while (out.endsWith("/")) out = out.slice(0, -1);
3255
+ return out;
3256
+ }
3257
+ function validateCapability(capability, print) {
3258
+ if (!(capability in CARDINALITY)) throw new PluginCreateError(`\`${capability}\` is not a known capability. See \`packages/cli/src/capabilities/index.ts\`.`);
3259
+ if (CARDINALITY[capability] === "many") print(` ! ${capability} is a many-cardinality capability — multiple providers can be active at once. Confirm your config wiring accordingly.`);
3260
+ }
3261
+ function defaultWrite(filepath, content) {
3262
+ mkdirSync(path.dirname(filepath), { recursive: true });
3263
+ writeFileSync(filepath, content, "utf8");
3264
+ }
3265
+ function printNextSteps(print, inputs) {
304
3266
  print("");
305
- print(" next: configure Trusted Publisher for each package on npm:");
306
- for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
307
- print(" Publisher: GitHub Actions Org: theholocron Repo: holocron Workflow: release.yml");
308
- if (env.NPM_TOKEN) {
309
- print("");
310
- print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
311
- print(" https://www.npmjs.com/settings/~/tokens");
312
- }
3267
+ print(` Scaffolded @theholocron/holocron-plugin-${inputs.slug} (18 files).`);
3268
+ print("");
3269
+ print(" Next:");
3270
+ print(` 1. pnpm install # picks up the workspace package`);
3271
+ print(` 2. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} typecheck # green`);
3272
+ print(` 3. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} lint # green`);
3273
+ print(` 4. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} test # green (stubs pass, real tests are it.todo)`);
3274
+ print(` 5. Implement ${inputs.capabilityClass} methods in src/capabilities/${inputs.capability}.ts`);
3275
+ print(` 6. Replace it.todo(...) with real tests in src/__tests__/${inputs.capability}.test.ts`);
3276
+ print(" 7. Commit + push when capability is functionally complete.");
313
3277
  }
314
- const defaultExec = async (cmd, args, opts) => {
315
- const result = spawnSync(cmd, args, {
316
- cwd: opts.cwd,
317
- encoding: "utf8",
318
- stdio: [
319
- "inherit",
320
- "pipe",
321
- "pipe"
322
- ]
323
- });
324
- return {
325
- exitCode: result.status ?? -1,
326
- stdout: result.stdout ?? "",
327
- stderr: result.stderr ?? ""
328
- };
329
- };
330
3278
  //#endregion
331
3279
  //#region src/commands/secret-set.ts
332
3280
  async function runSecretSet(input) {
@@ -495,6 +3443,153 @@ function vaultProviderName(loader) {
495
3443
  }
496
3444
  //#endregion
497
3445
  //#region src/commands/setup.ts
3446
+ const DEPENDABOT_CONFIG = `\
3447
+ # AUTO-GENERATED by holocron — run \`holocron setup\` to regenerate.
3448
+ version: 2
3449
+ updates:
3450
+ - package-ecosystem: npm
3451
+ directory: /
3452
+ schedule:
3453
+ interval: weekly
3454
+ groups:
3455
+ security-patches:
3456
+ applies-to: security-updates
3457
+ patterns:
3458
+ - "*"
3459
+ all-dependencies:
3460
+ update-types:
3461
+ - minor
3462
+ - patch
3463
+
3464
+ - package-ecosystem: github-actions
3465
+ directory: /
3466
+ schedule:
3467
+ interval: weekly
3468
+ groups:
3469
+ all-actions:
3470
+ patterns:
3471
+ - "*"
3472
+ `;
3473
+ const RULESET_NAME = "holocron-default-branch";
3474
+ const BALANCED_REPO_SETTINGS = {
3475
+ allow_squash_merge: true,
3476
+ allow_merge_commit: false,
3477
+ allow_rebase_merge: false,
3478
+ allow_auto_merge: true,
3479
+ allow_update_branch: true,
3480
+ delete_branch_on_merge: true,
3481
+ has_issues: true,
3482
+ has_discussions: true,
3483
+ has_projects: true,
3484
+ has_wiki: false
3485
+ };
3486
+ function buildClassicProtectionPayload(requiredChecks = []) {
3487
+ return {
3488
+ required_status_checks: requiredChecks.length > 0 ? {
3489
+ strict: false,
3490
+ contexts: requiredChecks
3491
+ } : null,
3492
+ enforce_admins: false,
3493
+ required_pull_request_reviews: {
3494
+ required_approving_review_count: 0,
3495
+ dismiss_stale_reviews: false,
3496
+ require_code_owner_reviews: false
3497
+ },
3498
+ restrictions: null,
3499
+ allow_force_pushes: false,
3500
+ allow_deletions: false
3501
+ };
3502
+ }
3503
+ function buildRulesetPayload(requiredChecks = []) {
3504
+ const rules = [
3505
+ { type: "deletion" },
3506
+ { type: "non_fast_forward" },
3507
+ {
3508
+ type: "pull_request",
3509
+ parameters: {
3510
+ required_approving_review_count: 0,
3511
+ dismiss_stale_reviews_on_push: false,
3512
+ require_code_owner_review: false,
3513
+ require_last_push_approval: false,
3514
+ required_review_thread_resolution: false
3515
+ }
3516
+ }
3517
+ ];
3518
+ if (requiredChecks.length > 0) rules.push({
3519
+ type: "required_status_checks",
3520
+ parameters: {
3521
+ required_status_checks: requiredChecks.map((context) => ({ context })),
3522
+ strict_required_status_checks_policy: false
3523
+ }
3524
+ });
3525
+ return {
3526
+ name: RULESET_NAME,
3527
+ target: "branch",
3528
+ enforcement: "active",
3529
+ conditions: { ref_name: {
3530
+ include: ["~DEFAULT_BRANCH"],
3531
+ exclude: []
3532
+ } },
3533
+ rules
3534
+ };
3535
+ }
3536
+ async function upsertBranchProtection(source, dryRun, requiredChecks) {
3537
+ const step = `upsert ruleset ${RULESET_NAME}`;
3538
+ if (dryRun) return {
3539
+ capability: "source",
3540
+ step,
3541
+ status: "dry-run"
3542
+ };
3543
+ try {
3544
+ const found = (await source.listRulesets()).find((r) => r.name === RULESET_NAME);
3545
+ if (found) {
3546
+ await source.updateRuleset(found.id, buildRulesetPayload(requiredChecks));
3547
+ return {
3548
+ capability: "source",
3549
+ step,
3550
+ status: "ok",
3551
+ message: "updated"
3552
+ };
3553
+ }
3554
+ await source.createRuleset(buildRulesetPayload(requiredChecks));
3555
+ return {
3556
+ capability: "source",
3557
+ step,
3558
+ status: "ok",
3559
+ message: "created"
3560
+ };
3561
+ } catch (err) {
3562
+ if (!(err instanceof ProviderApiError) || err.status !== 403) return {
3563
+ capability: "source",
3564
+ step,
3565
+ status: "fail",
3566
+ message: err instanceof Error ? err.message : String(err)
3567
+ };
3568
+ }
3569
+ try {
3570
+ const repo = await source.getRepo();
3571
+ await source.protectBranch(repo.defaultBranch, buildClassicProtectionPayload(requiredChecks));
3572
+ return {
3573
+ capability: "source",
3574
+ step,
3575
+ status: "ok",
3576
+ message: `classic protection on ${repo.defaultBranch}`
3577
+ };
3578
+ } catch (err) {
3579
+ if (err instanceof ProviderApiError && err.status === 403) return {
3580
+ capability: "source",
3581
+ step,
3582
+ status: "skip",
3583
+ message: "branch protection unavailable on private repos without GitHub Pro/Team"
3584
+ };
3585
+ return {
3586
+ capability: "source",
3587
+ step,
3588
+ status: "fail",
3589
+ message: err instanceof Error ? err.message : String(err)
3590
+ };
3591
+ }
3592
+ }
498
3593
  async function runSetup(input) {
499
3594
  const print = input.print ?? ((line) => console.log(line));
500
3595
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
@@ -512,13 +3607,59 @@ async function runSetup(input) {
512
3607
  "enableVulnerabilityAlerts",
513
3608
  "enableAutomatedSecurityFixes",
514
3609
  "enableSecretScanning",
515
- "enablePrivateVulnerabilityReporting"
3610
+ "enablePrivateVulnerabilityReporting",
3611
+ "enableDependencyGraph"
516
3612
  ]) {
517
3613
  steps.push(await runStep("source", method, dryRun, async () => {
518
3614
  await source[method]();
519
3615
  }));
520
3616
  print(formatStep(steps[steps.length - 1]));
521
3617
  }
3618
+ steps.push(await runStep("source", "enableCodeScanning", dryRun, async () => {
3619
+ return await source.enableCodeScanning();
3620
+ }));
3621
+ print(formatStep(steps[steps.length - 1]));
3622
+ const policy = config.project.repoPolicy;
3623
+ if (policy && policy.preset !== "none") {
3624
+ const preset = policy.preset ?? "balanced";
3625
+ steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
3626
+ await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
3627
+ }));
3628
+ print(formatStep(steps[steps.length - 1]));
3629
+ const requiredChecks = preset === "strict" ? policy.requiredChecks ?? [] : [];
3630
+ steps.push(await upsertBranchProtection(source, dryRun, requiredChecks));
3631
+ print(formatStep(steps[steps.length - 1]));
3632
+ }
3633
+ }
3634
+ const workflows = config.project.workflows;
3635
+ if (loader.has("source") && workflows && workflows.length > 0) {
3636
+ const source = loader.get("source");
3637
+ print(" → workflows");
3638
+ for (const entry of workflows) {
3639
+ const name = typeof entry === "string" ? entry : entry.name;
3640
+ const withOverrides = typeof entry === "object" ? entry.with : void 0;
3641
+ if (!KNOWN_WORKFLOWS.has(name)) {
3642
+ steps.push({
3643
+ capability: "source",
3644
+ step: `write workflow ${name}`,
3645
+ status: "skip",
3646
+ message: `unknown workflow "${name}" — no template available`
3647
+ });
3648
+ print(formatStep(steps[steps.length - 1]));
3649
+ continue;
3650
+ }
3651
+ steps.push(await runStep("source", `write workflow ${name}`, dryRun, async () => {
3652
+ await source.writeWorkflowFile(`${name}.yml`, workflowHeader() + generateThinCallerContent(name, withOverrides));
3653
+ }));
3654
+ print(formatStep(steps[steps.length - 1]));
3655
+ }
3656
+ }
3657
+ if (loader.has("source") && config.project.repoPolicy?.preset !== "none") {
3658
+ const source = loader.get("source");
3659
+ steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
3660
+ await source.writeRepoFile(".github/dependabot.yml", DEPENDABOT_CONFIG);
3661
+ }));
3662
+ print(formatStep(steps[steps.length - 1]));
522
3663
  }
523
3664
  if (loader.has("environments")) {
524
3665
  const envs = loader.get("environments");
@@ -559,6 +3700,22 @@ async function runSetup(input) {
559
3700
  if (loader.has("vault")) {
560
3701
  const vault = loader.get("vault");
561
3702
  print(" → vault");
3703
+ if (vault.ensureProject) {
3704
+ steps.push(await runStep("vault", `ensureProject ${config.project.name}`, dryRun, async () => {
3705
+ return `project ${(await vault.ensureProject(config.project.name)).alreadyExists ? "exists" : "created"}`;
3706
+ }));
3707
+ print(formatStep(steps[steps.length - 1]));
3708
+ }
3709
+ if (vault.ensureEnvironment) for (const envName of [
3710
+ "dev",
3711
+ "stg",
3712
+ "prd"
3713
+ ]) {
3714
+ steps.push(await runStep("vault", `ensureEnvironment ${envName}`, dryRun, async () => {
3715
+ return `${envName} ${(await vault.ensureEnvironment(config.project.name, envName)).alreadyExists ? "exists" : "created"}`;
3716
+ }));
3717
+ print(formatStep(steps[steps.length - 1]));
3718
+ }
562
3719
  try {
563
3720
  const keys = await vault.list();
564
3721
  steps.push({
@@ -695,6 +3852,7 @@ async function fileExists(path) {
695
3852
  }
696
3853
  //#endregion
697
3854
  //#region src/cli.ts
3855
+ const { version: CLI_VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
698
3856
  await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [options]").option("dry-run", {
699
3857
  type: "boolean",
700
3858
  default: false,
@@ -707,7 +3865,7 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
707
3865
  default: process.cwd(),
708
3866
  describe: "Directory to search for holocron.config.json"
709
3867
  }).command("version", "Print the CLI version", () => {}, () => {
710
- console.log("holocron 2.0.0-alpha.0");
3868
+ console.log(`holocron ${CLI_VERSION}`);
711
3869
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
712
3870
  type: "string",
713
3871
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
@@ -815,7 +3973,17 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
815
3973
  branch: argv.branch,
816
3974
  ...argv.target ? { target: argv.target } : {}
817
3975
  })).status === "fail") process.exitCode = 1;
818
- }).command("npm publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (y) => y.option("tag", {
3976
+ }).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", {
3977
+ type: "string",
3978
+ demandOption: true,
3979
+ describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
3980
+ }), async (argv) => {
3981
+ if ((await runNpmBumpVersions({
3982
+ version: argv.newVersion,
3983
+ cwd: argv.cwd,
3984
+ dryRun: argv.dryRun
3985
+ })).status === "fail") process.exitCode = 1;
3986
+ }).command("publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (yy) => yy.option("tag", {
819
3987
  type: "string",
820
3988
  default: "alpha",
821
3989
  describe: "npm distribution tag (defaults to alpha)"
@@ -829,10 +3997,110 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
829
3997
  dryRun: argv.dryRun,
830
3998
  ...argv.otp ? { otp: argv.otp } : {}
831
3999
  })).status === "fail") process.exitCode = 1;
4000
+ }).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github via the GitHub API", (y) => y.option("repo", {
4001
+ type: "string",
4002
+ default: "theholocron/.github",
4003
+ describe: "Target org/repo (default: theholocron/.github)"
4004
+ }).option("branch", {
4005
+ type: "string",
4006
+ describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
4007
+ }).option("pr", {
4008
+ type: "boolean",
4009
+ default: false,
4010
+ describe: "Open a PR after pushing to --branch (no-op without --branch)"
4011
+ }).option("message", {
4012
+ type: "string",
4013
+ describe: "Commit message (default: chore: sync from theholocron/holocron)"
4014
+ }).option("output-dir", {
4015
+ type: "string",
4016
+ describe: "Write generated files to this local directory instead of pushing (for validation)"
4017
+ }), async (argv) => {
4018
+ const outputDir = argv["output-dir"];
4019
+ const token = outputDir ? "no-token-needed" : argv.token ?? process.env.GITHUB_TOKEN ?? process.env.HOLOCRON_GITHUB_TOKEN;
4020
+ if (!token) {
4021
+ console.error("sync-github: GitHub token required — pass --token or set GITHUB_TOKEN");
4022
+ process.exitCode = 1;
4023
+ return;
4024
+ }
4025
+ if ((await runSyncGithub({
4026
+ token,
4027
+ repo: argv.repo,
4028
+ dryRun: argv.dryRun,
4029
+ ...argv.branch ? { branch: argv.branch } : {},
4030
+ ...argv.pr ? { createPr: true } : {},
4031
+ ...argv.message ? { message: argv.message } : {},
4032
+ ...outputDir ? { outputDir } : {}
4033
+ })).status === "fail") process.exitCode = 1;
832
4034
  }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
833
4035
  const loaded = await loadConfig(argv.cwd);
834
4036
  console.log(JSON.stringify(loaded.resolved, null, 2));
835
- }).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
4037
+ }).command("plugin create <slug> <vendor>", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
4038
+ type: "string",
4039
+ demandOption: true,
4040
+ describe: "Package slug (kebab-case)"
4041
+ }).positional("vendor", {
4042
+ type: "string",
4043
+ demandOption: true,
4044
+ describe: "Vendor display name (PascalCase)"
4045
+ }).option("capability", {
4046
+ type: "string",
4047
+ describe: "Capability key: source|ci|secrets|environments|issues|deployment|storage|auth|vault|dns|tooling|notifications|analytics|observability"
4048
+ }).option("token-env", {
4049
+ type: "string",
4050
+ describe: "Holocron env var name (defaults to HOLOCRON_<VENDOR>_TOKEN)"
4051
+ }).option("vendor-env", {
4052
+ type: "string",
4053
+ describe: "Vendor-native env var name"
4054
+ }).option("base-url", {
4055
+ type: "string",
4056
+ describe: "REST base URL"
4057
+ }).option("verify", {
4058
+ type: "boolean",
4059
+ default: true,
4060
+ describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
4061
+ }), (argv) => {
4062
+ try {
4063
+ 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.");
4064
+ if (runPluginCreate({
4065
+ slug: argv.slug,
4066
+ vendorName: argv.vendor,
4067
+ capability: argv.capability,
4068
+ vendorEnv: argv.vendorEnv,
4069
+ baseUrl: argv.baseUrl,
4070
+ ...argv.tokenEnv ? { tokenEnv: argv.tokenEnv } : {},
4071
+ dryRun: argv.dryRun,
4072
+ noVerify: !argv.verify,
4073
+ cwd: argv.cwd
4074
+ }).status === "fail") process.exitCode = 1;
4075
+ } catch (err) {
4076
+ if (err instanceof PluginCreateError) {
4077
+ console.error(`plugin create: ${err.message}`);
4078
+ process.exitCode = 1;
4079
+ return;
4080
+ }
4081
+ throw err;
4082
+ }
4083
+ }).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", {
4084
+ type: "string",
4085
+ demandOption: true
4086
+ }).positional("token", { type: "string" }), async (argv) => {
4087
+ if ((await runAuthSet({
4088
+ provider: argv.provider,
4089
+ ...argv.token ? { positional: argv.token } : {}
4090
+ })).status === "fail") process.exitCode = 1;
4091
+ }).command("unset <provider>", "Remove a stored bootstrap token", (yy) => yy.positional("provider", {
4092
+ type: "string",
4093
+ demandOption: true
4094
+ }), (argv) => {
4095
+ runAuthUnset({ provider: argv.provider });
4096
+ }).command("check <provider>", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", {
4097
+ type: "string",
4098
+ demandOption: true
4099
+ }), async (argv) => {
4100
+ if ((await runAuthCheck({ provider: argv.provider })).status === "fail") process.exitCode = 1;
4101
+ }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
4102
+ await runAuthList();
4103
+ }).demandCommand(1, "Run `holocron auth --help` to see available auth subcommands."), () => {}).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
836
4104
  /**
837
4105
  * Parse `--scope` strings: `repo` | `env=NAME` | `org=NAME`.
838
4106
  */