@theholocron/cli 2.0.0-alpha.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,12 +1,261 @@
1
- #!/usr/bin/env -S tsx
2
- import { n as ProviderApiError, t as CARDINALITY } from "./capabilities-DapaKOlX.mjs";
3
- import { a as ConfigError, c as resolvePluginPackage, i as setToken, n as getToken, o as resolveConfig, r as listStoredProviders, t as deleteToken } from "./keyring-DwNEmrBc.mjs";
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
4
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import path, { basename, dirname, join, relative } from "node:path";
5
5
  import yargs from "yargs";
6
6
  import { hideBin } from "yargs/helpers";
7
- import path, { join } from "node:path";
8
- import { spawnSync } from "node:child_process";
9
- import { readFile, stat } from "node:fs/promises";
7
+ import { createInterface } from "node:readline";
8
+ import { stdin, stdout } from "node:process";
9
+ import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1 } from "@theholocron/http-client";
10
+ import { Entry, findCredentials } from "@napi-rs/keyring";
11
+ import ora from "ora";
12
+ import chalk from "chalk";
13
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+ import { createGitHubClient } from "@theholocron/github-client";
16
+ import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
17
+ import { pathToFileURL } from "node:url";
18
+ import { promisify } from "node:util";
19
+ import { homedir } from "node:os";
20
+ //#region src/capabilities/index.ts
21
+ const CARDINALITY = {
22
+ source: "single",
23
+ ci: "single",
24
+ secrets: "single",
25
+ environments: "single",
26
+ issues: "single",
27
+ deployment: "single",
28
+ storage: "single",
29
+ auth: "single",
30
+ vault: "single",
31
+ dns: "single",
32
+ tooling: "many",
33
+ notifications: "many",
34
+ analytics: "many",
35
+ observability: "many"
36
+ };
37
+ /**
38
+ * No capabilities are strictly required — repos without secrets (e.g. org
39
+ * community health repos) legitimately omit vault. Plugins validate their
40
+ * own requirements at call time.
41
+ */
42
+ const REQUIRED_CAPABILITIES = [];
43
+ //#endregion
44
+ //#region src/config.ts
45
+ /**
46
+ * `holocron.config.json` schema, parser, and provider resolution.
47
+ *
48
+ * ESLint-style entry forms:
49
+ *
50
+ * "source": "github" ← single, short
51
+ * "deployment": ["vercel", { team: "rando" }] ← single, with options
52
+ * "notifications": ["slack", "discord"] ← multi, short
53
+ * "notifications": [
54
+ * ["slack", { channel: "#ops" }],
55
+ * ["discord", { webhook: "env:HOOK" }]
56
+ * ] ← multi, with options
57
+ *
58
+ * Discriminator: an array entry is a `[provider, options]` tuple when
59
+ * the length is 2 AND element[1] is a non-array, non-null object.
60
+ * Otherwise it's a multi-provider list (string[] or tuple[]).
61
+ *
62
+ * Validation rules:
63
+ * - `vault` is REQUIRED (every project has secrets somewhere)
64
+ * - Entries for `'many'` capabilities are normalized to an array of
65
+ * normalized tuples; entries for `'single'` capabilities are
66
+ * normalized to one tuple
67
+ * - Tokens / secret values never appear in config — providers read
68
+ * them from env (or pull from `vault` at runtime)
69
+ */
70
+ var ConfigError = class extends Error {
71
+ name = "ConfigError";
72
+ };
73
+ const PLUGIN_PREFIX = "@theholocron/holocron-plugin-";
74
+ const COMMUNITY_PREFIX = "holocron-plugin-";
75
+ /**
76
+ * Resolve `"github"` → `"@theholocron/holocron-plugin-github"`.
77
+ * Fully-qualified names (scoped or not) are honored verbatim, which
78
+ * is how third-party plugins published outside the org work.
79
+ */
80
+ function resolvePluginPackage(provider) {
81
+ if (!provider) throw new ConfigError("provider name is empty");
82
+ if (provider.startsWith("@")) return provider;
83
+ if (provider.startsWith(COMMUNITY_PREFIX)) return provider;
84
+ if (provider.includes("/")) return provider;
85
+ return PLUGIN_PREFIX + provider;
86
+ }
87
+ /** A bare `[provider, options]` tuple, with both elements present? */
88
+ function isOptionsTuple(value) {
89
+ if (!Array.isArray(value)) return false;
90
+ if (value.length !== 2) return false;
91
+ if (typeof value[0] !== "string") return false;
92
+ const opt = value[1];
93
+ return typeof opt === "object" && opt !== null && !Array.isArray(opt);
94
+ }
95
+ function normalizeEntry(entry) {
96
+ if (typeof entry === "string") return {
97
+ provider: entry,
98
+ packageName: resolvePluginPackage(entry),
99
+ options: {}
100
+ };
101
+ const [provider, options] = entry;
102
+ return {
103
+ provider,
104
+ packageName: resolvePluginPackage(provider),
105
+ options
106
+ };
107
+ }
108
+ function resolveEntry(key, raw) {
109
+ const cardinality = CARDINALITY[key];
110
+ if (typeof raw === "string") {
111
+ if (cardinality === "many") throw new ConfigError(`\`${key}\` accepts multiple providers; wrap a single one in an array: ["${raw}"]`);
112
+ return {
113
+ cardinality: "single",
114
+ tuple: normalizeEntry(raw)
115
+ };
116
+ }
117
+ if (!Array.isArray(raw)) throw new ConfigError(`\`${key}\` entry must be a string or array, got ${typeof raw}`);
118
+ if (isOptionsTuple(raw)) {
119
+ if (cardinality === "many") return {
120
+ cardinality: "many",
121
+ tuples: [normalizeEntry(raw)]
122
+ };
123
+ return {
124
+ cardinality: "single",
125
+ tuple: normalizeEntry(raw)
126
+ };
127
+ }
128
+ if (cardinality === "single") throw new ConfigError(`\`${key}\` accepts exactly one provider; got a multi-provider list with ${raw.length} entries`);
129
+ return {
130
+ cardinality: "many",
131
+ tuples: raw.map((entry, idx) => {
132
+ if (typeof entry === "string") return normalizeEntry(entry);
133
+ if (isOptionsTuple(entry)) return normalizeEntry(entry);
134
+ throw new ConfigError(`\`${key}[${idx}]\` must be a provider string or [provider, options] tuple`);
135
+ })
136
+ };
137
+ }
138
+ function resolveConfig(raw) {
139
+ if (!raw.name) throw new ConfigError("`name` is required");
140
+ if (!raw.providers || typeof raw.providers !== "object") throw new ConfigError("`providers` block is required");
141
+ const providers = {};
142
+ for (const [key, entry] of Object.entries(raw.providers)) {
143
+ if (entry === void 0) continue;
144
+ providers[key] = resolveEntry(key, entry);
145
+ }
146
+ for (const required of REQUIRED_CAPABILITIES) if (!providers[required]) throw new ConfigError(`required capability \`${required}\` is missing from providers`);
147
+ return {
148
+ name: raw.name,
149
+ description: raw.description,
150
+ repo: raw.repo,
151
+ workflows: raw.workflows,
152
+ providers,
153
+ apps: raw.apps ?? [],
154
+ doctor: raw.doctor ?? {},
155
+ agent: raw.agent,
156
+ skills: raw.skills
157
+ };
158
+ }
159
+ //#endregion
160
+ //#region src/keyring.ts
161
+ /**
162
+ * Keyring-backed bootstrap credential store.
163
+ *
164
+ * Every holocron plugin's bootstrap token (the one it needs before it
165
+ * can talk to its vendor's API) can be stored in the OS keyring under
166
+ * a single reverse-DNS service scope. Managed via `holocron auth`
167
+ * subcommands; consulted at position 4 in every plugin's auth
168
+ * precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
169
+ *
170
+ * See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
171
+ *
172
+ * Failure model: keyring access is best-effort. Platforms without a
173
+ * supported credential store (some Linux CI images, sandboxed
174
+ * environments) will throw from the underlying library. Every export
175
+ * here catches and returns a null/empty result rather than propagating
176
+ * — the plugin's precedence chain then falls through to
177
+ * env-var-only paths, which is exactly how CI is meant to work.
178
+ */
179
+ const SERVICE = "com.theholocron.cli";
180
+ /**
181
+ * Store or overwrite a bootstrap token for a provider. Returns true on
182
+ * success, false when the underlying keyring is unsupported or errored.
183
+ */
184
+ function setToken(provider, token) {
185
+ try {
186
+ new Entry(SERVICE, provider).setPassword(token);
187
+ return true;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+ /**
193
+ * Read the bootstrap token for a provider. Returns `null` for both
194
+ * "not stored" and "keyring unavailable" — callers can treat them the
195
+ * same way (fall through to env-var precedence).
196
+ */
197
+ function getToken(provider) {
198
+ try {
199
+ return new Entry(SERVICE, provider).getPassword();
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+ /**
205
+ * Delete a stored token. Returns true when a token was removed, false
206
+ * when there was nothing to delete or the keyring is unavailable.
207
+ * Distinguishing the two cases isn't worth the surface area — the
208
+ * command output makes the situation clear either way.
209
+ */
210
+ function deleteToken(provider) {
211
+ try {
212
+ return new Entry(SERVICE, provider).deletePassword();
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+ /**
218
+ * List provider slugs with a stored token in this service scope.
219
+ * Uses the library's `findCredentials(service)` — supported on all
220
+ * platforms the underlying credential store supports.
221
+ */
222
+ function listStoredProviders() {
223
+ try {
224
+ return findCredentials(SERVICE).map((c) => c.account);
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+ //#endregion
230
+ //#region src/ui/progress.ts
231
+ /**
232
+ * Runs `fn`, showing an ora spinner for its duration in TTY environments.
233
+ * In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
234
+ */
235
+ async function withSpinner(label, fn) {
236
+ if (!process.stdout.isTTY) return fn();
237
+ const spinner = ora(label).start();
238
+ try {
239
+ const result = await fn();
240
+ spinner.succeed();
241
+ return result;
242
+ } catch (err) {
243
+ spinner.fail();
244
+ throw err;
245
+ }
246
+ }
247
+ //#endregion
248
+ //#region src/ui/style.ts
249
+ const style = {
250
+ success: (msg) => `${chalk.green("✓")} ${msg}`,
251
+ warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
252
+ fail: (msg) => `${chalk.red("✗")} ${msg}`,
253
+ step: (msg) => `${chalk.cyan("→")} ${msg}`,
254
+ hint: (msg) => chalk.dim(msg),
255
+ dim: (msg) => chalk.dim(msg),
256
+ header: (msg) => chalk.bold(msg)
257
+ };
258
+ //#endregion
10
259
  //#region src/commands/auth.ts
11
260
  /**
12
261
  * `holocron auth <subcommand>` — manage bootstrap credentials in the
@@ -49,11 +298,11 @@ async function runAuthSet(input) {
49
298
  });
50
299
  const packageName = resolvePluginPackage(provider);
51
300
  if (!token) {
52
- print(`no token supplied for \`${provider}\`.`);
53
- print(` pass as positional arg: holocron auth set ${provider} <token>`);
54
- print(` or via env: HOLOCRON_${provider.toUpperCase()}_TOKEN / ${provider.toUpperCase()}_TOKEN`);
301
+ print(style.fail(`no token supplied for \`${provider}\`.`));
302
+ print(style.hint(` pass as positional arg: holocron auth set ${provider} <token>`));
303
+ print(style.hint(` or via env: HOLOCRON_${provider.toUpperCase()}_TOKEN / ${provider.toUpperCase()}_TOKEN`));
55
304
  const hint = await tryLoadHint(importer, packageName);
56
- if (hint) print(` hint: ${hint}`);
305
+ if (hint) print(style.hint(` hint: ${hint}`));
57
306
  return {
58
307
  status: "fail",
59
308
  message: "no token supplied"
@@ -65,27 +314,28 @@ async function runAuthSet(input) {
65
314
  if (typeof module.verifyToken === "function") {
66
315
  const verified = await module.verifyToken(token);
67
316
  if (!verified.ok) {
68
- print(`token rejected by ${provider}: ${verified.message}`);
69
- if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
317
+ print(style.fail(`token rejected by ${provider}: ${verified.message}`));
318
+ if (module.AUTH_HINT) print(style.hint(` hint: ${module.AUTH_HINT}`));
70
319
  return {
71
320
  status: "fail",
72
321
  message: verified.message
73
322
  };
74
323
  }
75
324
  subject = verified.subject;
76
- } else print(`(${provider} plugin has no verifyToken; storing without verification)`);
325
+ } else print(style.warn(`${provider} plugin has no verifyToken; storing without verification`));
77
326
  } catch (err) {
78
- print(`cannot verify token — failed to load ${packageName}: ${err instanceof Error ? err.message : String(err)}`);
79
- print(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`);
327
+ const msg = err instanceof Error ? err.message : String(err);
328
+ print(style.warn(`cannot verify token failed to load ${packageName}: ${msg}`));
329
+ print(style.hint(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`));
80
330
  }
81
331
  if (!setToken(provider, token)) {
82
- print(`keyring unavailable — token not stored. Use env vars instead.`);
332
+ print(style.fail(`keyring unavailable — token not stored. Use env vars instead.`));
83
333
  return {
84
334
  status: "fail",
85
335
  message: "keyring unavailable"
86
336
  };
87
337
  }
88
- print(`stored ${provider} token${subject ? ` (${subject})` : ""}`);
338
+ print(style.success(`stored ${provider} token${subject ? ` (${subject})` : ""}`));
89
339
  return {
90
340
  status: "ok",
91
341
  ...subject ? { message: subject } : {}
@@ -94,10 +344,10 @@ async function runAuthSet(input) {
94
344
  function runAuthUnset(input) {
95
345
  const print = input.print ?? ((l) => console.log(l));
96
346
  if (deleteToken(input.provider)) {
97
- print(`removed ${input.provider} token`);
347
+ print(style.success(`removed ${input.provider} token`));
98
348
  return { status: "ok" };
99
349
  }
100
- print(`no stored token for ${input.provider}`);
350
+ print(style.dim(`no stored token for ${input.provider}`));
101
351
  return {
102
352
  status: "skip",
103
353
  message: "nothing to remove"
@@ -109,7 +359,7 @@ async function runAuthCheck(input) {
109
359
  const { provider } = input;
110
360
  const token = getToken(provider);
111
361
  if (!token) {
112
- print(`no stored token for ${provider}`);
362
+ print(style.dim(`no stored token for ${provider}`));
113
363
  return {
114
364
  status: "skip",
115
365
  message: "no stored token"
@@ -119,29 +369,30 @@ async function runAuthCheck(input) {
119
369
  try {
120
370
  const module = await importer(packageName);
121
371
  if (typeof module.verifyToken !== "function") {
122
- print(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`);
372
+ print(style.warn(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`));
123
373
  return {
124
374
  status: "ok",
125
375
  message: "stored, unverified"
126
376
  };
127
377
  }
128
- const verified = await module.verifyToken(token);
378
+ const verify = () => module.verifyToken(token);
379
+ const verified = input.showSpinner !== false ? await withSpinner(`Verifying ${provider} token…`, verify) : await verify();
129
380
  if (verified.ok) {
130
- print(`${provider}: ok — ${verified.subject}`);
381
+ print(style.success(`${provider}: ok — ${verified.subject}`));
131
382
  return {
132
383
  status: "ok",
133
384
  message: verified.subject
134
385
  };
135
386
  }
136
- print(`${provider}: rejected — ${verified.message}`);
137
- if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
387
+ print(style.fail(`${provider}: rejected — ${verified.message}`));
388
+ if (module.AUTH_HINT) print(style.hint(` hint: ${module.AUTH_HINT}`));
138
389
  return {
139
390
  status: "fail",
140
391
  message: verified.message
141
392
  };
142
393
  } catch (err) {
143
394
  const msg = err instanceof Error ? err.message : String(err);
144
- print(`${provider}: cannot verify — ${msg}`);
395
+ print(style.fail(`${provider}: cannot verify — ${msg}`));
145
396
  return {
146
397
  status: "fail",
147
398
  message: msg
@@ -153,8 +404,8 @@ async function runAuthList(input = {}) {
153
404
  const importer = input.importer ?? defaultImporter$1;
154
405
  const providers = listStoredProviders();
155
406
  if (providers.length === 0) {
156
- print("no stored tokens.");
157
- print("run: holocron auth set <provider> <token>");
407
+ print(style.dim("no stored tokens."));
408
+ print(style.hint("run: holocron auth set <provider> <token>"));
158
409
  return {
159
410
  status: "ok",
160
411
  message: "none"
@@ -164,9 +415,13 @@ async function runAuthList(input = {}) {
164
415
  const check = await runAuthCheck({
165
416
  provider,
166
417
  importer,
167
- print: () => {}
418
+ print: () => {},
419
+ showSpinner: false
168
420
  });
169
- print(` ${check.status === "ok" ? "✓" : check.status === "fail" ? "✗" : "·"} ${provider}${check.message ? ` — ${check.message}` : ""}`);
421
+ const label = `${provider}${check.message ? ` — ${check.message}` : ""}`;
422
+ if (check.status === "ok") print(` ${style.success(label)}`);
423
+ else if (check.status === "fail") print(` ${style.fail(label)}`);
424
+ else print(` ${style.dim(`· ${label}`)}`);
170
425
  }
171
426
  return { status: "ok" };
172
427
  }
@@ -178,6 +433,210 @@ async function tryLoadHint(importer, packageName) {
178
433
  }
179
434
  }
180
435
  //#endregion
436
+ //#region src/commands/new.ts
437
+ /**
438
+ * `holocron new <type> <name>` — create a GitHub repo from a template and
439
+ * bootstrap it by replacing all template-slug casing variants with the new
440
+ * project name.
441
+ *
442
+ * Flow:
443
+ * 1. Preflight — verify `gh` CLI is available.
444
+ * 2. Resolve type, name, description (prompt via readline if missing).
445
+ * 3. `gh repo create <org>/<name> --template <org>/<type>-template --private --clone`
446
+ * → clones to `<cwd>/<name>/`
447
+ * 4. Detect template slug from cloned package.json.
448
+ * 5. Replace all casing variants of the slug across every text file.
449
+ * 6. Replace `<description>` placeholder if a description was given.
450
+ * 7. Commit the patched files (-s for DCO).
451
+ * 8. Unless --no-verify: `pnpm install` in the new repo.
452
+ * 9. Print next steps.
453
+ */
454
+ var NewError = class extends Error {
455
+ name = "NewError";
456
+ };
457
+ function cap(word) {
458
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
459
+ }
460
+ /**
461
+ * Derive all common casing variants of a kebab-case slug (e.g.
462
+ * "cli-template") and map each to the corresponding form of a
463
+ * kebab-case name (e.g. "my-tool").
464
+ *
465
+ * Returns search→replacement pairs, deduplicating single-word slugs
466
+ * that would otherwise produce identical entries.
467
+ */
468
+ function deriveVariants(slug, name) {
469
+ const sw = slug.split("-");
470
+ const nw = name.split("-");
471
+ const pairs = [
472
+ [slug, name],
473
+ [sw.join("_"), nw.join("_")],
474
+ [sw.join("_").toUpperCase(), nw.join("_").toUpperCase()],
475
+ [sw.map(cap).join(""), nw.map(cap).join("")],
476
+ [sw[0].toLowerCase() + sw.slice(1).map(cap).join(""), nw[0].toLowerCase() + nw.slice(1).map(cap).join("")],
477
+ [sw.map(cap).join(" "), nw.map(cap).join(" ")]
478
+ ];
479
+ const seen = /* @__PURE__ */ new Set();
480
+ return pairs.filter(([s]) => {
481
+ if (seen.has(s)) return false;
482
+ seen.add(s);
483
+ return true;
484
+ });
485
+ }
486
+ const SKIP_DIRS$1 = /* @__PURE__ */ new Set([
487
+ ".git",
488
+ "node_modules",
489
+ "dist",
490
+ ".turbo"
491
+ ]);
492
+ function defaultWalkFiles$1(dir) {
493
+ const results = [];
494
+ for (const entry of readdirSync(dir)) {
495
+ if (SKIP_DIRS$1.has(entry)) continue;
496
+ const full = path.join(dir, entry);
497
+ const stat = statSync(full);
498
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$1(full));
499
+ else if (stat.isFile()) results.push(full);
500
+ }
501
+ return results;
502
+ }
503
+ function isBinary(content) {
504
+ for (let i = 0; i < Math.min(content.length, 8e3); i++) if (content.charCodeAt(i) === 0) return true;
505
+ return false;
506
+ }
507
+ function patchFiles(dir, variants, description, print, readFn, writeFn, walkFn) {
508
+ const patched = [];
509
+ for (const filepath of walkFn(dir)) {
510
+ let content;
511
+ try {
512
+ content = readFn(filepath);
513
+ } catch {
514
+ continue;
515
+ }
516
+ if (isBinary(content)) continue;
517
+ const original = content;
518
+ for (const [search, replacement] of variants) content = content.split(search).join(replacement);
519
+ if (description !== void 0) content = content.split("<description>").join(description);
520
+ if (content !== original) {
521
+ writeFn(filepath, content);
522
+ print(` ✓ ${path.relative(dir, filepath)}`);
523
+ patched.push(filepath);
524
+ }
525
+ }
526
+ return patched;
527
+ }
528
+ function preflight$1() {
529
+ const result = spawnSync("gh", ["--version"], { encoding: "utf8" });
530
+ if (result.error != null || result.status !== 0) throw new NewError("`gh` CLI is not installed or not on PATH. Install it from https://cli.github.com");
531
+ }
532
+ function defaultExec$3(cmd, args, opts) {
533
+ execFileSync(cmd, args, {
534
+ cwd: opts.cwd,
535
+ stdio: opts.stdio
536
+ });
537
+ }
538
+ function defaultReadFile(filepath) {
539
+ return readFileSync(filepath, "utf-8");
540
+ }
541
+ function defaultWriteFile(filepath, content) {
542
+ mkdirSync(path.dirname(filepath), { recursive: true });
543
+ writeFileSync(filepath, content, "utf-8");
544
+ }
545
+ async function runNew(input) {
546
+ const cwd = input.cwd ?? process.cwd();
547
+ const org = input.org ?? "theholocron";
548
+ const print = input.print ?? ((line) => console.log(line));
549
+ const execFn = input.exec ?? defaultExec$3;
550
+ const readFn = input.readFile ?? defaultReadFile;
551
+ const writeFn = input.writeFile ?? defaultWriteFile;
552
+ const walkFn = input.walkFiles ?? defaultWalkFiles$1;
553
+ preflight$1();
554
+ const templateRepo = `${org}/${input.type}-template`;
555
+ const newRepo = `${org}/${input.name}`;
556
+ const repoDir = path.join(cwd, input.name);
557
+ if (input.dryRun) {
558
+ print(` Would create ${newRepo} from template ${templateRepo}`);
559
+ print(` Would clone to ${repoDir}`);
560
+ print(` Would patch all casing variants of "${input.type}-template" → "${input.name}"`);
561
+ if (input.description) print(` Would replace <description> → "${input.description}"`);
562
+ return { status: "dry-run" };
563
+ }
564
+ if (existsSync(repoDir)) throw new NewError(`\`${repoDir}\` already exists — delete it or pick a different name.`);
565
+ print(` Creating ${newRepo} from template ${templateRepo}…`);
566
+ try {
567
+ execFn("gh", [
568
+ "repo",
569
+ "create",
570
+ newRepo,
571
+ `--template=${templateRepo}`,
572
+ "--private",
573
+ "--clone"
574
+ ], {
575
+ cwd,
576
+ stdio: "inherit"
577
+ });
578
+ } catch (err) {
579
+ throw new NewError(`gh repo create failed: ${err instanceof Error ? err.message : String(err)}`);
580
+ }
581
+ let templateSlug = `${input.type}-template`;
582
+ const pkgJsonPath = path.join(repoDir, "package.json");
583
+ if (existsSync(pkgJsonPath)) try {
584
+ const pkg = JSON.parse(readFn(pkgJsonPath));
585
+ if (typeof pkg.name === "string") templateSlug = pkg.name.split("/").pop() ?? templateSlug;
586
+ } catch {}
587
+ print(` Detected template slug: ${templateSlug}`);
588
+ print(` Patching files…`);
589
+ const filesPatched = patchFiles(repoDir, deriveVariants(templateSlug, input.name), input.description, print, readFn, writeFn, walkFn);
590
+ print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
591
+ if (filesPatched.length > 0) {
592
+ execFn("git", ["add", "-A"], {
593
+ cwd: repoDir,
594
+ stdio: "inherit"
595
+ });
596
+ execFn("git", [
597
+ "commit",
598
+ "-s",
599
+ "-m",
600
+ `chore: bootstrap from ${templateSlug}`
601
+ ], {
602
+ cwd: repoDir,
603
+ stdio: "inherit"
604
+ });
605
+ }
606
+ if (!input.noVerify) {
607
+ print("");
608
+ print(" Installing dependencies…");
609
+ try {
610
+ execFn("pnpm", ["install"], {
611
+ cwd: repoDir,
612
+ stdio: "inherit"
613
+ });
614
+ } catch (err) {
615
+ print(` ✗ pnpm install failed — ${err instanceof Error ? err.message : String(err)}`);
616
+ return {
617
+ status: "fail",
618
+ repoDir,
619
+ filesPatched,
620
+ message: "pnpm install failed; inspect output above"
621
+ };
622
+ }
623
+ }
624
+ print("");
625
+ print(` Scaffolded ${newRepo} (${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched).`);
626
+ print("");
627
+ print(" Next:");
628
+ print(` 1. cd ${repoDir}`);
629
+ if (input.noVerify) print(` 2. pnpm install`);
630
+ const step = input.noVerify ? 3 : 2;
631
+ print(` ${step}. holocron setup # wire up secrets, teams, labels, etc.`);
632
+ print(` ${step + 1}. git push -u origin HEAD`);
633
+ return {
634
+ status: "ok",
635
+ repoDir,
636
+ filesPatched
637
+ };
638
+ }
639
+ //#endregion
181
640
  //#region src/loader.ts
182
641
  var LoaderError = class extends Error {
183
642
  name = "LoaderError";
@@ -225,17 +684,33 @@ var PluginLoader = class {
225
684
  }
226
685
  /** Internal — invoke a plugin's capability factory and return the impl. */
227
686
  async loadOne(key, tuple) {
228
- const module = await this.importer(tuple.packageName).catch((err) => {
687
+ const mod = await this.importer(tuple.packageName).catch((err) => {
229
688
  throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
230
689
  });
231
- if (typeof module.createPlugin !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\``);
232
- const factory = module.createPlugin({
233
- ...this.projectDefaults(),
234
- ...this.context,
235
- ...tuple.options
236
- }).capabilities[key];
237
- if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
238
- return factory();
690
+ if (isPluginModule(mod)) {
691
+ const effectiveToken = this.context.cliTokens?.[tuple.provider] ?? this.context.cliToken;
692
+ const factory = mod.createPlugin({
693
+ ...this.projectDefaults(),
694
+ ...this.context,
695
+ ...effectiveToken !== void 0 ? { cliToken: effectiveToken } : {},
696
+ cliTokens: void 0,
697
+ ...tuple.options
698
+ }).capabilities[key];
699
+ if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
700
+ return factory();
701
+ }
702
+ if (isCapabilityConfigModule(mod)) {
703
+ const cap = mod.default;
704
+ return this.loadOne(key, {
705
+ provider: cap.provider,
706
+ packageName: resolvePluginPackage(cap.provider),
707
+ options: {
708
+ ...cap.options,
709
+ ...tuple.options
710
+ }
711
+ });
712
+ }
713
+ throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\` or a capability config ({ provider, options? })`);
239
714
  }
240
715
  /**
241
716
  * Project-level defaults that get merged into every plugin's options
@@ -244,14 +719,20 @@ var PluginLoader = class {
244
719
  */
245
720
  projectDefaults() {
246
721
  const defaults = {};
247
- if (this.config.project.repo) defaults.repo = this.config.project.repo;
722
+ if (this.config.repo?.name) defaults.repo = this.config.repo.name;
248
723
  return defaults;
249
724
  }
250
725
  };
251
726
  /** Default importer — native dynamic import. */
252
727
  const defaultImporter = async (pkg) => {
253
- return await import(pkg);
728
+ return import(pkg);
254
729
  };
730
+ function isPluginModule(mod) {
731
+ return typeof mod.createPlugin === "function";
732
+ }
733
+ function isCapabilityConfigModule(mod) {
734
+ return typeof mod.default?.provider === "string";
735
+ }
255
736
  //#endregion
256
737
  //#region src/commands/deploy.ts
257
738
  async function runDeploy(input) {
@@ -259,12 +740,12 @@ async function runDeploy(input) {
259
740
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
260
741
  await loader.load();
261
742
  const dryRun = input.context.dryRun ?? false;
262
- print(`Holocron deploy — branch=${input.branch}${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`);
743
+ print(style.header(`Holocron deploy — branch=${input.branch}${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
263
744
  if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
264
745
  const deploy = loader.get("deployment");
265
746
  if (dryRun) {
266
747
  const message = `would: ${deploy.providerName}.triggerDeployment(projectId=${input.projectId}, branch=${input.branch}${input.target ? `, target=${input.target}` : ""})`;
267
- print(` ${message}`);
748
+ print(` ${style.dim(`… ${message}`)}`);
268
749
  return {
269
750
  deployment: null,
270
751
  status: "dry-run",
@@ -272,19 +753,19 @@ async function runDeploy(input) {
272
753
  };
273
754
  }
274
755
  try {
275
- const record = await deploy.triggerDeployment({
756
+ const record = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
276
757
  projectId: input.projectId,
277
758
  branch: input.branch,
278
759
  ...input.target ? { target: input.target } : {}
279
- });
280
- print(` ${record.status} — ${record.url}`);
760
+ }));
761
+ print(` ${style.success(`${record.status} — ${record.url}`)}`);
281
762
  return {
282
763
  deployment: record,
283
764
  status: "ok"
284
765
  };
285
766
  } catch (err) {
286
767
  const message = err instanceof Error ? err.message : String(err);
287
- print(` ${message}`);
768
+ print(` ${style.fail(message)}`);
288
769
  return {
289
770
  deployment: null,
290
771
  status: "fail",
@@ -297,11 +778,11 @@ async function runDeploy(input) {
297
778
  async function runDoctor(input) {
298
779
  const print = input.print ?? ((line) => console.log(line));
299
780
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
300
- await loader.load();
781
+ await withSpinner("Loading plugins…", () => loader.load());
301
782
  const rows = [];
302
783
  const config = input.loaded.resolved;
303
- print(`Holocron doctor — ${config.project.name}`);
304
- print(` config: ${input.loaded.filepath}`);
784
+ print(style.header(`Holocron doctor — ${config.name}`));
785
+ print(style.dim(` config: ${input.loaded.filepath}`));
305
786
  print("");
306
787
  for (const key of loader.loadedKeys()) {
307
788
  const cardinality = CARDINALITY[key];
@@ -321,7 +802,12 @@ async function runDoctor(input) {
321
802
  rows.push(row);
322
803
  }
323
804
  }
324
- for (const row of rows) print(` ${row.status === "ok" ? "✓" : row.status === "fail" ? "✗" : "·"} ${pad(row.capability, 14)} via ${pad(row.provider, 14)} ${row.message}`);
805
+ for (const row of rows) {
806
+ const label = `${pad(row.capability, 14)} via ${pad(row.provider, 14)} ${row.message}`;
807
+ if (row.status === "ok") print(` ${style.success(label)}`);
808
+ else if (row.status === "fail") print(` ${style.fail(label)}`);
809
+ else print(` ${style.dim(`· ${label}`)}`);
810
+ }
325
811
  const summary = rows.reduce((acc, r) => {
326
812
  acc[r.status] += 1;
327
813
  return acc;
@@ -330,8 +816,9 @@ async function runDoctor(input) {
330
816
  fail: 0,
331
817
  skip: 0
332
818
  });
819
+ const summaryLine = `${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`;
333
820
  print("");
334
- print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`);
821
+ print(summary.fail > 0 ? style.fail(summaryLine) : style.success(summaryLine));
335
822
  return {
336
823
  rows,
337
824
  summary
@@ -446,1075 +933,667 @@ async function runNpmBumpVersions(input) {
446
933
  };
447
934
  }
448
935
  //#endregion
936
+ //#region src/commands/upgrade-node.ts
937
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
938
+ "node_modules",
939
+ ".git",
940
+ "dist",
941
+ "coverage",
942
+ "build",
943
+ ".turbo",
944
+ ".next",
945
+ "out"
946
+ ]);
947
+ function patchPackageJson(content, from, to) {
948
+ let pkg;
949
+ try {
950
+ pkg = JSON.parse(content);
951
+ } catch {
952
+ return null;
953
+ }
954
+ let changed = false;
955
+ const engines = pkg.engines;
956
+ if (engines?.node && /^>=\d+(?:\.0\.0)?$/.test(engines.node)) {
957
+ if (parseInt(engines.node.match(/\d+/)[0], 10) === from) {
958
+ engines.node = `>=${to}.0.0`;
959
+ changed = true;
960
+ }
961
+ }
962
+ for (const field of ["devDependencies", "dependencies"]) {
963
+ const deps = pkg[field];
964
+ if (!deps?.["@types/node"]) continue;
965
+ if (deps["@types/node"] === `^${from}.0.0`) {
966
+ deps["@types/node"] = `^${to}.0.0`;
967
+ changed = true;
968
+ }
969
+ }
970
+ return changed ? JSON.stringify(pkg, null, 2) + "\n" : null;
971
+ }
972
+ function patchYaml(content, from, to) {
973
+ const updated = content.replace(/node-version:\s+['"]?(\d+)['"]?/g, (match, ver) => ver === String(from) ? match.replace(String(from), String(to)) : match);
974
+ return updated !== content ? updated : null;
975
+ }
976
+ function patchPinFile(content, from, to) {
977
+ const trimmed = content.trim();
978
+ if (trimmed === String(from) || trimmed.startsWith(`${from}.`)) return `${to}\n`;
979
+ return null;
980
+ }
981
+ function patchDockerfile(content, from, to) {
982
+ const updated = content.replace(/^(FROM\s+node:)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
983
+ return updated !== content ? updated : null;
984
+ }
985
+ function patchToolVersions(content, from, to) {
986
+ const updated = content.replace(/^(nodejs\s+)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
987
+ return updated !== content ? updated : null;
988
+ }
989
+ const PATTERNS = [
990
+ {
991
+ matches: (n) => n === "package.json",
992
+ patch: patchPackageJson
993
+ },
994
+ {
995
+ matches: (n) => n.endsWith(".yml") || n.endsWith(".yaml"),
996
+ patch: patchYaml
997
+ },
998
+ {
999
+ matches: (n) => n === ".nvmrc" || n === ".node-version",
1000
+ patch: patchPinFile
1001
+ },
1002
+ {
1003
+ matches: (n) => n === "Dockerfile" || n.startsWith("Dockerfile."),
1004
+ patch: patchDockerfile
1005
+ },
1006
+ {
1007
+ matches: (n) => n === ".tool-versions",
1008
+ patch: patchToolVersions
1009
+ }
1010
+ ];
1011
+ function detectFrom(cwd, _readFile) {
1012
+ for (const name of [".nvmrc", ".node-version"]) try {
1013
+ const major = parseInt(_readFile(join(cwd, name)).trim(), 10);
1014
+ if (!isNaN(major)) return major;
1015
+ } catch {}
1016
+ try {
1017
+ const node = JSON.parse(_readFile(join(cwd, "package.json"))).engines?.node;
1018
+ if (node) {
1019
+ const m = node.match(/(\d+)/);
1020
+ if (m) return parseInt(m[1], 10);
1021
+ }
1022
+ } catch {}
1023
+ return null;
1024
+ }
1025
+ function defaultWalkFiles(dir) {
1026
+ const results = [];
1027
+ function walk(current) {
1028
+ let entries;
1029
+ try {
1030
+ entries = readdirSync(current);
1031
+ } catch {
1032
+ return;
1033
+ }
1034
+ for (const entry of entries) {
1035
+ if (SKIP_DIRS.has(entry)) continue;
1036
+ const abs = join(current, entry);
1037
+ try {
1038
+ if (statSync(abs).isDirectory()) walk(abs);
1039
+ else results.push(abs);
1040
+ } catch {}
1041
+ }
1042
+ }
1043
+ walk(dir);
1044
+ return results;
1045
+ }
1046
+ async function runUpgradeNode(input) {
1047
+ const print = input.print ?? ((line) => console.log(line));
1048
+ const cwd = input.cwd ?? process.cwd();
1049
+ const { to, dryRun = false, extra = [] } = input;
1050
+ const _readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1051
+ const _writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
1052
+ const _walkFiles = input.walkFiles ?? defaultWalkFiles;
1053
+ const from = input.from ?? detectFrom(cwd, _readFile);
1054
+ if (from === null) return {
1055
+ status: "fail",
1056
+ updated: [],
1057
+ message: "could not detect current Node version — pass --from <major>"
1058
+ };
1059
+ if (from === to) {
1060
+ print(`Already at Node.js ${to} — nothing to do.`);
1061
+ return {
1062
+ status: "ok",
1063
+ updated: []
1064
+ };
1065
+ }
1066
+ print(`Upgrading Node.js ${from} → ${to}${dryRun ? " (dry-run)" : ""}…`);
1067
+ const updated = [];
1068
+ const scanned = [..._walkFiles(cwd), ...extra.map((p) => join(cwd, p))];
1069
+ for (const abs of scanned) {
1070
+ const name = basename(abs);
1071
+ const pattern = PATTERNS.find((p) => p.matches(name));
1072
+ if (!pattern) continue;
1073
+ let content;
1074
+ try {
1075
+ content = _readFile(abs);
1076
+ } catch {
1077
+ continue;
1078
+ }
1079
+ const patched = pattern.patch(content, from, to);
1080
+ if (patched === null) continue;
1081
+ const rel = abs.startsWith(cwd + "/") ? abs.slice(cwd.length + 1) : abs;
1082
+ if (!dryRun) _writeFile(abs, patched);
1083
+ print(` ${dryRun ? "~" : "✓"} ${rel}`);
1084
+ updated.push(rel);
1085
+ }
1086
+ if (updated.length === 0) print(` · no files contained Node.js ${from} pins`);
1087
+ return {
1088
+ status: dryRun ? "dry-run" : "ok",
1089
+ updated
1090
+ };
1091
+ }
1092
+ //#endregion
1093
+ //#region src/templates/actions/setup.yml
1094
+ var setup_default = "name: Setup\ndescription: Prepare the environment and install project dependencies.\n\ninputs:\n node-version:\n description: Node.js version\n required: false\n default: \"22.x\"\n\nruns:\n using: composite\n\n steps:\n - uses: theholocron/.github/.github/actions/setup-node@main\n with:\n node-version: ${{ inputs.node-version }}\n\n - uses: theholocron/.github/.github/actions/install@main\n";
1095
+ //#endregion
1096
+ //#region src/templates/actions/install.yml
1097
+ var install_default = "name: Install dependencies\ndescription: Install project dependencies with pnpm frozen lockfile.\n\nruns:\n using: composite\n\n steps:\n - name: Install dependencies\n if: ${{ hashFiles('pnpm-lock.yaml') != '' }}\n shell: bash\n run: pnpm install --frozen-lockfile\n";
1098
+ //#endregion
1099
+ //#region src/templates/actions/setup-node.yml
1100
+ var setup_node_default = "name: Setup Node\ndescription: Install pnpm and Node.js with pnpm dependency caching.\n\ninputs:\n node-version:\n description: Node.js version\n required: false\n default: \"22.x\"\n\nruns:\n using: composite\n\n steps:\n - name: Setup pnpm\n if: ${{ hashFiles('pnpm-lock.yaml') != '' }}\n uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4\n\n - name: Setup Node.js\n uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4\n with:\n node-version: ${{ hashFiles('.node-version') != '' && '' || inputs.node-version }}\n node-version-file: ${{ hashFiles('.node-version') != '' && '.node-version' || '' }}\n cache: ${{ hashFiles('pnpm-lock.yaml') != '' && 'pnpm' || '' }}\n\n - name: Add node_modules/.bin to PATH\n shell: bash\n run: echo \"$GITHUB_WORKSPACE/node_modules/.bin\" >> $GITHUB_PATH\n";
1101
+ //#endregion
1102
+ //#region src/templates/workflows/audit.yml
1103
+ var audit_default$1 = "name: Audit\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n build-script:\n description: Script to build and upload bundle stats to Codecov\n type: string\n required: false\n default: pnpm build\n secrets:\n CODECOV_TOKEN:\n required: false\n\njobs:\n bundle-size:\n name: Audit the bundle size\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 15\n concurrency:\n group: audit-${{ github.ref }}\n cancel-in-progress: true\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: eval \"$BUILD_SCRIPT\"\n name: Build and upload bundle stats\n env:\n BUILD_SCRIPT: ${{ inputs.build-script }}\n CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}\n";
1104
+ //#endregion
1105
+ //#region src/templates/workflows/bookkeeping.yml
1106
+ var bookkeeping_default$1 = "name: Bookkeeping\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n configuration-path:\n description: Path to the labeler configuration file in the calling repo\n type: string\n required: false\n default: .github/labeler.yml\n\njobs:\n label:\n name: Apply Labels\n permissions:\n contents: read\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n concurrency:\n group: bookkeeping-${{ github.event.pull_request.number || github.event.issue.number }}\n cancel-in-progress: true\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n with:\n sparse-checkout: ${{ inputs.configuration-path || '.github/labeler.yml' }}\n sparse-checkout-cone-mode: false\n\n - uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4\n if: ${{ github.event_name == 'pull_request' && hashFiles(inputs.configuration-path || '.github/labeler.yml') != '' }}\n # v3.4 bundles Node 20; allow it to run under Actions' current default.\n env:\n ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true\n with:\n # Fall back to default path when triggered directly (not via workflow_call)\n # because inputs.* defaults only apply on workflow_call events.\n configuration-path: ${{ inputs.configuration-path || '.github/labeler.yml' }}\n include-title: 1\n include-body: 0\n sync-labels: 1\n enable-versioned-regex: 0\n repo-token: ${{ github.token }}\n";
1107
+ //#endregion
1108
+ //#region src/templates/workflows/codeql.yml
1109
+ var codeql_default$1 = "name: CodeQL\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n language:\n description: CodeQL language to analyze\n type: string\n required: false\n default: javascript-typescript\n\njobs:\n analyze:\n name: Analyze (${{ inputs.language }})\n permissions:\n actions: read\n contents: read\n security-events: write\n runs-on: ubuntu-latest\n timeout-minutes: 45\n # Do not cancel in-progress security scans.\n concurrency:\n group: codeql-${{ github.ref }}\n cancel-in-progress: false\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0\n name: Initialize CodeQL\n with:\n languages: ${{ inputs.language }}\n\n - uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0\n name: Autobuild\n\n - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0\n name: Analyze\n with:\n category: /language:${{ inputs.language }}\n";
1110
+ //#endregion
1111
+ //#region src/templates/workflows/dependencies.yml
1112
+ var dependencies_default$1 = "name: Dependencies\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n secrets:\n merge-token:\n description: >\n Optional privileged token for auto-merge. Falls back to GITHUB_TOKEN.\n Required when branch protection enforces required reviews — GITHUB_TOKEN\n cannot approve its own PRs.\n required: false\n\njobs:\n dependabot:\n name: Update the dependencies\n permissions:\n contents: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n concurrency:\n group: dependencies-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n if: github.event.pull_request.user.login == 'dependabot[bot]'\n steps:\n - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0\n name: Fetch Dependabot metadata\n id: metadata\n\n - run: gh pr merge --auto --squash \"$PR_URL\"\n # --squash is intentional: repo protection sets allow_merge_commit: false,\n # so --merge would fail on any repo using the standard preset.\n name: Enable auto-merge for Dependabot PRs\n if: steps.metadata.outputs.update-type == 'version-update:semver-patch'\n env:\n PR_URL: ${{ github.event.pull_request.html_url }}\n GH_TOKEN: ${{ secrets.merge-token || github.token }}\n";
1113
+ //#endregion
1114
+ //#region src/templates/workflows/greetings.yml
1115
+ var greetings_default$1 = "name: Greetings\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n\njobs:\n greeting:\n name: Greet first-time contributors\n permissions:\n issues: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 5\n # Group by the issue/PR number so duplicate events don't race each other.\n concurrency:\n group: greetings-${{ github.event.issue.number || github.event.pull_request.number }}\n cancel-in-progress: false\n steps:\n - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n name: Greet on first contribution\n with:\n script: |\n // Only greet on the initial open — ignore synchronize, reopened, etc.\n if (context.payload.action !== 'opened') return;\n\n const actor = context.actor;\n const { owner, repo } = context.repo;\n\n // Payload inspection is more reliable than context.eventName for detecting\n // whether this is an issue vs. PR event — works regardless of how GitHub\n // propagates event names through workflow_call chains.\n const isIssue = !!context.payload.issue && !context.payload.pull_request;\n // listForRepo returns both issues and PRs (GitHub treats PRs as issues),\n // sorted newest-first. Filter by type to track first-issue and first-PR\n // independently, and avoid search-index eventual-consistency lag.\n const { data: recent } = await github.rest.issues.listForRepo({\n owner, repo,\n creator: actor,\n state: 'all',\n per_page: 100\n });\n\n const sameType = recent.filter(item =>\n isIssue ? !item.pull_request : !!item.pull_request\n );\n\n if (sameType.length !== 1) return;\n const body = isIssue\n ? `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.`\n : `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.`;\n\n await github.rest.issues.createComment({\n owner,\n repo,\n issue_number: context.issue.number,\n body\n });\n";
1116
+ //#endregion
1117
+ //#region src/templates/workflows/lint.yml
1118
+ var lint_default$1 = "name: Lint\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n prettier-config:\n type: string\n required: false\n default: prettier.config.js\n yaml-config:\n type: string\n required: false\n default: yamllint.config.yml\n enable-auto-commit:\n description: Auto-commit super-linter fixes via GPG-signed commit\n type: boolean\n required: false\n default: false\n secrets:\n SUPER_LINTER_GPG_PRIVATE_KEY:\n required: false\n SUPER_LINTER_GPG_PASSPHRASE:\n required: false\n\njobs:\n super-lint:\n name: Lint entire codebase\n permissions:\n contents: write\n statuses: write\n runs-on: ubuntu-latest\n timeout-minutes: 30\n env:\n GPG_KEY_SET: ${{ secrets.SUPER_LINTER_GPG_PRIVATE_KEY != '' }}\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n token: ${{ github.token }}\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n if: ${{ hashFiles('pnpm-lock.yaml') != '' }}\n\n - uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0\n name: Run Super Linter\n env:\n GITHUB_TOKEN: ${{ github.token }}\n DEFAULT_BRANCH: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}\n ANNOTATE_ONLY: true\n DISABLE_COMMENTS: false\n IGNORE_GITIGNORED_FILES: true\n LINTER_RULES_PATH: /\n EDITORCONFIG_FILE_NAME: \".editorconfig-checker.json\"\n FIX_ENV: true\n FIX_GRAPHQL_PRETTIER: true\n FIX_HTML_PRETTIER: true\n FIX_JAVASCRIPT_PRETTIER: true\n FIX_JSX_PRETTIER: true\n FIX_MARKDOWN_PRETTIER: true\n FIX_TSX: true\n FIX_TYPESCRIPT_PRETTIER: true\n PRETTIER_CONFIG: ${{ inputs.prettier-config }}\n VALIDATE_DOCKERFILE: true\n VALIDATE_EDITORCONFIG: true\n VALIDATE_ENV: true\n VALIDATE_GIT_COMMITLINT: true\n VALIDATE_GIT_MERGE_CONFLICT_MARKERS: true\n VALIDATE_GITHUB_ACTIONS: true\n VALIDATE_GITLEAKS: true\n VALIDATE_GRAPHQL_PRETTIER: true\n VALIDATE_HTML_PRETTIER: true\n VALIDATE_JAVASCRIPT_PRETTIER: true\n VALIDATE_JSX_PRETTIER: true\n VALIDATE_MARKDOWN_PRETTIER: true\n VALIDATE_TSX: true\n VALIDATE_TYPESCRIPT_PRETTIER: true\n VALIDATE_YAML: true\n YAML_CONFIG_FILE: ${{ inputs.yaml-config }}\n\n - uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0\n name: Import GPG Key\n # Conditions mirror auto-commit exactly — no point importing GPG if the\n # commit step will be skipped (fork PR, default branch, or secret unset).\n if: >\n inputs.enable-auto-commit == true &&\n github.event.pull_request != null &&\n github.event.pull_request.head.repo.full_name == github.repository &&\n github.ref_name != github.event.repository.default_branch &&\n env.GPG_KEY_SET == 'true'\n with:\n git_user_signingkey: true\n git_commit_gpgsign: true\n GPG_PRIVATE_KEY: ${{ secrets.SUPER_LINTER_GPG_PRIVATE_KEY }}\n PASSPHRASE: ${{ secrets.SUPER_LINTER_GPG_PASSPHRASE }}\n\n - uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0\n name: Commit and push linting fixes\n if: >\n inputs.enable-auto-commit == true &&\n github.event.pull_request != null &&\n github.event.pull_request.head.repo.full_name == github.repository &&\n github.ref_name != github.event.repository.default_branch &&\n env.GPG_KEY_SET == 'true'\n with:\n branch: ${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}\n commit_message: \"chore: fix linting issues\\n\\nSigned-off-by: super-linter <super-linter@super-linter.dev>\"\n commit_options: \"--no-verify\"\n commit_user_name: super-linter\n commit_user_email: super-linter@super-linter.dev\n";
1119
+ //#endregion
1120
+ //#region src/templates/workflows/release.yml
1121
+ var release_default$1 = "name: Release\n\n# Semantic-release with OIDC Trusted Publishing — no NPM_TOKEN required.\n# The calling repo must have a .releaserc.json that configures branches,\n# plugins, and any publish options. npm@11+ is installed to support OIDC.\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n run-build:\n description: Run `pnpm build` before releasing\n type: boolean\n required: false\n default: true\n secrets:\n RELEASE_TOKEN:\n description: >\n Fine-grained PAT (Contents + Issues + Pull requests: write) owned by\n an admin. Required when the default branch is protected by a ruleset —\n github.token cannot push through rulesets, but an admin PAT can.\n Takes priority over SYNC_TOKEN. Falls back to github.token.\n required: false\n SYNC_TOKEN:\n description: >\n Legacy alias for RELEASE_TOKEN — kept for backward compatibility.\n Prefer RELEASE_TOKEN for new repos.\n required: false\n\njobs:\n release:\n name: Semantic release\n permissions:\n contents: write\n id-token: write\n issues: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 30\n # Do not cancel in-progress releases — a partial release is worse than a slow one.\n concurrency:\n group: release-${{ github.ref }}\n cancel-in-progress: false\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n # Use SYNC_TOKEN when available — git push (tags, release commits)\n # uses the checkout credential, not GITHUB_TOKEN env var. The\n # built-in github.token cannot push through branch protection rulesets.\n token: ${{ secrets.RELEASE_TOKEN || secrets.SYNC_TOKEN || github.token }}\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - name: Configure git identity\n run: |\n GIT_NAME=$(gh api user --jq .name 2>/dev/null || echo \"github-actions[bot]\")\n GIT_EMAIL=$(gh api user --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"' 2>/dev/null || echo \"41898282+github-actions[bot]@users.noreply.github.com\")\n git config --global user.name \"$GIT_NAME\"\n git config --global user.email \"$GIT_EMAIL\"\n git config --global format.signoff true\n env:\n GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.SYNC_TOKEN || github.token }}\n\n - run: npm install -g npm@11 sigstore\n name: Upgrade npm for OIDC support\n # sigstore is required by libnpmpublish/provenance.js at module parse\n # time — before any config takes effect. Some npm 11.x builds stopped\n # bundling it; installing it globally into the same prefix ensures it\n # resolves regardless of npm version. (Discovered 2026-07-09.)\n\n - run: pnpm build\n name: Build\n if: ${{ inputs.run-build == true }}\n\n - run: npx semantic-release\n name: Release\n env:\n # Prefer RELEASE_TOKEN (fine-grained PAT, Contents+Issues+PRs write,\n # owned by an admin with ruleset bypass) so @semantic-release/git can\n # push the version-bump commit through branch protection. Falls back to\n # SYNC_TOKEN (legacy) then github.token for unprotected repos.\n GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.SYNC_TOKEN || github.token }}\n NPM_CONFIG_PROVENANCE: true\n";
1122
+ //#endregion
1123
+ //#region src/templates/workflows/review.yml
1124
+ var review_default$1 = "name: Review\n\n# ReviewDog is the annotation layer — posts inline PR diff annotations.\n# Runs on pull_request only: inline annotations require PR context,\n# and branch protection ensures all changes go through PRs anyway.\n# super-linter (lint.yml) is the CI gate covering push + PR events.\n# Gitleaks and YAML are intentionally duplicated: super-linter gates\n# merges; ReviewDog surfaces exact line annotations in the PR diff.\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n\nconcurrency:\n group: review-${{ github.workflow }}-${{ github.ref }}\n cancel-in-progress: true\n\njobs:\n reviewdog:\n name: Review PRs\n runs-on: ubuntu-latest\n timeout-minutes: 20\n permissions:\n contents: read\n checks: write\n pull-requests: write\n\n steps:\n - name: Checkout repository\n uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n with:\n fetch-depth: 0\n\n - name: Setup\n if: ${{ hashFiles('pnpm-lock.yaml') != '' }}\n uses: theholocron/.github/.github/actions/setup@main\n\n - name: Install ReviewDog\n uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1\n with:\n reviewdog_version: latest\n\n # Detect which tools are relevant for this repo, excluding node_modules.\n # hashFiles('**/*') recurses into node_modules/.pnpm and produces false\n # positives for repos that don't own those file types.\n # -print -quit stops find after the first match without a pipe, avoiding\n # the SIGPIPE/pipefail exit-141 that find|head-1 triggers under\n # GitHub Actions' default bash --noprofile --norc -e -o pipefail mode.\n - name: Detect project features\n id: detect\n shell: bash\n run: |\n has() { find . -not -path '*/node_modules/*' -name \"$1\" -print -quit 2>/dev/null | grep -q .; }\n has_ext() { find . -not -path '*/node_modules/*' -name \"$1\" -print -quit 2>/dev/null | grep -q .; }\n { { has 'eslint.config.js' || has 'eslint.config.mjs' || has 'eslint.config.cjs' || \\\n has 'eslint.config.ts' || has '.eslintrc' || has '.eslintrc.js' || \\\n has '.eslintrc.cjs' || has '.eslintrc.json' || has '.eslintrc.yaml' || \\\n has '.eslintrc.yml'; } && grep -qF '\"eslint\":' package.json 2>/dev/null; } && echo \"eslint=true\" >> \"$GITHUB_OUTPUT\" || echo \"eslint=false\" >> \"$GITHUB_OUTPUT\"\n { has 'tsconfig.json' && grep -qF '\"typescript\":' package.json 2>/dev/null; } && echo \"tsconfig=true\" >> \"$GITHUB_OUTPUT\" || echo \"tsconfig=false\" >> \"$GITHUB_OUTPUT\"\n has_ext '*.sh' && echo \"shell=true\" >> \"$GITHUB_OUTPUT\" || echo \"shell=false\" >> \"$GITHUB_OUTPUT\"\n has 'Dockerfile' || has_ext '*.Dockerfile' || has 'Containerfile' && \\\n echo \"docker=true\" >> \"$GITHUB_OUTPUT\" || echo \"docker=false\" >> \"$GITHUB_OUTPUT\"\n has_ext '.env*' && echo \"dotenv=true\" >> \"$GITHUB_OUTPUT\" || echo \"dotenv=false\" >> \"$GITHUB_OUTPUT\"\n has_ext '*.md' && echo \"markdown=true\" >> \"$GITHUB_OUTPUT\" || echo \"markdown=false\" >> \"$GITHUB_OUTPUT\"\n\n #\n # Always applicable\n #\n\n - name: Gitleaks (secrets)\n uses: reviewdog/action-gitleaks@2b7b5685e3e3eecddab5d30cfa04f18123031421 # v1\n with:\n reporter: github-pr-check\n tool_name: \"Review / gitleaks\"\n gitleaks_flags: --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}\n\n - name: YamlLint\n if: ${{ hashFiles('yamllint.config.yml') != '' }}\n uses: reviewdog/action-yamllint@b5f7217d8c815ae374d1d55840d5e569d82f01f0 # v1\n with:\n reporter: github-pr-check\n tool_name: \"Review / yamllint\"\n yamllint_flags: -c ${{ github.workspace }}/yamllint.config.yml ${{ github.workspace }}\n\n - name: ActionLint (GitHub Actions)\n if: ${{ hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}\n uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1\n with:\n reporter: github-pr-check\n tool_name: \"Review / actionlint\"\n\n #\n # TypeScript / JavaScript\n #\n\n - name: ESLint\n if: steps.detect.outputs.eslint == 'true'\n uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0\n with:\n reporter: github-pr-check\n tool_name: \"Review / eslint\"\n eslint_flags: .\n\n - name: TypeScript\n if: steps.detect.outputs.tsconfig == 'true'\n uses: EPMatt/reviewdog-action-tsc@63d923a3c5b4497671940b8874f58a404e2351b5 # v1\n with:\n reporter: github-pr-check\n tool_name: \"Review / tsc\"\n\n #\n # Shell\n #\n\n - name: ShellCheck\n if: steps.detect.outputs.shell == 'true'\n uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1\n with:\n reporter: github-pr-check\n tool_name: \"Review / shellcheck\"\n fail_level: none\n\n #\n # Docker\n #\n\n - name: Hadolint\n if: steps.detect.outputs.docker == 'true'\n uses: reviewdog/action-hadolint@1b2cfa6ba72072ad35158d7ff3aa49bbdc03506d # v1\n with:\n reporter: github-pr-check\n tool_name: \"Review / hadolint\"\n fail_level: none\n\n #\n # Environment files\n #\n\n - name: dotenv-linter\n if: steps.detect.outputs.dotenv == 'true'\n uses: dotenv-linter/action-dotenv-linter@afde61cfda2ecffe7bea35837b6f20b956c88689 # v3.0.0\n with:\n reporter: github-code-suggestions\n\n #\n # Documentation\n #\n\n - name: Alex (inclusive language)\n if: steps.detect.outputs.markdown == 'true'\n uses: reviewdog/action-alex@347481655add010a2ae302df34b57c9bcfa0d6e4 # v1\n with:\n reporter: github-pr-check\n tool_name: \"Review / alex\"\n";
1125
+ //#endregion
1126
+ //#region src/templates/workflows/stale.yml
1127
+ var stale_default$1 = "name: Stale\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n days-before-stale:\n description: Days of inactivity before an issue is marked stale\n type: number\n required: false\n default: 30\n days-before-close:\n description: Days of inactivity after stale label before closing\n type: number\n required: false\n default: 5\n\njobs:\n stale:\n name: Mark stale issues and pull requests\n permissions:\n contents: write\n issues: write\n pull-requests: write\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0\n name: Run Stale\n with:\n close-issue-message: >\n This issue was closed because it has been stalled for\n ${{ inputs.days-before-close }} days with no activity.\n days-before-close: ${{ inputs.days-before-close }}\n days-before-stale: ${{ inputs.days-before-stale }}\n exempt-all-pr-milestones: true\n stale-issue-label: wontfix\n stale-issue-message: >\n This issue is stale because it has been open ${{ inputs.days-before-stale }}\n days with no activity. Remove the stale label or comment, or this will be\n closed in ${{ inputs.days-before-close }} days.\n stale-pr-label: wontfix\n stale-pr-message: >\n This PR is stale because it has been open ${{ inputs.days-before-stale }}\n days with no activity. Remove the stale label or comment, or this will be\n closed in ${{ inputs.days-before-close }} days.\n";
1128
+ //#endregion
1129
+ //#region src/templates/workflows/sync-github.yml
1130
+ var sync_github_default$1 = "name: Sync GitHub Templates\n\n# Builds the holocron CLI from source and pushes updated workflow templates\n# and composite actions to downstream .github repos. Runs whenever the\n# template source files change on main or alpha.\n#\n# Secrets required:\n# SYNC_TOKEN — fine-grained PAT (resource owner: org) with:\n# Contents: Read and write (git trees, blobs, refs)\n# Pull requests: Read and write (open sync PR)\n# Workflows: Read and write (write .github/workflows/*.yml)\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n inputs:\n primary-repo:\n description: >\n Primary .github repo — receives composite actions, reusable workflows,\n and thin-caller templates. Requires a PR (branch protection assumed).\n type: string\n required: false\n default: theholocron/.github\n secondary-repos:\n description: >\n Space-separated list of secondary repos (reusable workflows + thin\n callers only, no composite actions). Changes are delivered via pull\n request, same as the primary repo.\n type: string\n required: false\n default: \"\"\n sync-branch:\n description: Branch name used for the primary and secondary repo PRs\n type: string\n required: false\n default: chore/sync-templates\n secrets:\n SYNC_TOKEN:\n required: true\n\njobs:\n sync:\n name: Sync templates\n runs-on: ubuntu-latest\n timeout-minutes: 15\n permissions:\n contents: read\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: pnpm build\n name: Build CLI\n\n - name: Validate generated workflows\n run: |\n node packages/cli/dist/cli.mjs sync-github \\\n --repo \"$PRIMARY_REPO\" \\\n --output-dir /tmp/sync-validate\n curl -fsSL https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz \\\n | tar -xz -C /tmp actionlint\n /tmp/actionlint /tmp/sync-validate/.github/workflows/*.yml\n env:\n PRIMARY_REPO: ${{ inputs.primary-repo }}\n\n - name: Sync primary repo (PR)\n run: |\n GIT_NAME=$(gh api user --jq .name 2>/dev/null || echo \"github-actions[bot]\")\n GIT_EMAIL=$(gh api user --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"' 2>/dev/null || echo \"41898282+github-actions[bot]@users.noreply.github.com\")\n COMMIT_MSG=\"chore: sync from theholocron/holocron\"$'\\n\\n'\"Signed-off-by: $GIT_NAME <$GIT_EMAIL>\"\n node packages/cli/dist/cli.mjs sync-github \\\n --repo \"$PRIMARY_REPO\" \\\n --branch \"$SYNC_BRANCH\" \\\n --pr \\\n --message \"$COMMIT_MSG\"\n env:\n GITHUB_TOKEN: ${{ secrets.SYNC_TOKEN }}\n GH_TOKEN: ${{ secrets.SYNC_TOKEN }}\n PRIMARY_REPO: ${{ inputs.primary-repo }}\n SYNC_BRANCH: ${{ inputs.sync-branch }}\n\n - name: Sync secondary repos (PR)\n if: ${{ inputs.secondary-repos != '' }}\n run: |\n GIT_NAME=$(gh api user --jq .name 2>/dev/null || echo \"github-actions[bot]\")\n GIT_EMAIL=$(gh api user --jq '\"\\(.id)+\\(.login)@users.noreply.github.com\"' 2>/dev/null || echo \"41898282+github-actions[bot]@users.noreply.github.com\")\n COMMIT_MSG=\"chore: sync from theholocron/holocron\"$'\\n\\n'\"Signed-off-by: $GIT_NAME <$GIT_EMAIL>\"\n for repo in $SECONDARY_REPOS; do\n node packages/cli/dist/cli.mjs sync-github \\\n --repo \"$repo\" \\\n --branch \"$SYNC_BRANCH\" \\\n --pr \\\n --message \"$COMMIT_MSG\"\n done\n env:\n GITHUB_TOKEN: ${{ secrets.SYNC_TOKEN }}\n GH_TOKEN: ${{ secrets.SYNC_TOKEN }}\n SECONDARY_REPOS: ${{ inputs.secondary-repos }}\n SYNC_BRANCH: ${{ inputs.sync-branch }}\n";
1131
+ //#endregion
1132
+ //#region src/templates/workflows/test.yml
1133
+ var test_default$1 = "name: Test\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n secrets:\n CODECOV_TOKEN:\n required: false\n\njobs:\n unit:\n name: Run tests and collect coverage\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 15\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n with:\n fetch-depth: 0\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: pnpm test -- --coverage\n name: Run tests with coverage\n\n - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0\n name: Upload coverage to Codecov\n with:\n token: ${{ secrets.CODECOV_TOKEN }}\n\n - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1\n name: Upload test results to Codecov\n if: ${{ !cancelled() }}\n with:\n token: ${{ secrets.CODECOV_TOKEN }}\n files: '**/test-report.junit.xml'\n";
1134
+ //#endregion
1135
+ //#region src/templates/workflows/typecheck.yml
1136
+ var typecheck_default$1 = "name: Typecheck\n\non: # yamllint disable-line rule:truthy\n workflow_call:\n\njobs:\n typecheck:\n name: tsc --noEmit\n permissions:\n contents: read\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0\n name: Checkout repository\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: pnpm typecheck\n name: Type check\n";
1137
+ //#endregion
449
1138
  //#region src/templates/index.ts
450
1139
  /**
451
1140
  * All reusable workflow and composite action content bundled as string
452
1141
  * constants so the CLI can push them to theholocron/.github without
453
1142
  * needing filesystem access at runtime.
454
1143
  *
455
- * These are the sources of truth the YAML files in this directory
456
- * have been removed in favour of these constants.
1144
+ * Content lives in standalone .yml files; the rawYml rollup plugin
1145
+ * inlines them as string exports at build time.
457
1146
  */
458
1147
  const ACTIONS = {
459
- "setup/action": `\
460
- name: Setup
461
- description: Prepare the environment and install project dependencies.
462
-
463
- inputs:
464
- node-version:
465
- description: Node.js version
466
- required: false
467
- default: "22.x"
468
-
469
- pnpm-version:
470
- description: pnpm version
471
- required: false
472
- default: "10"
473
-
474
- runs:
475
- using: composite
476
-
477
- steps:
478
- - uses: ./.github/actions/setup-node
479
- with:
480
- node-version: \${{ inputs.node-version }}
481
- pnpm-version: \${{ inputs.pnpm-version }}
482
-
483
- - uses: ./.github/actions/install
484
- `,
485
- "install/action": `\
486
- name: Install dependencies
487
- description: Install project dependencies with pnpm frozen lockfile.
488
-
489
- runs:
490
- using: composite
491
-
492
- steps:
493
- - name: Install dependencies
494
- shell: bash
495
- run: pnpm install --frozen-lockfile
496
- `,
497
- "setup-node/action": `\
498
- name: Setup Node
499
- description: Install pnpm and Node.js with pnpm dependency caching.
500
-
501
- inputs:
502
- node-version:
503
- description: Node.js version
504
- required: false
505
- default: "22.x"
506
-
507
- pnpm-version:
508
- description: pnpm version
509
- required: false
510
- default: "10"
511
-
512
- runs:
513
- using: composite
514
-
515
- steps:
516
- - name: Setup pnpm
517
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
518
- with:
519
- version: \${{ inputs.pnpm-version }}
520
-
521
- - name: Setup Node.js
522
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
523
- with:
524
- node-version: \${{ inputs.node-version }}
525
- cache: pnpm
526
- `
1148
+ "setup/action": setup_default,
1149
+ "install/action": install_default,
1150
+ "setup-node/action": setup_node_default
527
1151
  };
528
1152
  const REUSABLE_WORKFLOWS = {
529
- audit: `\
530
- name: Audit
531
-
532
- on: # yamllint disable-line rule:truthy
533
- workflow_call:
534
- inputs:
535
- build-script:
536
- description: Script to build before analyzing bundle size
537
- type: string
538
- required: false
539
- default: pnpm build
540
- secrets:
541
- BUNDLEWATCH_GITHUB_TOKEN:
542
- required: true
543
-
544
- jobs:
545
- bundle-size:
546
- name: Audit the bundle size
547
- permissions:
548
- contents: read
549
- runs-on: ubuntu-latest
550
- timeout-minutes: 15
551
- concurrency:
552
- group: audit-\${{ github.ref }}
553
- cancel-in-progress: true
554
- steps:
555
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
556
- name: Checkout repository
557
- with:
558
- fetch-depth: 0
559
-
560
- - uses: theholocron/.github/.github/actions/setup@main
561
- name: Setup
562
-
563
- - uses: jackyef/bundlewatch-gh-action@01f51133d3580a6daa046ca83eb233d79735e1c1 # 0.3.0
564
- name: Analyze using BundleWatch
565
- with:
566
- build-script: \${{ inputs.build-script }}
567
- bundlewatch-github-token: \${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
568
- `,
569
- "bookkeeping-pr": `\
570
- name: PR Bookkeeping
571
-
572
- on: # yamllint disable-line rule:truthy
573
- workflow_call:
574
- inputs:
575
- configuration-path:
576
- description: Path to the labeler configuration file in the calling repo
577
- type: string
578
- required: false
579
- default: .github/labeler.yml
580
-
581
- jobs:
582
- label:
583
- name: Add Labels to PRs
584
- permissions:
585
- contents: read
586
- pull-requests: write
587
- runs-on: ubuntu-latest
588
- timeout-minutes: 5
589
- concurrency:
590
- group: bookkeeping-pr-\${{ github.event.pull_request.number }}
591
- cancel-in-progress: true
592
- steps:
593
- - uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
594
- with:
595
- # Fall back to default path when triggered directly (not via workflow_call)
596
- # because inputs.* defaults only apply on workflow_call events.
597
- configuration-path: \${{ inputs.configuration-path || '.github/labeler.yml' }}
598
- include-title: 1
599
- include-body: 0
600
- sync-labels: 1
601
- enable-versioned-regex: 0
602
- repo-token: \${{ github.token }}
603
- `,
604
- codeql: `\
605
- name: CodeQL
606
-
607
- on: # yamllint disable-line rule:truthy
608
- workflow_call:
609
- inputs:
610
- language:
611
- description: CodeQL language to analyze
612
- type: string
613
- required: false
614
- default: javascript-typescript
615
-
616
- jobs:
617
- analyze:
618
- name: Analyze (\${{ inputs.language }})
619
- permissions:
620
- actions: read
621
- contents: read
622
- security-events: write
623
- runs-on: ubuntu-latest
624
- timeout-minutes: 45
625
- # Do not cancel in-progress security scans.
626
- concurrency:
627
- group: codeql-\${{ github.ref }}
628
- cancel-in-progress: false
629
- steps:
630
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
631
- name: Checkout repository
632
-
633
- - uses: github/codeql-action/init@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
634
- name: Initialize CodeQL
635
- with:
636
- languages: \${{ inputs.language }}
637
-
638
- - uses: github/codeql-action/autobuild@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
639
- name: Autobuild
640
-
641
- - uses: github/codeql-action/analyze@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
642
- name: Analyze
643
- with:
644
- category: /language:\${{ inputs.language }}
645
- `,
646
- dependencies: `\
647
- name: Dependencies
648
-
649
- on: # yamllint disable-line rule:truthy
650
- workflow_call:
651
- secrets:
652
- merge-token:
653
- description: >
654
- Optional privileged token for auto-merge. Falls back to GITHUB_TOKEN.
655
- Required when branch protection enforces required reviews — GITHUB_TOKEN
656
- cannot approve its own PRs.
657
- required: false
658
-
659
- jobs:
660
- dependabot:
661
- name: Update the dependencies
662
- permissions:
663
- contents: write
664
- pull-requests: write
665
- runs-on: ubuntu-latest
666
- timeout-minutes: 5
667
- concurrency:
668
- group: dependencies-\${{ github.event.pull_request.number }}
669
- cancel-in-progress: true
670
- if: github.event.pull_request.user.login == 'dependabot[bot]'
671
- steps:
672
- - uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2
673
- name: Fetch Dependabot metadata
674
- id: metadata
675
-
676
- - run: gh pr merge --auto --squash "$PR_URL"
677
- # --squash is intentional: repoPolicy sets allow_merge_commit: false,
678
- # so --merge would fail on any repo using the standard preset.
679
- name: Enable auto-merge for Dependabot PRs
680
- if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
681
- env:
682
- PR_URL: \${{ github.event.pull_request.html_url }}
683
- GH_TOKEN: \${{ secrets.merge-token || github.token }}
684
- `,
685
- greetings: `\
686
- name: Greetings
687
-
688
- on: # yamllint disable-line rule:truthy
689
- workflow_call:
690
-
691
- jobs:
692
- greeting:
693
- name: Greet first-time contributors
694
- permissions:
695
- issues: write
696
- pull-requests: write
697
- runs-on: ubuntu-latest
698
- timeout-minutes: 5
699
- # Group by the issue/PR number so duplicate events don't race each other.
700
- concurrency:
701
- group: greetings-\${{ github.event.issue.number || github.event.pull_request.number }}
702
- cancel-in-progress: false
703
- steps:
704
- - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
705
- name: Greet on first contribution
706
- with:
707
- script: |
708
- // Only greet on the initial open — ignore synchronize, reopened, etc.
709
- if (context.payload.action !== 'opened') return;
710
-
711
- const actor = context.actor;
712
- const { owner, repo } = context.repo;
713
-
714
- // Payload inspection is more reliable than context.eventName for detecting
715
- // whether this is an issue vs. PR event — works regardless of how GitHub
716
- // propagates event names through workflow_call chains.
717
- const isIssue = !!context.payload.issue && !context.payload.pull_request;
718
- // listForRepo returns both issues and PRs (GitHub treats PRs as issues),
719
- // sorted newest-first. Filter by type to track first-issue and first-PR
720
- // independently, and avoid search-index eventual-consistency lag.
721
- const { data: recent } = await github.rest.issues.listForRepo({
722
- owner, repo,
723
- creator: actor,
724
- state: 'all',
725
- per_page: 100
726
- });
727
-
728
- const sameType = recent.filter(item =>
729
- isIssue ? !item.pull_request : !!item.pull_request
730
- );
731
-
732
- if (sameType.length !== 1) return;
733
- const body = isIssue
734
- ? \`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.\`
735
- : \`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.\`;
736
-
737
- await github.rest.issues.createComment({
738
- owner,
739
- repo,
740
- issue_number: context.issue.number,
741
- body
742
- });
743
- `,
744
- lint: `\
745
- name: Lint
746
-
747
- on: # yamllint disable-line rule:truthy
748
- workflow_call:
749
- inputs:
750
- prettier-config:
751
- type: string
752
- required: false
753
- default: prettier.config.js
754
- yaml-config:
755
- type: string
756
- required: false
757
- default: yamllint.config.yml
758
- enable-auto-commit:
759
- description: Auto-commit super-linter fixes via GPG-signed commit
760
- type: boolean
761
- required: false
762
- default: false
763
- secrets:
764
- SUPER_LINTER_GPG_PRIVATE_KEY:
765
- required: false
766
- SUPER_LINTER_GPG_PASSPHRASE:
767
- required: false
768
-
769
- jobs:
770
- super-lint:
771
- name: Lint entire codebase
772
- permissions:
773
- contents: write
774
- statuses: write
775
- runs-on: ubuntu-latest
776
- timeout-minutes: 30
777
- steps:
778
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
779
- name: Checkout repository
780
- with:
781
- fetch-depth: 0
782
- token: \${{ github.token }}
783
-
784
- - uses: theholocron/.github/.github/actions/setup@main
785
- name: Setup
786
-
787
- - uses: super-linter/super-linter/slim@b92721f792f381cedc002ecdbb9847a15ece5bb8 # v7.1.0
788
- name: Run Super Linter
789
- env:
790
- GITHUB_TOKEN: \${{ github.token }}
791
- ANNOTATE_ONLY: true
792
- DISABLE_COMMENTS: false
793
- IGNORE_GITIGNORED_FILES: true
794
- LINTER_RULES_PATH: /
795
- EDITORCONFIG_FILE_NAME: ".editorconfig-checker.json"
796
- FIX_ENV: true
797
- FIX_GRAPHQL_PRETTIER: true
798
- FIX_HTML_PRETTIER: true
799
- FIX_JAVASCRIPT_PRETTIER: true
800
- FIX_JSX_PRETTIER: true
801
- FIX_MARKDOWN_PRETTIER: true
802
- FIX_TSX: true
803
- FIX_TYPESCRIPT_PRETTIER: true
804
- PRETTIER_CONFIG: \${{ inputs.prettier-config }}
805
- VALIDATE_DOCKERFILE: true
806
- VALIDATE_EDITORCONFIG: true
807
- VALIDATE_ENV: true
808
- VALIDATE_GIT_COMMITLINT: true
809
- VALIDATE_GIT_MERGE_CONFLICT_MARKERS: true
810
- VALIDATE_GITHUB_ACTIONS: true
811
- VALIDATE_GITLEAKS: true
812
- VALIDATE_GRAPHQL_PRETTIER: true
813
- VALIDATE_HTML_PRETTIER: true
814
- VALIDATE_JAVASCRIPT_PRETTIER: true
815
- VALIDATE_JSX_PRETTIER: true
816
- VALIDATE_MARKDOWN_PRETTIER: true
817
- VALIDATE_TSX: true
818
- VALIDATE_TYPESCRIPT_PRETTIER: true
819
- VALIDATE_YAML: true
820
- YAML_CONFIG_FILE: \${{ inputs.yaml-config }}
821
-
822
- - uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
823
- name: Import GPG Key
824
- # Conditions mirror auto-commit exactly — no point importing GPG if the
825
- # commit step will be skipped (fork PR, default branch, or secret unset).
826
- if: >
827
- inputs.enable-auto-commit == true &&
828
- github.event.pull_request != null &&
829
- github.event.pull_request.head.repo.full_name == github.repository &&
830
- github.ref_name != github.event.repository.default_branch &&
831
- secrets.SUPER_LINTER_GPG_PRIVATE_KEY != ''
832
- with:
833
- git_user_signingkey: true
834
- git_commit_gpgsign: true
835
- GPG_PRIVATE_KEY: \${{ secrets.SUPER_LINTER_GPG_PRIVATE_KEY }}
836
- PASSPHRASE: \${{ secrets.SUPER_LINTER_GPG_PASSPHRASE }}
837
-
838
- - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5
839
- name: Commit and push linting fixes
840
- if: >
841
- inputs.enable-auto-commit == true &&
842
- github.event.pull_request != null &&
843
- github.event.pull_request.head.repo.full_name == github.repository &&
844
- github.ref_name != github.event.repository.default_branch &&
845
- secrets.SUPER_LINTER_GPG_PRIVATE_KEY != ''
846
- with:
847
- branch: \${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}
848
- commit_message: "chore: fix linting issues"
849
- commit_options: "--no-verify --signoff"
850
- commit_user_name: super-linter
851
- commit_user_email: super-linter@super-linter.dev
852
- `,
853
- release: `\
854
- name: Release
855
-
856
- # Semantic-release with OIDC Trusted Publishing — no NPM_TOKEN required.
857
- # The calling repo must have a .releaserc.json that configures branches,
858
- # plugins, and any publish options. npm@11+ is installed to support OIDC.
859
-
860
- on: # yamllint disable-line rule:truthy
861
- workflow_call:
862
- inputs:
863
- run-build:
864
- description: Run \`pnpm build\` before releasing
865
- type: boolean
866
- required: false
867
- default: true
868
-
869
- jobs:
870
- release:
871
- name: Semantic release
872
- permissions:
873
- contents: write
874
- id-token: write
875
- issues: write
876
- pull-requests: write
877
- runs-on: ubuntu-latest
878
- timeout-minutes: 30
879
- # Do not cancel in-progress releases — a partial release is worse than a slow one.
880
- concurrency:
881
- group: release-\${{ github.ref }}
882
- cancel-in-progress: false
883
- steps:
884
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
885
- name: Checkout repository
886
- with:
887
- fetch-depth: 0
888
- token: \${{ github.token }}
889
-
890
- - uses: theholocron/.github/.github/actions/setup@main
891
- name: Setup
892
-
893
- - run: npm install -g npm@11 sigstore
894
- name: Upgrade npm for OIDC support
895
- # sigstore is required by libnpmpublish/provenance.js at module parse
896
- # time — before any config takes effect. Some npm 11.x builds stopped
897
- # bundling it; installing it globally into the same prefix ensures it
898
- # resolves regardless of npm version. (Discovered 2026-07-09.)
899
-
900
- - run: pnpm build
901
- name: Build
902
- if: \${{ inputs.run-build == true }}
903
-
904
- - run: npx semantic-release
905
- name: Release
906
- env:
907
- GITHUB_TOKEN: \${{ github.token }}
908
- NPM_CONFIG_PROVENANCE: true
909
- `,
910
- review: `\
911
- name: Review
912
-
913
- # ReviewDog is the annotation layer — posts inline PR diff annotations.
914
- # Runs on pull_request only: inline annotations require PR context,
915
- # and branch protection ensures all changes go through PRs anyway.
916
- # super-linter (lint.yml) is the CI gate covering push + PR events.
917
- # Gitleaks and YAML are intentionally duplicated: super-linter gates
918
- # merges; ReviewDog surfaces exact line annotations in the PR diff.
919
-
920
- on: # yamllint disable-line rule:truthy
921
- workflow_call:
922
-
923
- concurrency:
924
- group: review-\${{ github.workflow }}-\${{ github.ref }}
925
- cancel-in-progress: true
926
-
927
- jobs:
928
- reviewdog:
929
- name: Review PRs
930
- runs-on: ubuntu-latest
931
- timeout-minutes: 20
932
- permissions:
933
- contents: read
934
- checks: write
935
- pull-requests: write
936
-
937
- steps:
938
- - name: Checkout repository
939
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
940
-
941
- - name: Setup
942
- if: \${{ hashFiles('pnpm-lock.yaml') != '' }}
943
- uses: theholocron/.github/.github/actions/setup@main
944
-
945
- - name: Install ReviewDog
946
- uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1
947
- with:
948
- reviewdog_version: latest
949
-
950
- #
951
- # Always applicable
952
- #
953
-
954
- - name: Gitleaks (secrets)
955
- uses: reviewdog/action-gitleaks@2b7b5685e3e3eecddab5d30cfa04f18123031421 # v1
956
- with:
957
- reporter: github-pr-check
958
-
959
- - name: YamlLint
960
- uses: reviewdog/action-yamllint@b5f7217d8c815ae374d1d55840d5e569d82f01f0 # v1
961
- with:
962
- reporter: github-pr-check
963
- yamllint_flags: >-
964
- \${{ hashFiles('yamllint.config.yml') != ''
965
- && format('-c {0}/yamllint.config.yml {0}', github.workspace)
966
- || github.workspace }}
967
-
968
- - name: ActionLint (GitHub Actions)
969
- if: \${{ hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}
970
- uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1
971
- with:
972
- reporter: github-pr-check
973
-
974
- #
975
- # TypeScript / JavaScript
976
- #
977
-
978
- - name: ESLint
979
- if: >-
980
- \${{
981
- hashFiles(
982
- '**/eslint.config.js',
983
- '**/eslint.config.mjs',
984
- '**/eslint.config.cjs',
985
- '**/eslint.config.ts',
986
- '**/.eslintrc',
987
- '**/.eslintrc.js',
988
- '**/.eslintrc.cjs',
989
- '**/.eslintrc.json',
990
- '**/.eslintrc.yaml',
991
- '**/.eslintrc.yml'
992
- ) != ''
993
- }}
994
- uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1
995
- with:
996
- reporter: github-pr-check
997
- eslint_flags: .
998
-
999
- - name: TypeScript
1000
- if: \${{ hashFiles('**/tsconfig.json') != '' }}
1001
- uses: EPMatt/reviewdog-action-tsc@63d923a3c5b4497671940b8874f58a404e2351b5 # v1
1002
- with:
1003
- reporter: github-pr-check
1004
-
1005
- #
1006
- # Shell
1007
- #
1008
-
1009
- - name: ShellCheck
1010
- if: \${{ hashFiles('**/*.sh') != '' }}
1011
- uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1
1012
- with:
1013
- reporter: github-pr-check
1014
- fail_level: none
1015
-
1016
- #
1017
- # Docker
1018
- #
1019
-
1020
- - name: Hadolint
1021
- if: >-
1022
- \${{
1023
- hashFiles(
1024
- '**/Dockerfile',
1025
- '**/*.Dockerfile',
1026
- '**/Containerfile'
1027
- ) != ''
1028
- }}
1029
- uses: reviewdog/action-hadolint@1b2cfa6ba72072ad35158d7ff3aa49bbdc03506d # v1
1030
- with:
1031
- reporter: github-pr-check
1032
- fail_level: none
1033
-
1034
- #
1035
- # Environment files
1036
- #
1037
-
1038
- - name: dotenv-linter
1039
- if: \${{ hashFiles('**/.env*') != '' }}
1040
- uses: dotenv-linter/action-dotenv-linter@21287e2624aaf2dc8da5dd8ccfe8e49c63501116 # v2
1041
- with:
1042
- reporter: github-code-suggestions
1043
-
1044
- #
1045
- # Documentation
1046
- #
1047
-
1048
- - name: Alex (inclusive language)
1049
- if: \${{ hashFiles('**/*.md') != '' }}
1050
- uses: reviewdog/action-alex@347481655add010a2ae302df34b57c9bcfa0d6e4 # v1
1051
- with:
1052
- reporter: github-pr-check
1053
- `,
1054
- stale: `\
1055
- name: Stale
1056
-
1057
- on: # yamllint disable-line rule:truthy
1058
- workflow_call:
1059
- inputs:
1060
- days-before-stale:
1061
- description: Days of inactivity before an issue is marked stale
1062
- type: number
1063
- required: false
1064
- default: 30
1065
- days-before-close:
1066
- description: Days of inactivity after stale label before closing
1067
- type: number
1068
- required: false
1069
- default: 5
1070
-
1071
- jobs:
1072
- stale:
1073
- name: Mark stale issues and pull requests
1074
- permissions:
1075
- contents: write
1076
- issues: write
1077
- pull-requests: write
1078
- runs-on: ubuntu-latest
1079
- timeout-minutes: 10
1080
- steps:
1081
- - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
1082
- name: Run Stale
1083
- with:
1084
- close-issue-message: >
1085
- This issue was closed because it has been stalled for
1086
- \${{ inputs.days-before-close }} days with no activity.
1087
- days-before-close: \${{ inputs.days-before-close }}
1088
- days-before-stale: \${{ inputs.days-before-stale }}
1089
- exempt-all-pr-milestones: true
1090
- stale-issue-label: wontfix
1091
- stale-issue-message: >
1092
- This issue is stale because it has been open \${{ inputs.days-before-stale }}
1093
- days with no activity. Remove the stale label or comment, or this will be
1094
- closed in \${{ inputs.days-before-close }} days.
1095
- stale-pr-label: wontfix
1096
- stale-pr-message: >
1097
- This PR is stale because it has been open \${{ inputs.days-before-stale }}
1098
- days with no activity. Remove the stale label or comment, or this will be
1099
- closed in \${{ inputs.days-before-close }} days.
1100
- `,
1101
- test: `\
1102
- name: Test
1103
-
1104
- on: # yamllint disable-line rule:truthy
1105
- workflow_call:
1106
- secrets:
1107
- CODECOV_TOKEN:
1108
- required: false
1109
-
1110
- jobs:
1111
- unit:
1112
- name: Run tests and collect coverage
1113
- permissions:
1114
- contents: read
1115
- runs-on: ubuntu-latest
1116
- timeout-minutes: 15
1117
- steps:
1118
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1119
- name: Checkout repository
1120
- with:
1121
- fetch-depth: 0
1122
-
1123
- - uses: theholocron/.github/.github/actions/setup@main
1124
- name: Setup
1125
-
1126
- - run: pnpm test -- --coverage
1127
- name: Run tests with coverage
1128
-
1129
- - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
1130
- name: Upload results to Codecov
1131
- with:
1132
- token: \${{ secrets.CODECOV_TOKEN }}
1133
- `,
1134
- typecheck: `\
1135
- name: Typecheck
1136
-
1137
- on: # yamllint disable-line rule:truthy
1138
- workflow_call:
1139
-
1140
- jobs:
1141
- typecheck:
1142
- name: tsc --noEmit
1143
- permissions:
1144
- contents: read
1145
- runs-on: ubuntu-latest
1146
- timeout-minutes: 10
1147
- steps:
1148
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1149
- name: Checkout repository
1150
-
1151
- - uses: theholocron/.github/.github/actions/setup@main
1152
- name: Setup
1153
-
1154
- - run: pnpm typecheck
1155
- name: Type check
1156
- `
1153
+ audit: audit_default$1,
1154
+ bookkeeping: bookkeeping_default$1,
1155
+ codeql: codeql_default$1,
1156
+ dependencies: dependencies_default$1,
1157
+ greetings: greetings_default$1,
1158
+ lint: lint_default$1,
1159
+ release: release_default$1,
1160
+ review: review_default$1,
1161
+ stale: stale_default$1,
1162
+ "sync-github": sync_github_default$1,
1163
+ test: test_default$1,
1164
+ typecheck: typecheck_default$1
1157
1165
  };
1166
+ const WORKFLOW_TEMPLATE_PROPERTIES = {
1167
+ bookkeeping: JSON.stringify({
1168
+ name: "Bookkeeping",
1169
+ description: "Label and track issues and pull requests.",
1170
+ iconName: "octicon tag"
1171
+ }, null, 2),
1172
+ "sync-github": JSON.stringify({
1173
+ name: "Sync GitHub Templates",
1174
+ description: "Sync workflow templates and composite actions from the holocron CLI.",
1175
+ iconName: "octicon sync"
1176
+ }, null, 2)
1177
+ };
1178
+ //#endregion
1179
+ //#region src/commands/dependabot.yml
1180
+ var dependabot_default = "# AUTO-GENERATED by holocron — run `holocron setup` to regenerate.\nversion: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: weekly\n groups:\n security-patches:\n applies-to: security-updates\n patterns:\n - \"*\"\n all-dependencies:\n update-types:\n - minor\n - patch\n\n - package-ecosystem: github-actions\n directory: /\n schedule:\n interval: weekly\n groups:\n all-actions:\n patterns:\n - \"*\"\n";
1181
+ //#endregion
1182
+ //#region src/commands/workflows/audit.yml
1183
+ var audit_default = "name: Audit\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\npermissions:\n contents: read\n\njobs:\n audit:\n uses: theholocron/.github/.github/workflows/audit.yml@main\n secrets: inherit\n";
1184
+ //#endregion
1185
+ //#region src/commands/workflows/bookkeeping.yml
1186
+ var bookkeeping_default = "name: Bookkeeping\n\non: # yamllint disable-line rule:truthy\n pull_request:\n types:\n - opened\n - edited\n\npermissions:\n contents: read\n pull-requests: write\n\njobs:\n bookkeeping:\n uses: theholocron/.github/.github/workflows/bookkeeping.yml@main\n secrets: inherit\n";
1187
+ //#endregion
1188
+ //#region src/commands/workflows/codeql.yml
1189
+ var codeql_default = "name: CodeQL\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n pull_request:\n branches:\n - main\n schedule:\n - cron: \"0 0 * * 1\"\n\npermissions:\n actions: read\n contents: read\n security-events: write\n\njobs:\n codeql:\n uses: theholocron/.github/.github/workflows/codeql.yml@main\n secrets: inherit\n";
1190
+ //#endregion
1191
+ //#region src/commands/workflows/dependencies.yml
1192
+ var dependencies_default = "name: Dependencies\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n dependencies:\n uses: theholocron/.github/.github/workflows/dependencies.yml@main\n secrets: inherit\n";
1193
+ //#endregion
1194
+ //#region src/commands/workflows/greetings.yml
1195
+ var greetings_default = "name: Greetings\n\non: # yamllint disable-line rule:truthy\n pull_request:\n issues:\n\npermissions:\n issues: write\n pull-requests: write\n\njobs:\n greetings:\n uses: theholocron/.github/.github/workflows/greetings.yml@main\n secrets: inherit\n";
1196
+ //#endregion
1197
+ //#region src/commands/workflows/lint.yml
1198
+ var lint_default = "name: Lint\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: lint-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: write\n statuses: write\n\njobs:\n lint:\n name: Lint\n uses: theholocron/.github/.github/workflows/lint.yml@main\n secrets: inherit\n with:\n enable-auto-commit: true\n";
1199
+ //#endregion
1200
+ //#region src/commands/workflows/release.yml
1201
+ var release_default = "name: Release\n\non: # yamllint disable-line rule:truthy\n push:\n branches:\n - main\n - alpha\n workflow_dispatch:\n\npermissions:\n contents: write\n id-token: write\n issues: write\n pull-requests: write\n\nconcurrency:\n group: ${{ github.workflow }}-${{ github.ref }}\n cancel-in-progress: false\n\njobs:\n release:\n uses: theholocron/.github/.github/workflows/release.yml@main\n secrets: inherit\n";
1202
+ //#endregion
1203
+ //#region src/commands/workflows/review.yml
1204
+ var review_default = "name: Review\n\non: # yamllint disable-line rule:truthy\n pull_request:\n\nconcurrency:\n group: review-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n checks: write\n pull-requests: write\n\njobs:\n review:\n name: Review\n uses: theholocron/.github/.github/workflows/review.yml@main\n secrets: inherit\n";
1205
+ //#endregion
1206
+ //#region src/commands/workflows/stale.yml
1207
+ var stale_default = "name: Stale\n\non: # yamllint disable-line rule:truthy\n schedule:\n - cron: \"30 1 * * *\"\n\npermissions:\n contents: write\n issues: write\n pull-requests: write\n\njobs:\n stale:\n uses: theholocron/.github/.github/workflows/stale.yml@main\n secrets: inherit\n";
1208
+ //#endregion
1209
+ //#region src/commands/workflows/sync-github.yml
1210
+ var sync_github_default = "name: Sync GitHub Templates\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n paths:\n - packages/cli/src/templates/index.ts\n - packages/cli/src/commands/setup-workflows.ts\n\nconcurrency:\n group: sync-github-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n\njobs:\n sync:\n name: Sync\n uses: theholocron/.github/.github/workflows/sync-github.yml@main\n with:\n secondary-repos: theholocron/.github-private\n secrets:\n SYNC_TOKEN: ${{ secrets.SYNC_TOKEN }}\n";
1211
+ //#endregion
1212
+ //#region src/commands/workflows/test.yml
1213
+ var test_default = "name: Test\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: test-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n\njobs:\n test:\n name: Test\n uses: theholocron/.github/.github/workflows/test.yml@main\n secrets: inherit\n";
1214
+ //#endregion
1215
+ //#region src/commands/workflows/typecheck.yml
1216
+ var typecheck_default = "name: Typecheck\n\non: # yamllint disable-line rule:truthy\n push:\n branches: [main, alpha]\n pull_request:\n\nconcurrency:\n group: typecheck-${{ github.ref }}\n cancel-in-progress: true\n\npermissions:\n contents: read\n\njobs:\n typecheck:\n name: Typecheck\n uses: theholocron/.github/.github/workflows/typecheck.yml@main\n secrets: inherit\n";
1158
1217
  //#endregion
1159
1218
  //#region src/commands/setup-workflows.ts
1160
- /**
1161
- * Thin workflow wrapper templates for `holocron setup`.
1162
- *
1163
- * Each entry is a complete `.github/workflows/<name>.yml` that delegates
1164
- * to the corresponding reusable `ci-<name>.yml` in `theholocron/.github`.
1165
- * Files are overwritten on each setup run — they are generated artifacts.
1166
- */
1167
- const WORKFLOW_REPO = "theholocron/.github";
1168
- const WORKFLOW_REF = "main";
1169
- function ref(name) {
1170
- return `${WORKFLOW_REPO}/.github/workflows/${name}.yml@${WORKFLOW_REF}`;
1219
+ /** Header prepended when holocron setup writes a generated file to a repo. */
1220
+ function workflowHeader(source = "packages/cli/src/commands/setup-workflows.ts") {
1221
+ return [
1222
+ `# AUTO-GENERATED do not edit directly.`,
1223
+ `# Source: theholocron/holocron · ${source}`,
1224
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1225
+ `# Tool: holocron setup`,
1226
+ `# Changes: run \`holocron setup\` to regenerate.`,
1227
+ ``
1228
+ ].join("\n");
1171
1229
  }
1172
- /** Header prepended when holocron setup writes a thin caller to a repo. */
1173
- const WORKFLOW_HEADER = `\
1174
- # AUTO-GENERATED by holocron — do not edit directly.
1175
- # Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts
1176
- # Run \`holocron setup\` to regenerate.
1177
-
1178
- `;
1179
1230
  const WORKFLOW_TEMPLATES = {
1180
- lint: `\
1181
- name: Lint
1182
-
1183
- on: # yamllint disable-line rule:truthy
1184
- push:
1185
- branches: [main, alpha]
1186
- pull_request:
1187
-
1188
- concurrency:
1189
- group: lint-\${{ github.ref }}
1190
- cancel-in-progress: true
1191
-
1192
- permissions:
1193
- contents: write
1194
- statuses: write
1195
-
1196
- jobs:
1197
- lint:
1198
- name: Lint
1199
- uses: ${ref("lint")}
1200
- secrets: inherit
1201
- with:
1202
- enable-auto-commit: true
1203
- `,
1204
- test: `\
1205
- name: Test
1206
-
1207
- on: # yamllint disable-line rule:truthy
1208
- push:
1209
- branches: [main, alpha]
1210
- pull_request:
1211
-
1212
- concurrency:
1213
- group: test-\${{ github.ref }}
1214
- cancel-in-progress: true
1215
-
1216
- permissions:
1217
- contents: read
1218
-
1219
- jobs:
1220
- test:
1221
- name: Test
1222
- uses: ${ref("test")}
1223
- secrets: inherit
1224
- `,
1225
- typecheck: `\
1226
- name: Typecheck
1227
-
1228
- on: # yamllint disable-line rule:truthy
1229
- push:
1230
- branches: [main, alpha]
1231
- pull_request:
1232
-
1233
- concurrency:
1234
- group: typecheck-\${{ github.ref }}
1235
- cancel-in-progress: true
1236
-
1237
- permissions:
1238
- contents: read
1239
-
1240
- jobs:
1241
- typecheck:
1242
- name: Typecheck
1243
- uses: ${ref("typecheck")}
1244
- secrets: inherit
1245
- `,
1246
- codeql: `\
1247
- name: CodeQL
1248
-
1249
- on: # yamllint disable-line rule:truthy
1250
- push:
1251
- branches:
1252
- - main
1253
- pull_request:
1254
- branches:
1255
- - main
1256
- schedule:
1257
- - cron: "0 0 * * 1"
1258
-
1259
- permissions:
1260
- actions: read
1261
- contents: read
1262
- security-events: write
1263
-
1264
- jobs:
1265
- codeql:
1266
- uses: ${ref("codeql")}
1267
- secrets: inherit
1268
- `,
1269
- review: `\
1270
- name: Review
1271
-
1272
- on: # yamllint disable-line rule:truthy
1273
- pull_request:
1274
-
1275
- concurrency:
1276
- group: review-\${{ github.ref }}
1277
- cancel-in-progress: true
1278
-
1279
- permissions:
1280
- contents: read
1281
- checks: write
1282
- pull-requests: write
1283
-
1284
- jobs:
1285
- review:
1286
- name: Review
1287
- uses: ${ref("review")}
1288
- secrets: inherit
1289
- `,
1290
- release: `\
1291
- name: Release
1292
-
1293
- on: # yamllint disable-line rule:truthy
1294
- push:
1295
- branches:
1296
- - main
1297
- - alpha
1298
- workflow_dispatch:
1299
-
1300
- permissions:
1301
- contents: write
1302
- id-token: write
1303
- issues: write
1304
- pull-requests: write
1305
-
1306
- concurrency:
1307
- group: \${{ github.workflow }}-\${{ github.ref }}
1308
- cancel-in-progress: false
1309
-
1310
- jobs:
1311
- release:
1312
- uses: ${ref("release")}
1313
- secrets: inherit
1314
- `,
1315
- stale: `\
1316
- name: Stale
1317
-
1318
- on: # yamllint disable-line rule:truthy
1319
- schedule:
1320
- - cron: "30 1 * * *"
1321
-
1322
- permissions:
1323
- contents: write
1324
- issues: write
1325
- pull-requests: write
1326
-
1327
- jobs:
1328
- stale:
1329
- uses: ${ref("stale")}
1330
- secrets: inherit
1331
- `,
1332
- greetings: `\
1333
- name: Greetings
1334
-
1335
- on: # yamllint disable-line rule:truthy
1336
- pull_request:
1337
- issues:
1338
-
1339
- permissions:
1340
- issues: write
1341
- pull-requests: write
1342
-
1343
- jobs:
1344
- greetings:
1345
- uses: ${ref("greetings")}
1346
- secrets: inherit
1347
- `,
1348
- dependencies: `\
1349
- name: Dependencies
1350
-
1351
- on: # yamllint disable-line rule:truthy
1352
- pull_request:
1353
-
1354
- permissions:
1355
- contents: write
1356
- pull-requests: write
1357
-
1358
- jobs:
1359
- dependencies:
1360
- uses: ${ref("dependencies")}
1361
- secrets: inherit
1362
- `,
1363
- "bookkeeping-pr": `\
1364
- name: PR Bookkeeping
1365
-
1366
- on: # yamllint disable-line rule:truthy
1367
- pull_request:
1368
- types:
1369
- - opened
1370
- - edited
1371
-
1372
- permissions:
1373
- contents: read
1374
- pull-requests: write
1375
-
1376
- jobs:
1377
- bookkeeping:
1378
- uses: ${ref("bookkeeping-pr")}
1379
- secrets: inherit
1380
- `,
1381
- audit: `\
1382
- name: Audit
1383
-
1384
- on: # yamllint disable-line rule:truthy
1385
- push:
1386
- branches: [main, alpha]
1387
- pull_request:
1388
-
1389
- permissions:
1390
- contents: read
1391
-
1392
- jobs:
1393
- audit:
1394
- uses: ${ref("audit")}
1395
- secrets: inherit
1396
- `
1231
+ lint: lint_default,
1232
+ test: test_default,
1233
+ typecheck: typecheck_default,
1234
+ codeql: codeql_default,
1235
+ review: review_default,
1236
+ release: release_default,
1237
+ stale: stale_default,
1238
+ greetings: greetings_default,
1239
+ dependencies: dependencies_default,
1240
+ bookkeeping: bookkeeping_default,
1241
+ audit: audit_default,
1242
+ "sync-github": sync_github_default
1397
1243
  };
1398
1244
  const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
1245
+ /**
1246
+ * GitHub check context name each CI workflow produces on a PR.
1247
+ *
1248
+ * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
1249
+ * job's own `name:` field does NOT appear in the external check name — only
1250
+ * the calling workflow's top-level `name:` and the inner reusable-workflow
1251
+ * job name matter. Only workflows that gate merges are listed here.
1252
+ */
1253
+ const WORKFLOW_CHECK_CONTEXTS = {
1254
+ lint: "Lint / Lint entire codebase",
1255
+ test: "Test / Run tests and collect coverage",
1256
+ typecheck: "Typecheck / tsc --noEmit"
1257
+ };
1258
+ /**
1259
+ * Generate the thin caller content for a workflow, optionally injecting or
1260
+ * merging `with:` overrides into the jobs block.
1261
+ *
1262
+ * Two strategies are used depending on the template:
1263
+ * - Templates that already have a `with:` block (e.g. lint, sync-github):
1264
+ * the override entries are merged in, replacing existing keys and appending
1265
+ * new ones.
1266
+ * - Templates that end with ` secrets: inherit`: a new `with:` block is
1267
+ * injected immediately before `secrets: inherit`.
1268
+ * If neither pattern matches the template, a warning is emitted and the
1269
+ * base template is returned unchanged.
1270
+ */
1271
+ function generateThinCallerContent(name, withOverrides) {
1272
+ const base = WORKFLOW_TEMPLATES[name];
1273
+ if (!base) return "";
1274
+ if (!withOverrides || Object.keys(withOverrides).length === 0) return base;
1275
+ const fmt = (k, v) => ` ${k}: ${v === true ? "true" : v === false ? "false" : String(v)}`;
1276
+ const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
1277
+ const existingMatch = base.match(withBlockRe);
1278
+ if (existingMatch) {
1279
+ const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).map((line) => {
1280
+ const m = line.match(/^ {6}([^:]+):\s*(.*)/);
1281
+ return m ? [m[1].trim(), m[2].trim()] : null;
1282
+ }).filter((e) => e !== null));
1283
+ for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, v === true ? "true" : v === false ? "false" : String(v));
1284
+ const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
1285
+ return base.replace(withBlockRe, ` with:\n${merged}\n`);
1286
+ }
1287
+ const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
1288
+ const result = base.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
1289
+ if (result === base) console.warn(`[generateThinCallerContent] could not inject with: overrides into "${name}" template`);
1290
+ return result;
1291
+ }
1399
1292
  //#endregion
1400
1293
  //#region src/commands/sync-github.ts
1401
1294
  const DEFAULT_REPO = "theholocron/.github";
1402
- const API_BASE = "https://api.github.com";
1403
- function reusableHeader(source) {
1404
- return [
1405
- `# AUTO-GENERATED do not edit in theholocron/.github directly.`,
1406
- `# Source: theholocron/holocron · ${source}`,
1407
- `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1408
- `# Tool: holocron sync-github`,
1409
- `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1295
+ /**
1296
+ * Extracts the `workflows` array from a `holocron.config.ts` source string.
1297
+ * Handles both plain string entries and `{ name, with }` object entries.
1298
+ * Falls back to an empty array if the array cannot be found or parsed.
1299
+ */
1300
+ function parseWorkflowsFromTs(source) {
1301
+ const keyMatch = source.match(/\bworkflows\s*:\s*\[/);
1302
+ if (!keyMatch) return [];
1303
+ const start = keyMatch.index + keyMatch[0].length;
1304
+ let depth = 1;
1305
+ let i = start;
1306
+ while (i < source.length && depth > 0) {
1307
+ if (source[i] === "[") depth++;
1308
+ else if (source[i] === "]") depth--;
1309
+ i++;
1310
+ }
1311
+ const body = source.slice(start, i - 1);
1312
+ const entries = [];
1313
+ const objSpans = [];
1314
+ const objRe = /\{\s*name\s*:\s*"([^"]+)"(?:\s*,\s*with\s*:\s*(\{[^}]*\}))?\s*\}/g;
1315
+ let m;
1316
+ while ((m = objRe.exec(body)) !== null) {
1317
+ objSpans.push([m.index, m.index + m[0].length]);
1318
+ let withObj;
1319
+ if (m[2]) try {
1320
+ withObj = JSON.parse(m[2]);
1321
+ } catch {}
1322
+ entries.push({
1323
+ pos: m.index,
1324
+ entry: {
1325
+ name: m[1],
1326
+ ...withObj && { with: withObj }
1327
+ }
1328
+ });
1329
+ }
1330
+ const strRe = /"([^"]+)"/g;
1331
+ while ((m = strRe.exec(body)) !== null) if (!objSpans.some(([s, e]) => m.index >= s && m.index < e)) entries.push({
1332
+ pos: m.index,
1333
+ entry: { name: m[1] }
1334
+ });
1335
+ entries.sort((a, b) => a.pos - b.pos);
1336
+ return entries.map(({ entry }) => entry);
1337
+ }
1338
+ function reusableHeader(source) {
1339
+ return [
1340
+ `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
1341
+ `# Source: theholocron/holocron · ${source}`,
1342
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1343
+ `# Tool: holocron sync-github`,
1344
+ `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1410
1345
  ``
1411
1346
  ].join("\n");
1412
1347
  }
1413
- function thinCallerHeader() {
1348
+ function thinCallerHeader(forPrimary = false) {
1414
1349
  return [
1415
- `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
1350
+ forPrimary ? `# AUTO-GENERATED — do not edit in theholocron/.github directly.` : `# AUTO-GENERATED — do not edit directly.`,
1416
1351
  `# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
1417
1352
  `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1418
1353
  `# Tool: holocron sync-github`,
1419
- `# Changes: edit setup-workflows.ts in theholocron/holocron and push.`,
1354
+ `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1420
1355
  ``
1421
1356
  ].join("\n");
1422
1357
  }
1423
- function buildBatch() {
1358
+ function buildBatch(repo, allowedWorkflows, withOverrides) {
1424
1359
  const files = [];
1425
- for (const [name, content] of Object.entries(ACTIONS)) files.push({
1360
+ const isPrimaryGithubRepo = repo === DEFAULT_REPO;
1361
+ if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(ACTIONS)) files.push({
1426
1362
  path: `.github/actions/${name}.yml`,
1427
1363
  content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1428
1364
  });
1429
- for (const [name, content] of Object.entries(REUSABLE_WORKFLOWS)) files.push({
1430
- path: `.github/workflows/${name}.yml`,
1431
- content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1432
- });
1433
- for (const [name, content] of Object.entries(WORKFLOW_TEMPLATES)) files.push({
1434
- path: `workflow-templates/${name}.yml`,
1435
- content: thinCallerHeader() + content
1436
- });
1365
+ if (isPrimaryGithubRepo) {
1366
+ for (const [name, content] of Object.entries(REUSABLE_WORKFLOWS)) files.push({
1367
+ path: `.github/workflows/${name}.yml`,
1368
+ content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1369
+ });
1370
+ for (const [name, content] of Object.entries(WORKFLOW_TEMPLATES)) {
1371
+ files.push({
1372
+ path: `workflow-templates/${name}.yml`,
1373
+ content: thinCallerHeader(true) + content
1374
+ });
1375
+ const props = WORKFLOW_TEMPLATE_PROPERTIES[name];
1376
+ if (props) files.push({
1377
+ path: `workflow-templates/${name}.properties.json`,
1378
+ content: props
1379
+ });
1380
+ }
1381
+ } else for (const name of Object.keys(REUSABLE_WORKFLOWS)) {
1382
+ if (allowedWorkflows && !allowedWorkflows.has(name)) continue;
1383
+ const content = generateThinCallerContent(name, withOverrides?.get(name));
1384
+ if (!content) continue;
1385
+ files.push({
1386
+ path: `.github/workflows/${name}.yml`,
1387
+ content: thinCallerHeader() + content
1388
+ });
1389
+ }
1437
1390
  return files;
1438
1391
  }
1392
+ /** Git blob SHA: sha1("blob {len}\0{content}") — used to detect unchanged files. */
1393
+ function gitBlobSha(content) {
1394
+ const buf = Buffer.from(content, "utf8");
1395
+ return createHash("sha1").update(`blob ${buf.length}\0`).update(buf).digest("hex");
1396
+ }
1439
1397
  async function runSyncGithub(input) {
1440
1398
  const print = input.print ?? ((line) => console.log(line));
1441
1399
  const repo = input.repo ?? DEFAULT_REPO;
1442
- const [owner, repoName] = repo.split("/");
1443
- const { token, dryRun = false } = input;
1400
+ const { token, dryRun = false, branch, createPr = false } = input;
1444
1401
  const message = input.message ?? `chore: sync from theholocron/holocron`;
1445
- const fetchFn = input.fetch ?? globalThis.fetch;
1446
- const headers = {
1447
- Authorization: `Bearer ${token}`,
1448
- Accept: "application/vnd.github+json",
1449
- "Content-Type": "application/json",
1450
- "X-GitHub-Api-Version": "2022-11-28"
1451
- };
1402
+ const client = createGitHubClient({
1403
+ token,
1404
+ fetch: input.fetch
1405
+ });
1452
1406
  print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
1453
- print(` repo: ${repo}`);
1407
+ print(` repo: ${repo}`);
1408
+ if (branch) print(` branch: ${branch}`);
1454
1409
  print("");
1455
- const batch = buildBatch();
1410
+ if (input.outputDir) {
1411
+ const batch = buildBatch(repo);
1412
+ for (const file of batch) {
1413
+ const dest = join(input.outputDir, file.path);
1414
+ mkdirSync(dirname(dest), { recursive: true });
1415
+ writeFileSync(dest, file.content, "utf8");
1416
+ }
1417
+ print(` ${batch.length} files written to ${input.outputDir}`);
1418
+ return {
1419
+ status: "ok",
1420
+ created: batch.length,
1421
+ updated: 0,
1422
+ unchanged: 0
1423
+ };
1424
+ }
1425
+ let targetBranch = branch;
1426
+ let defaultBranch;
1427
+ if (!targetBranch || createPr) try {
1428
+ defaultBranch = (await client.repos.getRepo(repo)).default_branch;
1429
+ if (!targetBranch) targetBranch = defaultBranch;
1430
+ } catch {
1431
+ const msg = "failed to fetch repo metadata";
1432
+ print(` ✗ ${msg}`);
1433
+ return {
1434
+ status: "fail",
1435
+ created: 0,
1436
+ updated: 0,
1437
+ unchanged: 0,
1438
+ message: msg
1439
+ };
1440
+ }
1441
+ const baseBranch = createPr && defaultBranch ? defaultBranch : targetBranch;
1442
+ let headSha;
1443
+ let baseTreeSha;
1444
+ let existingBlobs;
1445
+ try {
1446
+ headSha = (await client.git.getRef(repo, baseBranch)).object.sha;
1447
+ baseTreeSha = (await client.git.getCommit(repo, headSha)).tree.sha;
1448
+ const treeData = await client.git.getTree(repo, baseTreeSha, true);
1449
+ existingBlobs = new Map(treeData.tree.filter((i) => i.type === "blob").map((i) => [i.path, i.sha]));
1450
+ } catch (err) {
1451
+ const msg = err instanceof Error ? err.message : `Branch ${baseBranch} not found`;
1452
+ print(` ✗ ${msg}`);
1453
+ return {
1454
+ status: "fail",
1455
+ created: 0,
1456
+ updated: 0,
1457
+ unchanged: 0,
1458
+ message: msg
1459
+ };
1460
+ }
1461
+ let allowedWorkflows;
1462
+ let withOverrides;
1463
+ if (repo !== DEFAULT_REPO) try {
1464
+ let entries = [];
1465
+ try {
1466
+ const data = await client.git.getContents(repo, "holocron.config.json");
1467
+ entries = (JSON.parse(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"))?.workflows ?? []).map((w) => typeof w === "string" ? { name: w } : w);
1468
+ } catch (err) {
1469
+ if (!(err instanceof ProviderApiError) || err.status !== 404) throw err;
1470
+ try {
1471
+ const data = await client.git.getContents(repo, "holocron.config.ts");
1472
+ entries = parseWorkflowsFromTs(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"));
1473
+ } catch {}
1474
+ }
1475
+ if (entries.length > 0) {
1476
+ allowedWorkflows = new Set(entries.map((e) => e.name));
1477
+ const overrideEntries = entries.filter((e) => e.with != null).map((e) => [e.name, e.with]);
1478
+ if (overrideEntries.length > 0) withOverrides = new Map(overrideEntries);
1479
+ }
1480
+ } catch {}
1481
+ const batch = buildBatch(repo, allowedWorkflows, withOverrides);
1456
1482
  let created = 0;
1457
1483
  let updated = 0;
1458
1484
  let unchanged = 0;
1485
+ const changedFiles = [];
1459
1486
  for (const file of batch) {
1460
- const url = `${API_BASE}/repos/${owner}/${repoName}/contents/${file.path}`;
1461
- const newContent = Buffer.from(file.content, "utf8").toString("base64");
1462
- let existingSha;
1463
- let existingContent;
1464
- try {
1465
- const getRes = await fetchFn(url, { headers });
1466
- if (getRes.ok) {
1467
- const data = await getRes.json();
1468
- existingSha = data.sha;
1469
- existingContent = data.content.replace(/\n/g, "");
1470
- }
1471
- } catch {}
1472
- if (existingContent === newContent) {
1487
+ const localSha = gitBlobSha(file.content);
1488
+ const existingSha = existingBlobs.get(file.path);
1489
+ if (existingSha === localSha) {
1473
1490
  print(` · unchanged ${file.path}`);
1474
1491
  unchanged++;
1475
- continue;
1476
- }
1477
- const verb = existingSha ? "updated " : "created ";
1478
- if (dryRun) {
1479
- print(` ~ ${verb} ${file.path}`);
1480
- if (existingSha) updated++;
1481
- else created++;
1482
- continue;
1483
- }
1484
- const body = {
1485
- message,
1486
- content: newContent
1487
- };
1488
- if (existingSha) body.sha = existingSha;
1489
- const putRes = await fetchFn(url, {
1490
- method: "PUT",
1491
- headers,
1492
- body: JSON.stringify(body)
1493
- });
1494
- if (!putRes.ok) {
1495
- const err = await putRes.json();
1496
- const msg = `failed to push ${file.path}: ${err.message ?? putRes.status}`;
1497
- print(` ✗ ${msg}`);
1498
- return {
1499
- status: "fail",
1500
- created,
1501
- updated,
1502
- unchanged,
1503
- message: msg
1504
- };
1492
+ } else if (existingSha) {
1493
+ print(` ${dryRun ? "~" : "✓"} updated ${file.path}`);
1494
+ updated++;
1495
+ if (!dryRun) changedFiles.push(file);
1496
+ } else {
1497
+ print(` ${dryRun ? "~" : "✓"} created ${file.path}`);
1498
+ created++;
1499
+ if (!dryRun) changedFiles.push(file);
1505
1500
  }
1506
- print(` ✓ ${verb} ${file.path}`);
1507
- if (existingSha) updated++;
1508
- else created++;
1509
1501
  }
1510
1502
  print("");
1511
1503
  print(` ${created} created, ${updated} updated, ${unchanged} unchanged`);
1512
- return {
1504
+ if (dryRun || changedFiles.length === 0) return {
1513
1505
  status: dryRun ? "dry-run" : "ok",
1514
1506
  created,
1515
1507
  updated,
1516
1508
  unchanged
1517
1509
  };
1510
+ const treeEntries = [];
1511
+ for (const file of changedFiles) try {
1512
+ const blob = await client.git.createBlob(repo, file.content);
1513
+ treeEntries.push({
1514
+ path: file.path,
1515
+ mode: "100644",
1516
+ type: "blob",
1517
+ sha: blob.sha
1518
+ });
1519
+ } catch (err) {
1520
+ const msg = `failed to create blob for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
1521
+ print(` ✗ ${msg}`);
1522
+ return {
1523
+ status: "fail",
1524
+ created,
1525
+ updated,
1526
+ unchanged,
1527
+ message: msg
1528
+ };
1529
+ }
1530
+ let newTreeSha;
1531
+ try {
1532
+ newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
1533
+ } catch (err) {
1534
+ const msg = `failed to create tree: ${err instanceof Error ? err.message : String(err)}`;
1535
+ print(` ✗ ${msg}`);
1536
+ return {
1537
+ status: "fail",
1538
+ created,
1539
+ updated,
1540
+ unchanged,
1541
+ message: msg
1542
+ };
1543
+ }
1544
+ let newCommitSha;
1545
+ try {
1546
+ newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
1547
+ } catch (err) {
1548
+ const msg = `failed to create commit: ${err instanceof Error ? err.message : String(err)}`;
1549
+ print(` ✗ ${msg}`);
1550
+ return {
1551
+ status: "fail",
1552
+ created,
1553
+ updated,
1554
+ unchanged,
1555
+ message: msg
1556
+ };
1557
+ }
1558
+ try {
1559
+ if (createPr && branch) try {
1560
+ await client.git.createRef(repo, `refs/heads/${branch}`, newCommitSha);
1561
+ } catch (err) {
1562
+ if (!(err instanceof ProviderApiError) || err.status !== 422) throw err;
1563
+ await client.git.updateRef(repo, `heads/${branch}`, newCommitSha, true);
1564
+ }
1565
+ else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
1566
+ } catch (err) {
1567
+ const msg = `failed to update ref: ${err instanceof Error ? err.message : String(err)}`;
1568
+ print(` ✗ ${msg}`);
1569
+ return {
1570
+ status: "fail",
1571
+ created,
1572
+ updated,
1573
+ unchanged,
1574
+ message: msg
1575
+ };
1576
+ }
1577
+ let prUrl;
1578
+ if (branch && createPr && !dryRun) try {
1579
+ prUrl = (await client.git.createPull(repo, {
1580
+ title: message.split("\n")[0],
1581
+ head: branch,
1582
+ base: "main",
1583
+ body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
1584
+ })).html_url;
1585
+ print(` → PR opened: ${prUrl}`);
1586
+ } catch (err) {
1587
+ if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("already exists")) print(` → PR already open for ${branch} — branch updated, ready to merge`);
1588
+ else print(` ⚠ PR creation failed: ${err instanceof Error ? err.message : String(err)}`);
1589
+ }
1590
+ return {
1591
+ status: "ok",
1592
+ created,
1593
+ updated,
1594
+ unchanged,
1595
+ prUrl
1596
+ };
1518
1597
  }
1519
1598
  //#endregion
1520
1599
  //#region src/commands/npm-publish-initial.ts
@@ -1559,11 +1638,10 @@ async function runNpmPublishInitial(input = {}) {
1559
1638
  const dryRun = input.dryRun ?? false;
1560
1639
  const otp = input.otp;
1561
1640
  const env = input.env ?? process.env;
1562
- const exec = input.exec ?? defaultExec;
1641
+ const exec = input.exec ?? defaultExec$2;
1563
1642
  const publishArgs = [
1564
1643
  "-r",
1565
1644
  "--filter=./packages/*",
1566
- "--filter=!@theholocron/cli-utils",
1567
1645
  "publish",
1568
1646
  "--access",
1569
1647
  "public",
@@ -1635,7 +1713,7 @@ function printNextSteps$1(print, env) {
1635
1713
  print(" https://www.npmjs.com/settings/~/tokens");
1636
1714
  }
1637
1715
  }
1638
- const defaultExec = async (cmd, args, opts) => {
1716
+ const defaultExec$2 = async (cmd, args, opts) => {
1639
1717
  const result = spawnSync(cmd, args, {
1640
1718
  cwd: opts.cwd,
1641
1719
  encoding: "utf8",
@@ -1654,7 +1732,7 @@ const defaultExec = async (cmd, args, opts) => {
1654
1732
  //#endregion
1655
1733
  //#region src/commands/plugin-create/template-inputs.ts
1656
1734
  /** Derive the standard defaults from a slug + vendor name. */
1657
- function deriveDefaults(input) {
1735
+ function deriveDefaults$1(input) {
1658
1736
  const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
1659
1737
  const capability = input.capability;
1660
1738
  return {
@@ -1667,48 +1745,19 @@ function deriveDefaults(input) {
1667
1745
  //#endregion
1668
1746
  //#region src/commands/plugin-create/templates/auth.ts
1669
1747
  function render$17(inputs) {
1670
- return `/**
1671
- * Token resolution for the ${inputs.vendorName} plugin.
1672
- *
1673
- * Resolution order (matches the standard 4-step precedence set by
1674
- * \`.notes/tech-auth-bootstrap.spec.md\`):
1675
- * 1. explicit \`cliToken\` argument (from \`--token\` flag)
1676
- * 2. ${inputs.tokenEnv} env var (preferred — explicit intent)
1677
- * 3. ${inputs.vendorEnv} env var (vendor-native)
1678
- * 4. keyring (com.theholocron.cli / "${inputs.slug}")
1679
- * 5. AuthError naming all four options + the bootstrap hint
1680
- */
1681
-
1682
- import { getToken as getKeyringToken } from "@theholocron/cli";
1683
-
1684
- export class AuthError extends Error {
1685
- override name = "AuthError";
1686
- }
1687
-
1688
- export interface ResolveTokenInput {
1689
- /** From \`--token\` CLI flag. */
1690
- cliToken?: string;
1691
- /** Env vars; passed in for testability. Defaults to \`process.env\`. */
1692
- env?: NodeJS.ProcessEnv;
1693
- /** Keyring lookup fn; passed in for testability. Defaults to \`getToken(provider)\`. */
1694
- keyring?: (provider: string) => string | null;
1695
- }
1696
-
1697
- export function resolveToken(input: ResolveTokenInput = {}): string {
1698
- const env = input.env ?? process.env;
1699
- const keyring = input.keyring ?? getKeyringToken;
1700
- // Bracket access so numeric-prefixed slugs (e.g., env.HOLOCRON_1PASSWORD_TOKEN
1701
- // which is invalid JS) still produce syntactically valid code.
1702
- const token =
1703
- input.cliToken || env["${inputs.tokenEnv}"] || env["${inputs.vendorEnv}"] || keyring("${inputs.slug}");
1704
- if (!token) {
1705
- throw new AuthError(
1706
- "no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
1707
- "or run: holocron auth set ${inputs.slug} <TOKEN>"
1708
- );
1709
- }
1710
- return token;
1711
- }
1748
+ return `import { AuthError, createResolveToken, type ResolveTokenInput } from "@theholocron/cli";
1749
+
1750
+ export { AuthError };
1751
+ export type { ResolveTokenInput };
1752
+
1753
+ export const resolveToken = createResolveToken({
1754
+ \tenvName: "${inputs.tokenEnv}",
1755
+ \tvendorEnvName: "${inputs.vendorEnv}",
1756
+ \tkeyringService: "${inputs.slug}",
1757
+ \terrorMessage:
1758
+ \t\t"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
1759
+ \t\t"or run: holocron auth set ${inputs.slug} <TOKEN>",
1760
+ });
1712
1761
  `;
1713
1762
  }
1714
1763
  //#endregion
@@ -2134,148 +2183,79 @@ Not yet published; capability methods are stubs.
2134
2183
  //#endregion
2135
2184
  //#region src/commands/plugin-create/templates/rest.ts
2136
2185
  function render$7(inputs) {
2137
- const clientClass = `${inputs.vendorName}RestClient`;
2138
- return `/**
2139
- * Thin REST wrapper around ${inputs.baseUrl}.
2140
- *
2141
- * Bearer auth, JSON-only bodies, transport-failure wrapping with
2142
- * \`status: 0\` so orchestrator soft-skip paths see a clear message
2143
- * instead of a generic \`TypeError: fetch failed\`.
2144
- */
2145
-
2146
- import { ProviderApiError } from "@theholocron/cli";
2147
-
2148
- export interface RestClientOptions {
2149
- token: string;
2150
- fetch?: typeof fetch;
2151
- baseUrl?: string;
2152
- }
2153
-
2154
- export interface RequestOptions {
2155
- method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
2156
- body?: unknown;
2157
- query?: Record<string, string>;
2158
- /** Treat this response as void even if 200 is returned. */
2159
- expectNoContent?: boolean;
2160
- }
2161
-
2162
- export class ${clientClass} {
2163
- private readonly token: string;
2164
- private readonly fetchImpl: typeof fetch;
2165
- readonly baseUrl: string;
2166
-
2167
- constructor(opts: RestClientOptions) {
2168
- this.token = opts.token;
2169
- this.fetchImpl = opts.fetch ?? globalThis.fetch;
2170
- // Manual trailing-slash trim — CodeQL flags regex on library
2171
- // input as polynomial ReDoS. O(n) loop, no backtracking risk.
2172
- let url = opts.baseUrl ?? "${inputs.baseUrl}";
2173
- while (url.endsWith("/")) url = url.slice(0, -1);
2174
- this.baseUrl = url;
2175
- }
2176
-
2177
- async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
2178
- const url = new URL(\`\${this.baseUrl}\${path.startsWith("/") ? path : "/" + path}\`);
2179
- for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
2180
- const fullUrl = url.toString();
2181
-
2182
- const headers: Record<string, string> = {
2183
- authorization: \`Bearer \${this.token}\`,
2184
- accept: "application/json",
2185
- };
2186
- const init: RequestInit = {
2187
- method: opts.method ?? "GET",
2188
- headers,
2189
- };
2190
- if (opts.body !== undefined) {
2191
- headers["content-type"] = "application/json";
2192
- init.body = JSON.stringify(opts.body);
2193
- }
2194
-
2195
- let res: Response;
2196
- try {
2197
- res = await this.fetchImpl(fullUrl, init);
2198
- } catch (err) {
2199
- const detail = err instanceof Error ? \`\${err.name}: \${err.message}\` : String(err);
2200
- throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} failed: \${detail}\`, 0, undefined);
2201
- }
2202
- if (!res.ok) {
2203
- const body = await res.text().catch(() => "");
2204
- throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} → \${res.status}\`, res.status, body);
2205
- }
2206
- if (opts.expectNoContent || res.status === 204) return undefined as T;
2207
- const text = await res.text();
2208
- if (!text) return undefined as T;
2209
- return JSON.parse(text) as T;
2210
- }
2186
+ return `import { createRestClient, type RequestOptions, type RestClient } from "@theholocron/cli";
2187
+
2188
+ export type { RequestOptions, RestClient };
2189
+
2190
+ export function ${`create${inputs.vendorName}RestClient`}(opts: {
2191
+ \ttoken: string;
2192
+ \tbaseUrl?: string;
2193
+ \tfetch?: typeof fetch;
2194
+ }): RestClient {
2195
+ \treturn createRestClient({
2196
+ \t\tbaseUrl: opts.baseUrl ?? "${inputs.baseUrl}",
2197
+ \t\ttoken: opts.token,
2198
+ \t\tvendor: "${inputs.vendorName}",
2199
+ \t\tfetch: opts.fetch,
2200
+ \t});
2211
2201
  }
2212
2202
  `;
2213
2203
  }
2214
2204
  //#endregion
2215
2205
  //#region src/commands/plugin-create/templates/rest-test.ts
2216
2206
  function render$6(inputs) {
2217
- const clientClass = `${inputs.vendorName}RestClient`;
2207
+ const factoryName = `create${inputs.vendorName}RestClient`;
2218
2208
  return `import { ProviderApiError } from "@theholocron/cli";
2219
2209
  import { describe, expect, it } from "vitest";
2220
2210
 
2221
- import { ${clientClass} } from "../rest.js";
2211
+ import { ${factoryName} } from "../rest.js";
2222
2212
  import { stubFetch } from "./helpers.js";
2223
2213
 
2224
- describe("${clientClass}", () => {
2225
- it("sends bearer + accept headers and returns the parsed body", async () => {
2226
- const stub = stubFetch([{ status: 200, body: { ok: true } }]);
2227
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2228
- const res = await client.request<{ ok: boolean }>("/me");
2229
- expect(res.ok).toBe(true);
2230
- expect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
2231
- expect(stub.calls[0]?.headers["accept"]).toBe("application/json");
2232
- });
2233
-
2234
- it("serializes body as JSON and sets content-type when present", async () => {
2235
- const stub = stubFetch([{ status: 200, body: {} }]);
2236
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2237
- await client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
2238
- expect(stub.calls[0]?.method).toBe("POST");
2239
- expect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
2240
- expect(stub.calls[0]?.body).toEqual({ name: "demo" });
2241
- });
2242
-
2243
- it("returns undefined on 204", async () => {
2244
- const stub = stubFetch([{ status: 204 }]);
2245
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
2246
- expect(await client.request<unknown>("/whatever")).toBeUndefined();
2247
- });
2248
-
2249
- it("throws ProviderApiError with the HTTP status on non-2xx", async () => {
2250
- const stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
2251
- const client = new ${clientClass}({ token: "bad", fetch: stub.fetch });
2252
- try {
2253
- await client.request<unknown>("/me");
2254
- throw new Error("should have thrown");
2255
- } catch (err) {
2256
- expect(err).toBeInstanceOf(ProviderApiError);
2257
- expect((err as ProviderApiError).status).toBe(401);
2258
- }
2259
- });
2260
-
2261
- it("wraps transport-level failures with status 0", async () => {
2262
- const throwing: typeof fetch = async () => {
2263
- throw new TypeError("fetch failed");
2264
- };
2265
- const client = new ${clientClass}({ token: "t", fetch: throwing });
2266
- try {
2267
- await client.request<unknown>("/me");
2268
- throw new Error("should have thrown");
2269
- } catch (err) {
2270
- expect(err).toBeInstanceOf(ProviderApiError);
2271
- expect((err as ProviderApiError).status).toBe(0);
2272
- }
2273
- });
2274
-
2275
- it("trims trailing slashes from the base URL", () => {
2276
- const client = new ${clientClass}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
2277
- expect(client.baseUrl).toBe("${inputs.baseUrl}");
2278
- });
2214
+ describe("${factoryName}", () => {
2215
+ \tit("sends bearer + accept headers and returns the parsed body", async () => {
2216
+ \t\tconst stub = stubFetch([{ status: 200, body: { ok: true } }]);
2217
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2218
+ \t\tconst res = await client.request<{ ok: boolean }>("/me");
2219
+ \t\texpect(res.ok).toBe(true);
2220
+ \t\texpect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
2221
+ \t\texpect(stub.calls[0]?.headers["accept"]).toBe("application/json");
2222
+ \t});
2223
+
2224
+ \tit("serializes body as JSON and sets content-type when present", async () => {
2225
+ \t\tconst stub = stubFetch([{ status: 200, body: {} }]);
2226
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2227
+ \t\tawait client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
2228
+ \t\texpect(stub.calls[0]?.method).toBe("POST");
2229
+ \t\texpect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
2230
+ \t\texpect(stub.calls[0]?.body).toEqual({ name: "demo" });
2231
+ \t});
2232
+
2233
+ \tit("returns undefined on 204", async () => {
2234
+ \t\tconst stub = stubFetch([{ status: 204 }]);
2235
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2236
+ \t\texpect(await client.request<unknown>("/whatever")).toBeUndefined();
2237
+ \t});
2238
+
2239
+ \tit("throws ProviderApiError with the HTTP status on non-2xx", async () => {
2240
+ \t\tconst stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
2241
+ \t\tconst client = ${factoryName}({ token: "bad", fetch: stub.fetch });
2242
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2243
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2244
+ \t\texpect((err as ProviderApiError).status).toBe(401);
2245
+ \t});
2246
+
2247
+ \tit("wraps transport-level failures with status 0", async () => {
2248
+ \t\tconst throwing: typeof fetch = async () => { throw new TypeError("fetch failed"); };
2249
+ \t\tconst client = ${factoryName}({ token: "t", fetch: throwing });
2250
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2251
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2252
+ \t\texpect((err as ProviderApiError).status).toBe(0);
2253
+ \t});
2254
+
2255
+ \tit("trims trailing slashes from the base URL", () => {
2256
+ \t\tconst client = ${factoryName}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
2257
+ \t\texpect(client.baseUrl).toBe("${inputs.baseUrl}");
2258
+ \t});
2279
2259
  });
2280
2260
  `;
2281
2261
  }
@@ -2584,13 +2564,11 @@ export default defineConfig({
2584
2564
  * 2. Slug collision — packages/holocron-plugin-<slug>/ must not exist.
2585
2565
  * 3. Capability sanity — must be one of the 14 known keys; warn for
2586
2566
  * many-cardinality caps.
2587
- * 4. Promptfill in any missing flags via cli-utils / inquirer
2588
- * (Phase B; Phase A takes fully-populated input).
2589
- * 5. Generate — for each template, write to
2567
+ * 4. Generatefor each template, write to
2590
2568
  * packages/holocron-plugin-<slug>/<path>.
2591
- * 6. Verify (unless --no-verify) — Phase B; runs pnpm install +
2569
+ * 5. Verify (unless --no-verify) — runs pnpm install +
2592
2570
  * pnpm --filter <pkg> typecheck lint test.
2593
- * 7. Print next steps.
2571
+ * 6. Print next steps.
2594
2572
  */
2595
2573
  var PluginCreateError = class extends Error {
2596
2574
  name = "PluginCreateError";
@@ -2683,7 +2661,7 @@ function runPluginCreate(input) {
2683
2661
  const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
2684
2662
  if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
2685
2663
  validateCapability(input.capability, print);
2686
- const derived = deriveDefaults({
2664
+ const derived = deriveDefaults$1({
2687
2665
  slug: input.slug,
2688
2666
  vendorName: input.vendorName,
2689
2667
  capability: input.capability
@@ -2713,6 +2691,51 @@ function runPluginCreate(input) {
2713
2691
  }
2714
2692
  filesWritten.push(resolvedPath);
2715
2693
  }
2694
+ if (!input.dryRun && !input.noVerify) {
2695
+ const execFn = input.exec ?? defaultExec$1;
2696
+ const pkg = `@theholocron/holocron-plugin-${inputs.slug}`;
2697
+ print("");
2698
+ print(" Verifying scaffold…");
2699
+ try {
2700
+ execFn("pnpm", ["install", "--frozen-lockfile=false"], {
2701
+ cwd,
2702
+ stdio: "inherit"
2703
+ });
2704
+ execFn("pnpm", [
2705
+ "--filter",
2706
+ pkg,
2707
+ "typecheck"
2708
+ ], {
2709
+ cwd,
2710
+ stdio: "inherit"
2711
+ });
2712
+ execFn("pnpm", [
2713
+ "--filter",
2714
+ pkg,
2715
+ "lint"
2716
+ ], {
2717
+ cwd,
2718
+ stdio: "inherit"
2719
+ });
2720
+ execFn("pnpm", [
2721
+ "--filter",
2722
+ pkg,
2723
+ "test"
2724
+ ], {
2725
+ cwd,
2726
+ stdio: "inherit"
2727
+ });
2728
+ print(" ✓ scaffold verified");
2729
+ } catch (err) {
2730
+ print(` ✗ verify failed — ${err instanceof Error ? err.message : String(err)}`);
2731
+ return {
2732
+ status: "fail",
2733
+ packagePath: packageDir,
2734
+ filesWritten,
2735
+ message: "post-scaffold verify failed; inspect output above"
2736
+ };
2737
+ }
2738
+ }
2716
2739
  if (!input.dryRun) printNextSteps(print, inputs);
2717
2740
  return {
2718
2741
  status: "ok",
@@ -2756,6 +2779,12 @@ function defaultWrite(filepath, content) {
2756
2779
  mkdirSync(path.dirname(filepath), { recursive: true });
2757
2780
  writeFileSync(filepath, content, "utf8");
2758
2781
  }
2782
+ function defaultExec$1(cmd, args, opts) {
2783
+ execFileSync(cmd, args, {
2784
+ cwd: opts.cwd,
2785
+ stdio: opts.stdio
2786
+ });
2787
+ }
2759
2788
  function printNextSteps(print, inputs) {
2760
2789
  print("");
2761
2790
  print(` Scaffolded @theholocron/holocron-plugin-${inputs.slug} (18 files).`);
@@ -2836,22 +2865,22 @@ function describeScope(scope) {
2836
2865
  async function runSecretsSync(input) {
2837
2866
  const print = input.print ?? ((line) => console.log(line));
2838
2867
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
2839
- await loader.load();
2868
+ await withSpinner("Loading plugins…", () => loader.load());
2840
2869
  const dryRun = input.context.dryRun ?? false;
2841
2870
  const targets = input.targets ?? ["production", "preview"];
2842
- print(`Holocron secrets sync — environment ${input.environmentId}${dryRun ? " (dry-run)" : ""}`);
2843
- print(` vault: ${vaultProviderName(loader)}`);
2871
+ print(style.header(`Holocron secrets sync — environment ${input.environmentId}${dryRun ? " (dry-run)" : ""}`));
2872
+ print(style.dim(` vault: ${vaultProviderName(loader)}`));
2844
2873
  print("");
2845
2874
  const vault = loader.get("vault");
2846
2875
  if (!vault.readEnvironment) throw new Error(`vault provider (${vault.providerName}) does not implement readEnvironment — sync needs bulk env reads`);
2847
- const envVars = await vault.readEnvironment(input.environmentId);
2876
+ const envVars = await withSpinner(`Reading vault environment ${input.environmentId}…`, () => vault.readEnvironment(input.environmentId));
2848
2877
  const keys = Object.keys(envVars).sort();
2849
- print(`read ${keys.length} keys from vault`);
2878
+ print(style.step(`read ${keys.length} keys from vault`));
2850
2879
  print("");
2851
2880
  const rows = [];
2852
2881
  if (loader.has("secrets")) {
2853
2882
  const secrets = loader.get("secrets");
2854
- print("secrets (repo scope)");
2883
+ print(style.step("secrets (repo scope)"));
2855
2884
  for (const key of keys) {
2856
2885
  rows.push(await runRow(`secrets:${secrets.providerName}`, "scope=repo", key, dryRun, async () => {
2857
2886
  await secrets.setSecret({ kind: "repo" }, key, envVars[key]);
@@ -2862,7 +2891,7 @@ async function runSecretsSync(input) {
2862
2891
  if (loader.has("deployment")) {
2863
2892
  const deploy = loader.get("deployment");
2864
2893
  if (!input.projectId) {
2865
- print("deployment");
2894
+ print(style.step("deployment"));
2866
2895
  const row = {
2867
2896
  destination: `deployment:${deploy.providerName}`,
2868
2897
  scope: "no projectId",
@@ -2873,7 +2902,7 @@ async function runSecretsSync(input) {
2873
2902
  rows.push(row);
2874
2903
  print(formatRow(row));
2875
2904
  } else for (const target of targets) {
2876
- print(`deployment (target=${target})`);
2905
+ print(style.step(`deployment (target=${target})`));
2877
2906
  for (const key of keys) {
2878
2907
  rows.push(await runRow(`deployment:${deploy.providerName}`, `target=${target}`, key, dryRun, async () => {
2879
2908
  await deploy.setEnvVar(input.projectId, target, key, envVars[key]);
@@ -2895,7 +2924,8 @@ async function runSecretsSync(input) {
2895
2924
  dryRun: 0
2896
2925
  });
2897
2926
  print("");
2898
- print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
2927
+ const summaryLine = `${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`;
2928
+ print(summary.fail > 0 ? style.fail(summaryLine) : style.success(summaryLine));
2899
2929
  return {
2900
2930
  rows,
2901
2931
  summary
@@ -2927,9 +2957,12 @@ async function runRow(destination, scope, key, dryRun, body) {
2927
2957
  }
2928
2958
  }
2929
2959
  function formatRow(row) {
2930
- const icon = row.status === "ok" ? "✓" : row.status === "fail" ? "✗" : row.status === "dry-run" ? "…" : "·";
2931
- const detail = row.message ? ` (${row.message})` : "";
2932
- return ` ${icon} ${row.key}${detail}`;
2960
+ const detail = row.message ? style.dim(` (${row.message})`) : "";
2961
+ const label = `${row.key}${detail}`;
2962
+ if (row.status === "ok") return ` ${style.success(label)}`;
2963
+ if (row.status === "fail") return ` ${style.fail(label)}`;
2964
+ if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
2965
+ return ` ${style.dim(`· ${label}`)}`;
2933
2966
  }
2934
2967
  function vaultProviderName(loader) {
2935
2968
  if (!loader.has("vault")) return "<missing>";
@@ -2937,33 +2970,277 @@ function vaultProviderName(loader) {
2937
2970
  }
2938
2971
  //#endregion
2939
2972
  //#region src/commands/setup.ts
2940
- const DEPENDABOT_CONFIG = `\
2941
- # AUTO-GENERATED by holocron run \`holocron setup\` to regenerate.
2942
- version: 2
2943
- updates:
2944
- - package-ecosystem: npm
2945
- directory: /
2946
- schedule:
2947
- interval: weekly
2948
- groups:
2949
- security-patches:
2950
- applies-to: security-updates
2951
- patterns:
2952
- - "*"
2953
- all-dependencies:
2954
- update-types:
2955
- - minor
2956
- - patch
2957
-
2958
- - package-ecosystem: github-actions
2959
- directory: /
2960
- schedule:
2961
- interval: weekly
2962
- groups:
2963
- all-actions:
2964
- patterns:
2965
- - "*"
2966
- `;
2973
+ /**
2974
+ * `holocron setup`orchestrates per-capability setup actions across
2975
+ * every plugin loaded from `holocron.config.json`.
2976
+ *
2977
+ * Per CLAUDE.md soft-skip: each step is wrapped in a try/catch and
2978
+ * failures don't abort subsequent capabilities. The summary at the end
2979
+ * reports counts so the operator can see what worked + what didn't.
2980
+ *
2981
+ * Per the Standards: when `ctx.dryRun` is true, mutating calls are
2982
+ * replaced with "would" log lines. Read-only probes (e.g.,
2983
+ * `vault.list`) still run so the operator sees real state.
2984
+ *
2985
+ * The orchestrator knows about specific capability methods by name
2986
+ * (e.g., `source.enableVulnerabilityAlerts`). This deliberate coupling
2987
+ * makes the "what does setup do" contract explicit and concrete —
2988
+ * decoupling via a per-capability `setupSteps()` method would be more
2989
+ * extensible but pushes the same knowledge into N plugins instead of
2990
+ * one central place.
2991
+ */
2992
+ function editorconfigContent() {
2993
+ return [
2994
+ workflowHeader("packages/cli/src/commands/setup.ts"),
2995
+ `root = true`,
2996
+ ``,
2997
+ `[*]`,
2998
+ `end_of_line = lf`,
2999
+ `charset = utf-8`,
3000
+ `trim_trailing_whitespace = true`,
3001
+ `insert_final_newline = true`,
3002
+ `indent_style = tab`,
3003
+ `indent_size = 4`,
3004
+ ``,
3005
+ `[.gitattributes]`,
3006
+ `indent_style = space`,
3007
+ `indent_size = 2`,
3008
+ ``,
3009
+ `[*.{json,yml,yaml}]`,
3010
+ `indent_style = space`,
3011
+ `indent_size = 2`,
3012
+ ``,
3013
+ `[*.md]`,
3014
+ `trim_trailing_whitespace = false`,
3015
+ `indent_style = space`,
3016
+ `indent_size = 2`,
3017
+ ``,
3018
+ `[.*{rc,ignore}]`,
3019
+ `indent_style = space`,
3020
+ `indent_size = 2`,
3021
+ ``
3022
+ ].join("\n");
3023
+ }
3024
+ const INDIVIDUAL_COMPONENTS_MARKER = " individual_components:";
3025
+ function codecovComponentBlock(packages) {
3026
+ if (packages.length === 0) return "\n []\n";
3027
+ return "\n" + packages.flatMap(({ slug }) => [
3028
+ ` - component_id: ${slug}`,
3029
+ ` name: "${slug}"`,
3030
+ ` paths:`,
3031
+ ` - packages/${slug}/**`,
3032
+ ``
3033
+ ]).join("\n");
3034
+ }
3035
+ function mergeCodecovComponents(existing, packages) {
3036
+ const idx = existing.indexOf(INDIVIDUAL_COMPONENTS_MARKER);
3037
+ if (idx === -1) return existing;
3038
+ return existing.slice(0, idx + 24) + codecovComponentBlock(packages);
3039
+ }
3040
+ function codecovContent(packages) {
3041
+ return [
3042
+ workflowHeader("packages/cli/src/commands/setup.ts"),
3043
+ `codecov:`,
3044
+ ` require_ci_to_pass: true`,
3045
+ ``,
3046
+ `coverage:`,
3047
+ ` precision: 2`,
3048
+ ` round: down`,
3049
+ ` status:`,
3050
+ ` project:`,
3051
+ ` default:`,
3052
+ ` target: auto`,
3053
+ ` threshold: 2%`,
3054
+ ` patch:`,
3055
+ ` default:`,
3056
+ ` target: 80%`,
3057
+ ``,
3058
+ `comment:`,
3059
+ ` layout: "reach,diff,flags,components"`,
3060
+ ` behavior: default`,
3061
+ ` require_changes: true`,
3062
+ ``,
3063
+ `component_management:`,
3064
+ ` default_rules:`,
3065
+ ` statuses:`,
3066
+ ` - type: patch`,
3067
+ ` target: 80%`,
3068
+ ` individual_components:`
3069
+ ].join("\n") + codecovComponentBlock(packages);
3070
+ }
3071
+ async function readWorkspacePackages(repoRoot) {
3072
+ const packagesDir = join(repoRoot, "packages");
3073
+ const entries = await readdir(packagesDir, { withFileTypes: true }).catch(() => null);
3074
+ if (!entries) return [];
3075
+ const packages = [];
3076
+ for (const entry of entries) {
3077
+ if (!entry.isDirectory()) continue;
3078
+ try {
3079
+ const raw = await readFile(join(packagesDir, entry.name, "package.json"), "utf8");
3080
+ const pkg = JSON.parse(raw);
3081
+ if (typeof pkg.name === "string") packages.push({
3082
+ slug: entry.name,
3083
+ name: pkg.name
3084
+ });
3085
+ } catch {}
3086
+ }
3087
+ return packages.sort((a, b) => a.slug.localeCompare(b.slug));
3088
+ }
3089
+ const EDITORCONFIG_CHECKER_CONFIG = JSON.stringify({
3090
+ Version: "v3.7.0",
3091
+ Verbose: false,
3092
+ Format: "",
3093
+ Debug: false,
3094
+ IgnoreDefaults: false,
3095
+ SpacesAfterTabs: false,
3096
+ NoColor: false,
3097
+ Exclude: ["(^|.+/)LICENSE$", "^public/.*"],
3098
+ AllowedContentTypes: [],
3099
+ PassedFiles: [],
3100
+ Disable: {
3101
+ EndOfLine: false,
3102
+ Indentation: false,
3103
+ InsertFinalNewline: false,
3104
+ TrimTrailingWhitespace: false,
3105
+ IndentSize: false,
3106
+ MaxLineLength: false
3107
+ }
3108
+ }, null, 2) + "\n";
3109
+ const ALEX_CONFIG = JSON.stringify({ allow: [
3110
+ "dead",
3111
+ "failure",
3112
+ "failures",
3113
+ "hook",
3114
+ "hooks",
3115
+ "husky",
3116
+ "period"
3117
+ ] }, null, 2) + "\n";
3118
+ const CANONICAL_LABELS = [
3119
+ {
3120
+ name: "bug",
3121
+ color: "d73a4a",
3122
+ description: "Something isn't working"
3123
+ },
3124
+ {
3125
+ name: "chore",
3126
+ color: "ededed",
3127
+ description: "Maintenance, no user-facing change"
3128
+ },
3129
+ {
3130
+ name: "ci",
3131
+ color: "0075ca",
3132
+ description: "CI/CD pipeline changes"
3133
+ },
3134
+ {
3135
+ name: "dependencies",
3136
+ color: "0366d6",
3137
+ description: "Dependency update"
3138
+ },
3139
+ {
3140
+ name: "documentation",
3141
+ color: "0075ca",
3142
+ description: "Documentation only"
3143
+ },
3144
+ {
3145
+ name: "duplicate",
3146
+ color: "cfd3d7",
3147
+ description: "Already reported"
3148
+ },
3149
+ {
3150
+ name: "enhancement",
3151
+ color: "a2eeef",
3152
+ description: "New feature or request"
3153
+ },
3154
+ {
3155
+ name: "good first issue",
3156
+ color: "7057ff",
3157
+ description: "Good for newcomers"
3158
+ },
3159
+ {
3160
+ name: "help wanted",
3161
+ color: "008672",
3162
+ description: "Extra attention needed"
3163
+ },
3164
+ {
3165
+ name: "invalid",
3166
+ color: "e4e669",
3167
+ description: "Doesn't seem right"
3168
+ },
3169
+ {
3170
+ name: "performance",
3171
+ color: "fbca04",
3172
+ description: "Performance improvement"
3173
+ },
3174
+ {
3175
+ name: "question",
3176
+ color: "d876e3",
3177
+ description: "Further information requested"
3178
+ },
3179
+ {
3180
+ name: "refactor",
3181
+ color: "cfd3d7",
3182
+ description: "Code restructuring"
3183
+ },
3184
+ {
3185
+ name: "released",
3186
+ color: "ededed",
3187
+ description: "Included in a release"
3188
+ },
3189
+ {
3190
+ name: "test",
3191
+ color: "bfd4f2",
3192
+ description: "Test-related changes"
3193
+ },
3194
+ {
3195
+ name: "triage",
3196
+ color: "e4e669",
3197
+ description: "Needs investigation"
3198
+ },
3199
+ {
3200
+ name: "wontfix",
3201
+ color: "ffffff",
3202
+ description: "Won't be addressed"
3203
+ }
3204
+ ];
3205
+ const STALE_LABELS = [
3206
+ "github_actions",
3207
+ "javascript",
3208
+ "autorelease: pending",
3209
+ "autorelease: tagged",
3210
+ "released on @alpha"
3211
+ ];
3212
+ function labelerConfig() {
3213
+ return [
3214
+ workflowHeader("packages/cli/src/commands/setup.ts"),
3215
+ `bug:`,
3216
+ ` - '^fix'`,
3217
+ ``,
3218
+ `chore:`,
3219
+ ` - '^chore(?!\\(deps)'`,
3220
+ ``,
3221
+ `ci:`,
3222
+ ` - '^ci'`,
3223
+ ``,
3224
+ `dependencies:`,
3225
+ ` - '^chore\\(deps'`,
3226
+ ``,
3227
+ `documentation:`,
3228
+ ` - '^docs'`,
3229
+ ``,
3230
+ `enhancement:`,
3231
+ ` - '^feat'`,
3232
+ ``,
3233
+ `performance:`,
3234
+ ` - '^perf'`,
3235
+ ``,
3236
+ `refactor:`,
3237
+ ` - '^refactor'`,
3238
+ ``,
3239
+ `test:`,
3240
+ ` - '^test'`,
3241
+ ``
3242
+ ].join("\n");
3243
+ }
2967
3244
  const RULESET_NAME = "holocron-default-branch";
2968
3245
  const BALANCED_REPO_SETTINGS = {
2969
3246
  allow_squash_merge: true,
@@ -3005,7 +3282,7 @@ function buildRulesetPayload(requiredChecks = []) {
3005
3282
  dismiss_stale_reviews_on_push: false,
3006
3283
  require_code_owner_review: false,
3007
3284
  require_last_push_approval: false,
3008
- required_review_thread_resolution: false
3285
+ required_review_thread_resolution: true
3009
3286
  }
3010
3287
  }
3011
3288
  ];
@@ -3020,6 +3297,11 @@ function buildRulesetPayload(requiredChecks = []) {
3020
3297
  name: RULESET_NAME,
3021
3298
  target: "branch",
3022
3299
  enforcement: "active",
3300
+ bypass_actors: [{
3301
+ actor_id: 4,
3302
+ actor_type: "RepositoryRole",
3303
+ bypass_mode: "always"
3304
+ }],
3023
3305
  conditions: { ref_name: {
3024
3306
  include: ["~DEFAULT_BRANCH"],
3025
3307
  exclude: []
@@ -3053,7 +3335,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
3053
3335
  message: "created"
3054
3336
  };
3055
3337
  } catch (err) {
3056
- if (!(err instanceof ProviderApiError) || err.status !== 403) return {
3338
+ if (!(err instanceof ProviderApiError$1) || err.status !== 403) return {
3057
3339
  capability: "source",
3058
3340
  step,
3059
3341
  status: "fail",
@@ -3070,7 +3352,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
3070
3352
  message: `classic protection on ${repo.defaultBranch}`
3071
3353
  };
3072
3354
  } catch (err) {
3073
- if (err instanceof ProviderApiError && err.status === 403) return {
3355
+ if (err instanceof ProviderApiError$1 && err.status === 403) return {
3074
3356
  capability: "source",
3075
3357
  step,
3076
3358
  status: "skip",
@@ -3087,16 +3369,18 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
3087
3369
  async function runSetup(input) {
3088
3370
  const print = input.print ?? ((line) => console.log(line));
3089
3371
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3090
- await loader.load();
3372
+ await withSpinner("Loading plugins…", () => loader.load());
3091
3373
  const config = input.loaded.resolved;
3092
3374
  const dryRun = input.context.dryRun ?? false;
3093
3375
  const steps = [];
3094
- print(`Holocron setup ${config.project.name}${dryRun ? " (dry-run)" : ""}`);
3095
- print(` config: ${input.loaded.filepath}`);
3376
+ const repo = config.repo;
3377
+ const effectivePreset = repo?.protection;
3378
+ print(style.header(`Holocron setup — ${config.name}${dryRun ? " (dry-run)" : ""}`));
3379
+ print(style.dim(` config: ${input.loaded.filepath}`));
3096
3380
  print("");
3097
3381
  if (loader.has("source")) {
3098
3382
  const source = loader.get("source");
3099
- print("source");
3383
+ print(style.step("source"));
3100
3384
  for (const method of [
3101
3385
  "enableVulnerabilityAlerts",
3102
3386
  "enableAutomatedSecurityFixes",
@@ -3109,27 +3393,37 @@ async function runSetup(input) {
3109
3393
  }));
3110
3394
  print(formatStep(steps[steps.length - 1]));
3111
3395
  }
3112
- steps.push(await runStep("source", "enableCodeScanning", dryRun, async () => {
3113
- return await source.enableCodeScanning();
3396
+ const usesAdvancedCodeQL = (config.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("codeql");
3397
+ steps.push(await runStep("source", usesAdvancedCodeQL ? "disableDefaultCodeScanning" : "enableCodeScanning", dryRun, async () => {
3398
+ if (usesAdvancedCodeQL) await source.disableDefaultCodeScanning();
3399
+ else return await source.enableCodeScanning();
3114
3400
  }));
3115
3401
  print(formatStep(steps[steps.length - 1]));
3116
- const policy = config.project.repoPolicy;
3117
- if (policy && policy.preset !== "none") {
3118
- const preset = policy.preset ?? "balanced";
3402
+ if (effectivePreset && effectivePreset !== "none") {
3119
3403
  steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
3120
3404
  await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
3121
3405
  }));
3122
3406
  print(formatStep(steps[steps.length - 1]));
3123
- const requiredChecks = preset === "strict" ? policy.requiredChecks ?? [] : [];
3407
+ const configuredWorkflowNames = (config.workflows ?? []).map((entry) => typeof entry === "string" ? entry : entry.name);
3408
+ const requiredChecks = effectivePreset === "strict" ? [
3409
+ "DCO",
3410
+ ...configuredWorkflowNames.flatMap((name) => {
3411
+ const ctx = WORKFLOW_CHECK_CONTEXTS[name];
3412
+ return ctx ? [ctx] : [];
3413
+ }),
3414
+ ...repo?.requiredChecks ?? []
3415
+ ] : [];
3124
3416
  steps.push(await upsertBranchProtection(source, dryRun, requiredChecks));
3125
3417
  print(formatStep(steps[steps.length - 1]));
3126
3418
  }
3127
3419
  }
3128
- const workflows = config.project.workflows;
3420
+ const workflows = config.workflows;
3129
3421
  if (loader.has("source") && workflows && workflows.length > 0) {
3130
3422
  const source = loader.get("source");
3131
- print("workflows");
3132
- for (const name of workflows) {
3423
+ print(style.step("workflows"));
3424
+ for (const entry of workflows) {
3425
+ const name = typeof entry === "string" ? entry : entry.name;
3426
+ const withOverrides = typeof entry === "object" ? entry.with : void 0;
3133
3427
  if (!KNOWN_WORKFLOWS.has(name)) {
3134
3428
  steps.push({
3135
3429
  capability: "source",
@@ -3141,21 +3435,113 @@ async function runSetup(input) {
3141
3435
  continue;
3142
3436
  }
3143
3437
  steps.push(await runStep("source", `write workflow ${name}`, dryRun, async () => {
3144
- await source.writeWorkflowFile(`${name}.yml`, WORKFLOW_HEADER + WORKFLOW_TEMPLATES[name]);
3438
+ await source.writeWorkflowFile(`${name}.yml`, workflowHeader() + generateThinCallerContent(name, withOverrides));
3145
3439
  }));
3146
3440
  print(formatStep(steps[steps.length - 1]));
3147
3441
  }
3148
3442
  }
3149
- if (loader.has("source") && config.project.repoPolicy?.preset !== "none") {
3443
+ if (loader.has("source") && (config.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping")) {
3444
+ const source = loader.get("source");
3445
+ steps.push(await runStep("source", "write .github/labeler.yml", dryRun, async () => {
3446
+ await source.writeRepoFile(".github/labeler.yml", labelerConfig());
3447
+ }));
3448
+ print(formatStep(steps[steps.length - 1]));
3449
+ }
3450
+ if (loader.has("source") && effectivePreset !== "none") {
3150
3451
  const source = loader.get("source");
3151
3452
  steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
3152
- await source.writeRepoFile(".github/dependabot.yml", DEPENDABOT_CONFIG);
3453
+ await source.writeRepoFile(".github/dependabot.yml", dependabot_default);
3153
3454
  }));
3154
3455
  print(formatStep(steps[steps.length - 1]));
3155
3456
  }
3156
- if (loader.has("environments")) {
3157
- const envs = loader.get("environments");
3158
- print(" environments");
3457
+ if (loader.has("source")) {
3458
+ const source = loader.get("source");
3459
+ steps.push(await runStep("source", "write .alexrc.json", dryRun, async () => {
3460
+ await source.writeRepoFile(".alexrc.json", ALEX_CONFIG);
3461
+ }));
3462
+ print(formatStep(steps[steps.length - 1]));
3463
+ steps.push(await runStep("source", "write .editorconfig", dryRun, async () => {
3464
+ await source.writeRepoFile(".editorconfig", editorconfigContent());
3465
+ }));
3466
+ print(formatStep(steps[steps.length - 1]));
3467
+ steps.push(await runStep("source", "write .editorconfig-checker.json", dryRun, async () => {
3468
+ await source.writeRepoFile(".editorconfig-checker.json", EDITORCONFIG_CHECKER_CONFIG);
3469
+ }));
3470
+ print(formatStep(steps[steps.length - 1]));
3471
+ {
3472
+ const packages = await readWorkspacePackages(input.context.repoRoot);
3473
+ const existing = await readFile(join(input.context.repoRoot, "codecov.yml"), "utf8").catch(() => null);
3474
+ if (packages.length === 0 && existing == null) steps.push({
3475
+ capability: "source",
3476
+ step: "write codecov.yml",
3477
+ status: "skip",
3478
+ message: "no packages and no existing codecov.yml"
3479
+ });
3480
+ else steps.push(await runStep("source", "write codecov.yml", dryRun, async () => {
3481
+ const content = existing != null ? mergeCodecovComponents(existing, packages) : codecovContent(packages);
3482
+ await source.writeRepoFile("codecov.yml", content);
3483
+ return packages.length > 0 ? `${packages.length} components` : "no components";
3484
+ }));
3485
+ print(formatStep(steps[steps.length - 1]));
3486
+ }
3487
+ if (source.syncLabels) {
3488
+ steps.push(await runStep("source", "sync labels", dryRun, async () => {
3489
+ return source.syncLabels(CANONICAL_LABELS, STALE_LABELS);
3490
+ }));
3491
+ print(formatStep(steps[steps.length - 1]));
3492
+ }
3493
+ const properties = {};
3494
+ if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
3495
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
3496
+ properties["monorepo"] = String(isMonorepo);
3497
+ const manual = repo?.properties ?? {};
3498
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
3499
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
3500
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
3501
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
3502
+ if (source.syncProperties) {
3503
+ steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
3504
+ print(formatStep(steps[steps.length - 1]));
3505
+ }
3506
+ const topics = repo?.topics ?? [];
3507
+ if (topics.length > 0 && source.syncTopics) {
3508
+ steps.push(await runStep("source", "sync topics", dryRun, () => source.syncTopics(topics)));
3509
+ print(formatStep(steps[steps.length - 1]));
3510
+ }
3511
+ const teams = repo?.teams ?? [];
3512
+ if (teams.length > 0) if (source.syncTeams) {
3513
+ steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams)));
3514
+ print(formatStep(steps[steps.length - 1]));
3515
+ const repoCoord = input.context.repo ?? repo?.name ?? "";
3516
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
3517
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
3518
+ slug: t,
3519
+ permission: "push"
3520
+ } : t).filter((t) => [
3521
+ "push",
3522
+ "maintain",
3523
+ "admin"
3524
+ ].includes(t.permission));
3525
+ if (org && writeableTeams.length > 0) {
3526
+ steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
3527
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
3528
+ await source.writeRepoFile(".github/CODEOWNERS", content);
3529
+ }));
3530
+ print(formatStep(steps[steps.length - 1]));
3531
+ }
3532
+ } else {
3533
+ steps.push({
3534
+ capability: "source",
3535
+ step: "sync teams",
3536
+ status: "skip",
3537
+ message: "provider does not implement syncTeams"
3538
+ });
3539
+ print(formatStep(steps[steps.length - 1]));
3540
+ }
3541
+ }
3542
+ if (loader.has("environments")) {
3543
+ const envs = loader.get("environments");
3544
+ print(style.step("environments"));
3159
3545
  for (const envName of ["staging", "production"]) {
3160
3546
  steps.push(await runStep("environments", `upsert ${envName}`, dryRun, async () => {
3161
3547
  await envs.upsertEnvironment({ name: envName });
@@ -3165,15 +3551,15 @@ async function runSetup(input) {
3165
3551
  }
3166
3552
  if (loader.has("deployment")) {
3167
3553
  const deploy = loader.get("deployment");
3168
- print("deployment");
3169
- steps.push(await runStep("deployment", `ensureProject ${config.project.name}`, dryRun, async () => {
3170
- await deploy.ensureProject({ name: config.project.name });
3554
+ print(style.step("deployment"));
3555
+ steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
3556
+ await deploy.ensureProject({ name: config.name });
3171
3557
  }));
3172
3558
  print(formatStep(steps[steps.length - 1]));
3173
3559
  }
3174
3560
  if (loader.has("auth")) {
3175
3561
  const auth = loader.get("auth");
3176
- print("auth");
3562
+ print(style.step("auth"));
3177
3563
  if (auth.ensureWebhookApp) {
3178
3564
  steps.push(await runStep("auth", "ensureWebhookApp", dryRun, async () => {
3179
3565
  return `webhook ${(await auth.ensureWebhookApp()).alreadyExists ? "exists" : "created"}`;
@@ -3191,10 +3577,10 @@ async function runSetup(input) {
3191
3577
  }
3192
3578
  if (loader.has("vault")) {
3193
3579
  const vault = loader.get("vault");
3194
- print("vault");
3580
+ print(style.step("vault"));
3195
3581
  if (vault.ensureProject) {
3196
- steps.push(await runStep("vault", `ensureProject ${config.project.name}`, dryRun, async () => {
3197
- return `project ${(await vault.ensureProject(config.project.name)).alreadyExists ? "exists" : "created"}`;
3582
+ steps.push(await runStep("vault", `ensureProject ${config.name}`, dryRun, async () => {
3583
+ return `project ${(await vault.ensureProject(config.name)).alreadyExists ? "exists" : "created"}`;
3198
3584
  }));
3199
3585
  print(formatStep(steps[steps.length - 1]));
3200
3586
  }
@@ -3204,7 +3590,7 @@ async function runSetup(input) {
3204
3590
  "prd"
3205
3591
  ]) {
3206
3592
  steps.push(await runStep("vault", `ensureEnvironment ${envName}`, dryRun, async () => {
3207
- return `${envName} ${(await vault.ensureEnvironment(config.project.name, envName)).alreadyExists ? "exists" : "created"}`;
3593
+ return `${envName} ${(await vault.ensureEnvironment(config.name, envName)).alreadyExists ? "exists" : "created"}`;
3208
3594
  }));
3209
3595
  print(formatStep(steps[steps.length - 1]));
3210
3596
  }
@@ -3228,7 +3614,7 @@ async function runSetup(input) {
3228
3614
  }
3229
3615
  if (loader.has("tooling")) {
3230
3616
  const tools = loader.get("tooling");
3231
- print("tooling");
3617
+ print(style.step("tooling"));
3232
3618
  for (const tool of tools) {
3233
3619
  steps.push(await runStep("tooling", `${tool.providerName}.sync`, dryRun, async () => {
3234
3620
  await tool.sync();
@@ -3236,6 +3622,23 @@ async function runSetup(input) {
3236
3622
  print(formatStep(steps[steps.length - 1]));
3237
3623
  }
3238
3624
  }
3625
+ if (config.skills && config.skills.length > 0 && config.agent) {
3626
+ print(style.step("skills"));
3627
+ if (!(config.agent in AGENT_SYMLINK_PATHS)) steps.push({
3628
+ capability: "skills",
3629
+ step: "install skills",
3630
+ status: "skip",
3631
+ message: `agent "${config.agent}" has no known skill install path`
3632
+ });
3633
+ else steps.push(await runStep("skills", "install skills", dryRun, async () => {
3634
+ return await installSkills({
3635
+ agent: config.agent,
3636
+ skills: config.skills,
3637
+ repoRoot: input.context.repoRoot
3638
+ });
3639
+ }));
3640
+ print(formatStep(steps[steps.length - 1]));
3641
+ }
3239
3642
  const summary = steps.reduce((acc, s) => {
3240
3643
  if (s.status === "ok") acc.ok += 1;
3241
3644
  else if (s.status === "fail") acc.fail += 1;
@@ -3249,12 +3652,118 @@ async function runSetup(input) {
3249
3652
  dryRun: 0
3250
3653
  });
3251
3654
  print("");
3252
- print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
3655
+ const summaryLine = ` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`;
3656
+ print(summary.fail > 0 ? style.fail(summaryLine.trim()) : style.success(summaryLine.trim()));
3657
+ if (steps.some((s) => s.reason === "permissions")) {
3658
+ print("");
3659
+ print(style.warn("Some steps failed with 403 (insufficient token permissions)."));
3660
+ print(style.hint(" These operations require a temporary fine-grained PAT with the"));
3661
+ print(style.hint(" following repository permissions:"));
3662
+ print("");
3663
+ print(style.hint(" · Administration — read and write"));
3664
+ print(style.hint(" · Code scanning alerts — read and write"));
3665
+ print(style.hint(" · Contents — read and write"));
3666
+ print(style.hint(" · Secret scanning alerts — read and write"));
3667
+ print(style.hint(" · Workflows — read and write"));
3668
+ print(style.hint(" · Metadata — read (added automatically)"));
3669
+ print("");
3670
+ print(style.hint(" Create one at: https://github.com/settings/personal-access-tokens/new"));
3671
+ print(style.hint(" Then re-run: holocron setup --token <your-temp-pat>"));
3672
+ print(style.hint(" You can revoke it immediately after setup completes."));
3673
+ }
3253
3674
  return {
3254
3675
  steps,
3255
3676
  summary
3256
3677
  };
3257
3678
  }
3679
+ const AGENTS_SKILLS_ROOT = ".agents/skills";
3680
+ /** Relative path of the agent-specific symlink. undefined = unsupported agent. */
3681
+ const AGENT_SYMLINK_PATHS = { claude: (name) => `.claude/skills/${name}` };
3682
+ const GITIGNORE_BLOCK_START = "# managed by holocron setup — skills";
3683
+ const GITIGNORE_BLOCK_END = "# end managed by holocron setup — skills";
3684
+ async function installSkills({ agent, skills, repoRoot }) {
3685
+ const symlinkFn = AGENT_SYMLINK_PATHS[agent];
3686
+ if (!symlinkFn) return `agent "${agent}" has no known skill install path — skipping`;
3687
+ const require = createRequire(pathToFileURL(join(repoRoot, "package.json")));
3688
+ let skillsRoot;
3689
+ try {
3690
+ skillsRoot = dirname(require.resolve("@theholocron/skills/package.json"));
3691
+ } catch {
3692
+ throw new Error("@theholocron/skills not found — run: pnpm add -D @theholocron/skills");
3693
+ }
3694
+ const gitignorePath = join(repoRoot, ".gitignore");
3695
+ const existingContent = await readFile(gitignorePath, "utf8").catch(() => "");
3696
+ const previouslyInstalled = parsePreviousSkills(existingContent, symlinkFn);
3697
+ const currentSet = new Set(skills);
3698
+ const stale = previouslyInstalled.filter((n) => !currentSet.has(n));
3699
+ for (const name of stale) {
3700
+ await rm(join(repoRoot, symlinkFn(name)), { force: true }).catch(() => void 0);
3701
+ await rm(join(repoRoot, AGENTS_SKILLS_ROOT, name), {
3702
+ recursive: true,
3703
+ force: true
3704
+ }).catch(() => void 0);
3705
+ }
3706
+ const installed = [];
3707
+ const missing = [];
3708
+ for (const name of skills) {
3709
+ const srcDir = join(skillsRoot, "skills", name);
3710
+ try {
3711
+ await stat(srcDir);
3712
+ } catch {
3713
+ missing.push(name);
3714
+ continue;
3715
+ }
3716
+ const agentsDir = join(repoRoot, AGENTS_SKILLS_ROOT, name);
3717
+ await copyDirRecursive(srcDir, agentsDir);
3718
+ const symlinkPath = join(repoRoot, symlinkFn(name));
3719
+ await mkdir(dirname(symlinkPath), { recursive: true });
3720
+ try {
3721
+ await unlink(symlinkPath);
3722
+ } catch {}
3723
+ await symlink(relative(dirname(symlinkPath), agentsDir).replace(/\\/g, "/"), symlinkPath);
3724
+ installed.push(name);
3725
+ }
3726
+ if (installed.length > 0 || stale.length > 0 || missing.length > 0) await updateSkillsGitignore(gitignorePath, existingContent, [...installed, ...missing], symlinkFn);
3727
+ const parts = [`installed ${installed.length}`];
3728
+ if (stale.length > 0) parts.push(`pruned: ${stale.join(", ")}`);
3729
+ if (missing.length > 0) parts.push(`unknown: ${missing.join(", ")}`);
3730
+ return parts.join("; ");
3731
+ }
3732
+ /** Extract skill names from the previous gitignore block so stale dirs can be pruned. */
3733
+ function parsePreviousSkills(gitignoreContent, symlinkFn) {
3734
+ if (!gitignoreContent.includes(GITIGNORE_BLOCK_START)) return [];
3735
+ const startIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_START);
3736
+ const endIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_END, startIdx);
3737
+ const block = endIdx !== -1 ? gitignoreContent.slice(startIdx, endIdx) : gitignoreContent.slice(startIdx);
3738
+ const placeholder = "__placeholder__";
3739
+ const symlinkPrefix = `/${symlinkFn(placeholder)}`.replace(placeholder, "");
3740
+ return block.split("\n").filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
3741
+ }
3742
+ async function copyDirRecursive(src, dest) {
3743
+ await mkdir(dest, { recursive: true });
3744
+ const entries = await readdir(src, { withFileTypes: true });
3745
+ for (const entry of entries) {
3746
+ const srcPath = join(src, entry.name);
3747
+ const destPath = join(dest, entry.name);
3748
+ if (entry.isDirectory()) await copyDirRecursive(srcPath, destPath);
3749
+ else await copyFile(srcPath, destPath);
3750
+ }
3751
+ }
3752
+ async function updateSkillsGitignore(gitignorePath, existingContent, skills, symlinkFn) {
3753
+ const block = [
3754
+ GITIGNORE_BLOCK_START,
3755
+ ...[`/${AGENTS_SKILLS_ROOT}/`, ...skills.map((n) => `/${symlinkFn(n)}`)],
3756
+ GITIGNORE_BLOCK_END
3757
+ ].join("\n");
3758
+ let content;
3759
+ if (existingContent.includes(GITIGNORE_BLOCK_START)) {
3760
+ const start = existingContent.indexOf(GITIGNORE_BLOCK_START);
3761
+ const end = existingContent.indexOf(GITIGNORE_BLOCK_END, start);
3762
+ const afterBlock = end !== -1 ? existingContent.slice(end + 40) : "\n";
3763
+ content = existingContent.slice(0, start) + block + afterBlock;
3764
+ } else content = (existingContent.trimEnd() ? existingContent.trimEnd() + "\n\n" : "") + block + "\n";
3765
+ await writeFile(gitignorePath, content, "utf8");
3766
+ }
3258
3767
  async function runStep(capability, step, dryRun, body) {
3259
3768
  if (dryRun) return {
3260
3769
  capability,
@@ -3271,6 +3780,16 @@ async function runStep(capability, step, dryRun, body) {
3271
3780
  if (typeof note === "string") result.message = note;
3272
3781
  return result;
3273
3782
  } catch (err) {
3783
+ if (err instanceof ProviderApiError$1 && err.status === 403) {
3784
+ const reason = classify403(err);
3785
+ return {
3786
+ capability,
3787
+ step,
3788
+ status: "fail",
3789
+ message: err.message,
3790
+ reason
3791
+ };
3792
+ }
3274
3793
  return {
3275
3794
  capability,
3276
3795
  step,
@@ -3279,26 +3798,382 @@ async function runStep(capability, step, dryRun, body) {
3279
3798
  };
3280
3799
  }
3281
3800
  }
3801
+ function classify403(err) {
3802
+ const detailText = typeof err.details === "string" ? err.details : typeof err.details === "object" && err.details !== null && "message" in err.details ? String(err.details.message) : "";
3803
+ const text = `${err.message} ${detailText}`.toLowerCase();
3804
+ if (text.includes("advanced security") || text.includes("not enabled for this repository") || text.includes("upgrade") || text.includes("not available on")) return "plan";
3805
+ return "permissions";
3806
+ }
3282
3807
  function formatStep(step) {
3808
+ const tag = step.reason === "permissions" ? " [permissions]" : step.reason === "plan" ? " [plan restriction]" : "";
3809
+ const detail = step.message ? style.dim(` (${step.message})`) : "";
3810
+ const label = `${step.step}${tag}${detail}`;
3811
+ if (step.status === "ok") return ` ${style.success(label)}`;
3812
+ if (step.status === "fail") return ` ${style.fail(label)}`;
3813
+ if (step.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3814
+ return ` ${style.dim(`· ${label}`)}`;
3815
+ }
3816
+ //#endregion
3817
+ //#region src/commands/skills.ts
3818
+ /**
3819
+ * `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
3820
+ *
3821
+ * Unlike `holocron setup`, this command is purely local (no GitHub token
3822
+ * required). It reads `agent` and `skills` from the config and installs
3823
+ * the listed skills into `.agents/skills/<name>/` with a symlink at the
3824
+ * agent-specific path (e.g. `.claude/skills/<name>` for Claude Code).
3825
+ *
3826
+ * `holocron skills remove [name]` and `holocron skills update [name]` delegate
3827
+ * to the corresponding `npx skills` subcommands from the upstream skills CLI.
3828
+ */
3829
+ const defaultExec = (cmd, args, opts) => {
3830
+ return { exitCode: spawnSync(cmd, args, {
3831
+ cwd: opts.cwd,
3832
+ stdio: "inherit"
3833
+ }).status ?? -1 };
3834
+ };
3835
+ async function runSkillsInstall(input) {
3836
+ const print = input.print ?? ((line) => console.log(line));
3837
+ const config = input.loaded.resolved;
3838
+ if (!config.agent || !config.skills?.length) {
3839
+ print("Nothing to install — set `agent` and `skills` in holocron.config.ts");
3840
+ return;
3841
+ }
3842
+ if (input.context.dryRun) {
3843
+ print(`Would install ${config.skills.length} skill(s) for agent: ${config.agent}`);
3844
+ for (const name of config.skills) print(` → would install: ${name}`);
3845
+ return;
3846
+ }
3847
+ print(`Installing ${config.skills.length} skill(s) for agent: ${config.agent}`);
3848
+ try {
3849
+ print(` → ${await installSkills({
3850
+ agent: config.agent,
3851
+ skills: config.skills,
3852
+ repoRoot: input.context.repoRoot
3853
+ })}`);
3854
+ } catch (err) {
3855
+ print(` ✗ ${err instanceof Error ? err.message : String(err)}`);
3856
+ }
3857
+ }
3858
+ function runSkillsRemove(input) {
3859
+ const { dryRun, repoRoot } = input.context;
3860
+ const exec = input.exec ?? defaultExec;
3861
+ const args = [
3862
+ "skills",
3863
+ "remove",
3864
+ ...input.names ?? []
3865
+ ];
3866
+ if (dryRun) {
3867
+ console.log(`Would run: npx ${args.join(" ")}`);
3868
+ return { status: "dry-run" };
3869
+ }
3870
+ const { exitCode } = exec("npx", args, { cwd: repoRoot });
3871
+ return { status: exitCode === 0 ? "ok" : "fail" };
3872
+ }
3873
+ function runSkillsUpdate(input) {
3874
+ const { dryRun, repoRoot } = input.context;
3875
+ const exec = input.exec ?? defaultExec;
3876
+ const args = [
3877
+ "skills",
3878
+ "update",
3879
+ ...input.name ? [input.name] : []
3880
+ ];
3881
+ if (dryRun) {
3882
+ console.log(`Would run: npx ${args.join(" ")}`);
3883
+ return { status: "dry-run" };
3884
+ }
3885
+ const { exitCode } = exec("npx", args, { cwd: repoRoot });
3886
+ return { status: exitCode === 0 ? "ok" : "fail" };
3887
+ }
3888
+ //#endregion
3889
+ //#region src/commands/sync.ts
3890
+ const SYNC_STEPS = [
3891
+ "labels",
3892
+ "properties",
3893
+ "teams",
3894
+ "topics",
3895
+ "keywords",
3896
+ "description"
3897
+ ];
3898
+ const LOCAL_STEPS = /* @__PURE__ */ new Set(["keywords", "description"]);
3899
+ async function runSync(input) {
3900
+ const print = input.print ?? ((line) => console.log(line));
3901
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3902
+ const config = input.loaded.resolved;
3903
+ const dryRun = input.context.dryRun ?? false;
3904
+ const requestedSteps = input.steps;
3905
+ const steps = [];
3906
+ if (!requestedSteps || requestedSteps.some((s) => !LOCAL_STEPS.has(s))) await loader.load();
3907
+ else try {
3908
+ await loader.load();
3909
+ } catch (err) {
3910
+ if (!(err instanceof AuthError)) throw err;
3911
+ }
3912
+ print(`Holocron sync — ${config.name}${dryRun ? " (dry-run)" : ""}`);
3913
+ print(` config: ${input.loaded.filepath}`);
3914
+ print("");
3915
+ if (loader.has("source")) {
3916
+ const source = loader.get("source");
3917
+ print(" → source");
3918
+ for (const stepName of SYNC_STEPS) {
3919
+ if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
3920
+ if (LOCAL_STEPS.has(stepName)) continue;
3921
+ if (stepName === "labels") if (source.syncLabels) {
3922
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
3923
+ print(formatSyncStep(steps[steps.length - 1]));
3924
+ } else {
3925
+ steps.push({
3926
+ capability: "source",
3927
+ step: "sync labels",
3928
+ status: "skip",
3929
+ message: "provider does not implement syncLabels"
3930
+ });
3931
+ print(formatSyncStep(steps[steps.length - 1]));
3932
+ }
3933
+ if (stepName === "properties") if (source.syncProperties) {
3934
+ const repo = config.repo;
3935
+ const properties = {};
3936
+ const effectivePreset = repo?.protection;
3937
+ if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
3938
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
3939
+ properties["monorepo"] = String(isMonorepo);
3940
+ const manual = repo?.properties ?? {};
3941
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
3942
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
3943
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
3944
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
3945
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
3946
+ print(formatSyncStep(steps[steps.length - 1]));
3947
+ } else {
3948
+ steps.push({
3949
+ capability: "source",
3950
+ step: "sync properties",
3951
+ status: "skip",
3952
+ message: "provider does not implement syncProperties"
3953
+ });
3954
+ print(formatSyncStep(steps[steps.length - 1]));
3955
+ }
3956
+ if (stepName === "teams") {
3957
+ const teams = config.repo?.teams ?? [];
3958
+ if (teams.length === 0) {
3959
+ steps.push({
3960
+ capability: "source",
3961
+ step: "sync teams",
3962
+ status: "skip",
3963
+ message: "no teams configured"
3964
+ });
3965
+ print(formatSyncStep(steps[steps.length - 1]));
3966
+ } else if (source.syncTeams) {
3967
+ steps.push(await runSyncStep("source", "sync teams", dryRun, () => source.syncTeams(teams)));
3968
+ print(formatSyncStep(steps[steps.length - 1]));
3969
+ const repoCoord = input.context.repo ?? config.repo?.name ?? "";
3970
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
3971
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
3972
+ slug: t,
3973
+ permission: "push"
3974
+ } : t).filter((t) => [
3975
+ "push",
3976
+ "maintain",
3977
+ "admin"
3978
+ ].includes(t.permission));
3979
+ if (org && writeableTeams.length > 0) {
3980
+ steps.push(await runSyncStep("source", "write .github/CODEOWNERS", dryRun, async () => {
3981
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
3982
+ await source.writeRepoFile(".github/CODEOWNERS", content);
3983
+ }));
3984
+ print(formatSyncStep(steps[steps.length - 1]));
3985
+ }
3986
+ } else {
3987
+ steps.push({
3988
+ capability: "source",
3989
+ step: "sync teams",
3990
+ status: "skip",
3991
+ message: "provider does not implement syncTeams"
3992
+ });
3993
+ print(formatSyncStep(steps[steps.length - 1]));
3994
+ }
3995
+ }
3996
+ if (stepName === "topics") {
3997
+ const topics = config.repo?.topics ?? [];
3998
+ if (topics.length === 0) {
3999
+ steps.push({
4000
+ capability: "source",
4001
+ step: "sync topics",
4002
+ status: "skip",
4003
+ message: "no topics configured"
4004
+ });
4005
+ print(formatSyncStep(steps[steps.length - 1]));
4006
+ } else if (source.syncTopics) {
4007
+ steps.push(await runSyncStep("source", "sync topics", dryRun, () => source.syncTopics(topics)));
4008
+ print(formatSyncStep(steps[steps.length - 1]));
4009
+ } else {
4010
+ steps.push({
4011
+ capability: "source",
4012
+ step: "sync topics",
4013
+ status: "skip",
4014
+ message: "provider does not implement syncTopics"
4015
+ });
4016
+ print(formatSyncStep(steps[steps.length - 1]));
4017
+ }
4018
+ }
4019
+ }
4020
+ if (requestedSteps) {
4021
+ for (const name of requestedSteps) if (!SYNC_STEPS.includes(name)) {
4022
+ steps.push({
4023
+ capability: "source",
4024
+ step: `sync ${name}`,
4025
+ status: "skip",
4026
+ message: `unknown step "${name}"`
4027
+ });
4028
+ print(formatSyncStep(steps[steps.length - 1]));
4029
+ }
4030
+ }
4031
+ }
4032
+ for (const stepName of ["keywords", "description"]) {
4033
+ if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
4034
+ if (stepName === "keywords") {
4035
+ const topics = config.repo?.topics ?? [];
4036
+ if (topics.length === 0) {
4037
+ steps.push({
4038
+ capability: "local",
4039
+ step: "sync keywords",
4040
+ status: "skip",
4041
+ message: "no topics configured"
4042
+ });
4043
+ print(formatSyncStep(steps[steps.length - 1]));
4044
+ } else {
4045
+ steps.push(await runSyncStep("local", "sync keywords", dryRun, async () => {
4046
+ return await writePackageJsonField(input.context.repoRoot, "keywords", topics) ? `${topics.length} keywords written` : `${topics.length} topics (no package.json)`;
4047
+ }));
4048
+ print(formatSyncStep(steps[steps.length - 1]));
4049
+ }
4050
+ }
4051
+ if (stepName === "description") {
4052
+ const description = config.description;
4053
+ if (!description) {
4054
+ steps.push({
4055
+ capability: "local",
4056
+ step: "sync description",
4057
+ status: "skip",
4058
+ message: "no description configured"
4059
+ });
4060
+ print(formatSyncStep(steps[steps.length - 1]));
4061
+ } else {
4062
+ const source = loader.has("source") ? loader.get("source") : null;
4063
+ steps.push(await runSyncStep("local", "sync description", dryRun, async () => {
4064
+ const pkgWrote = await writePackageJsonField(input.context.repoRoot, "description", description);
4065
+ const readmeWrote = await updateReadmeDescription(input.context.repoRoot, description);
4066
+ if (source?.syncDescription) await source.syncDescription(description);
4067
+ const parts = [];
4068
+ if (pkgWrote) parts.push("package.json");
4069
+ if (readmeWrote) parts.push("README.md");
4070
+ if (source?.syncDescription) parts.push("GitHub");
4071
+ return parts.length > 0 ? parts.join(", ") + " updated" : "description synced";
4072
+ }));
4073
+ print(formatSyncStep(steps[steps.length - 1]));
4074
+ }
4075
+ }
4076
+ }
4077
+ const summary = steps.reduce((acc, s) => {
4078
+ if (s.status === "ok") acc.ok += 1;
4079
+ else if (s.status === "fail") acc.fail += 1;
4080
+ else if (s.status === "skip") acc.skip += 1;
4081
+ else if (s.status === "dry-run") acc.dryRun += 1;
4082
+ return acc;
4083
+ }, {
4084
+ ok: 0,
4085
+ fail: 0,
4086
+ skip: 0,
4087
+ dryRun: 0
4088
+ });
4089
+ print("");
4090
+ print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
4091
+ return {
4092
+ steps,
4093
+ summary
4094
+ };
4095
+ }
4096
+ async function runSyncStep(capability, step, dryRun, body) {
4097
+ if (dryRun) return {
4098
+ capability,
4099
+ step,
4100
+ status: "dry-run"
4101
+ };
4102
+ try {
4103
+ const note = await body();
4104
+ const result = {
4105
+ capability,
4106
+ step,
4107
+ status: "ok"
4108
+ };
4109
+ if (typeof note === "string") result.message = note;
4110
+ return result;
4111
+ } catch (err) {
4112
+ return {
4113
+ capability,
4114
+ step,
4115
+ status: "fail",
4116
+ message: err instanceof Error ? err.message : String(err)
4117
+ };
4118
+ }
4119
+ }
4120
+ function formatSyncStep(step) {
3283
4121
  const icon = step.status === "ok" ? "✓" : step.status === "fail" ? "✗" : step.status === "dry-run" ? "…" : "·";
3284
4122
  const detail = step.message ? ` (${step.message})` : "";
3285
4123
  return ` ${icon} ${step.step}${detail}`;
3286
4124
  }
4125
+ async function writePackageJsonField(repoRoot, field, value) {
4126
+ const pkgPath = join(repoRoot, "package.json");
4127
+ let content;
4128
+ try {
4129
+ content = await readFile(pkgPath, "utf8");
4130
+ } catch {
4131
+ return false;
4132
+ }
4133
+ const pkg = JSON.parse(content);
4134
+ pkg[field] = value;
4135
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
4136
+ return true;
4137
+ }
4138
+ const README_DESC_START = "<!-- holocron:description -->";
4139
+ const README_DESC_END = "<!-- /holocron:description -->";
4140
+ async function updateReadmeDescription(repoRoot, description) {
4141
+ const readmePath = join(repoRoot, "README.md");
4142
+ let content;
4143
+ try {
4144
+ content = await readFile(readmePath, "utf8");
4145
+ } catch {
4146
+ return false;
4147
+ }
4148
+ const lines = content.split("\n");
4149
+ const startIdx = lines.findIndex((l) => l.trim() === README_DESC_START);
4150
+ const endIdx = lines.findIndex((l) => l.trim() === README_DESC_END);
4151
+ if (startIdx !== -1) {
4152
+ if (endIdx === -1 || endIdx <= startIdx) return false;
4153
+ lines.splice(startIdx + 1, endIdx - startIdx - 1, description);
4154
+ await writeFile(readmePath, lines.join("\n"), "utf8");
4155
+ return true;
4156
+ }
4157
+ const h1Index = lines.findIndex((l) => /^# /.test(l));
4158
+ if (h1Index === -1) return false;
4159
+ lines.splice(h1Index + 1, 0, "", README_DESC_START, description, README_DESC_END);
4160
+ await writeFile(readmePath, lines.join("\n"), "utf8");
4161
+ return true;
4162
+ }
3287
4163
  //#endregion
3288
4164
  //#region src/load-config.ts
3289
4165
  /**
3290
4166
  * `holocron.config.{json,js,ts}` file loader.
3291
4167
  *
3292
- * v2.0 only documents the JSON form, but the loader looks up all three
3293
- * extensions in priority order (json js ts) that's the schema
3294
- * commitment captured in [`.notes/tech-architecture.spec.md` Roadmap
3295
- * Shareable configs] so the JS/TS preset story (issue #75) can land
3296
- * later without a breaking change.
4168
+ * Search order: json js ts. JSON is parsed directly; JS is loaded
4169
+ * via native dynamic import; TS is loaded via `tsImport` from tsx (a
4170
+ * runtime dep) so operators can write typed configs with `defineConfig`
4171
+ * without needing a separate build step.
3297
4172
  *
3298
- * The JS/TS forms aren't actually parsed today; they trigger a clear
3299
- * error telling the operator to use JSON for v2.0. The contract is the
3300
- * lookup order, not the interpretation.
4173
+ * All three forms are validated through the same `resolveConfig` path.
4174
+ * Implements the lookup-order contract from issue #75 / #81.
3301
4175
  */
4176
+ const execFileAsync = promisify(execFile);
3302
4177
  const CANDIDATE_FILENAMES = [
3303
4178
  "holocron.config.json",
3304
4179
  "holocron.config.js",
@@ -3310,7 +4185,7 @@ var ConfigFileError = class extends Error {
3310
4185
  /**
3311
4186
  * Read + parse + resolve `holocron.config.*` from the given directory.
3312
4187
  * Search order: json → js → ts. Throws `ConfigFileError` if nothing
3313
- * found, or `ConfigError` if the JSON is malformed / invalid.
4188
+ * found, or `ConfigError` if the config is malformed / invalid.
3314
4189
  */
3315
4190
  async function loadConfig(cwd) {
3316
4191
  for (const filename of CANDIDATE_FILENAMES) {
@@ -3320,7 +4195,14 @@ async function loadConfig(cwd) {
3320
4195
  resolved: await loadJson(fullPath),
3321
4196
  filepath: fullPath
3322
4197
  };
3323
- throw new ConfigFileError(`${filename} found, but v2.0 only supports the JSON form. Rename to holocron.config.json. The JS/TS form lands with the preset feature (see issue #75).`);
4198
+ if (filename.endsWith(".ts")) return {
4199
+ resolved: await loadTs(fullPath),
4200
+ filepath: fullPath
4201
+ };
4202
+ return {
4203
+ resolved: await loadJs(fullPath),
4204
+ filepath: fullPath
4205
+ };
3324
4206
  }
3325
4207
  }
3326
4208
  throw new ConfigFileError(`no holocron.config.{json,js,ts} found in ${cwd}. Create one — see the README for the schema.`);
@@ -3333,7 +4215,61 @@ async function loadJson(filepath) {
3333
4215
  } catch (err) {
3334
4216
  throw new ConfigError(`${filepath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
3335
4217
  }
3336
- return resolveConfig(parsed);
4218
+ return resolveConfig(await deriveDefaults(dirname(filepath), parsed));
4219
+ }
4220
+ async function loadJs(filepath) {
4221
+ const mod = await import(pathToFileURL(filepath).href);
4222
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
4223
+ }
4224
+ async function loadTs(filepath) {
4225
+ const { tsImport } = await import("tsx/esm/api");
4226
+ const mod = await tsImport(pathToFileURL(filepath).href, import.meta.url);
4227
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
4228
+ }
4229
+ function extractRaw(filepath, mod) {
4230
+ const outer = mod.default;
4231
+ const raw = outer?.__esModule === true ? outer.default : outer;
4232
+ if (raw === void 0 || raw === null) throw new ConfigFileError(`${filepath} must have a default export (use \`export default defineConfig({…})\`)`);
4233
+ return raw;
4234
+ }
4235
+ async function deriveDefaults(configDir, raw) {
4236
+ const result = { ...raw };
4237
+ if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
4238
+ if (result.repo && !result.repo.name) {
4239
+ const repoName = await readGitRemote(configDir);
4240
+ if (repoName) result.repo = {
4241
+ ...result.repo,
4242
+ name: repoName
4243
+ };
4244
+ }
4245
+ return result;
4246
+ }
4247
+ async function readPackageJsonName(dir) {
4248
+ try {
4249
+ const content = await readFile(join(dir, "package.json"), "utf8");
4250
+ const pkg = JSON.parse(content);
4251
+ return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
4252
+ } catch {
4253
+ return;
4254
+ }
4255
+ }
4256
+ async function readGitRemote(dir) {
4257
+ try {
4258
+ const { stdout } = await execFileAsync("git", [
4259
+ "remote",
4260
+ "get-url",
4261
+ "origin"
4262
+ ], { cwd: dir });
4263
+ return parseGitRemoteUrl(stdout.trim());
4264
+ } catch {
4265
+ return;
4266
+ }
4267
+ }
4268
+ function parseGitRemoteUrl(url) {
4269
+ const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
4270
+ if (httpsMatch) return httpsMatch[1];
4271
+ const sshMatch = url.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
4272
+ if (sshMatch) return sshMatch[1];
3337
4273
  }
3338
4274
  async function fileExists(path) {
3339
4275
  try {
@@ -3343,15 +4279,181 @@ async function fileExists(path) {
3343
4279
  }
3344
4280
  }
3345
4281
  //#endregion
4282
+ //#region src/token-args.ts
4283
+ var TokenParseError = class extends Error {
4284
+ name = "TokenParseError";
4285
+ };
4286
+ /**
4287
+ * Converts raw --token CLI values into a typed result.
4288
+ *
4289
+ * Bare form: --token ghp_xxx → { cliToken: "ghp_xxx" }
4290
+ * Keyed form: --token github=ghp_xxx → { cliTokens: { github: "ghp_xxx" } }
4291
+ * Mixed: --token github=ghp_xxx --token v_yyy
4292
+ * → { cliToken: "v_yyy", cliTokens: { github: "ghp_xxx" } }
4293
+ *
4294
+ * Values may contain "=" (e.g. base64 strings) — only the first "=" is treated as a separator.
4295
+ */
4296
+ function parseTokenArgs(tokens) {
4297
+ if (tokens.length === 0) return {};
4298
+ const cliTokens = {};
4299
+ const bare = [];
4300
+ for (const raw of tokens) {
4301
+ const eqIdx = raw.indexOf("=");
4302
+ if (eqIdx === -1) {
4303
+ bare.push(raw);
4304
+ continue;
4305
+ }
4306
+ const vendor = raw.slice(0, eqIdx);
4307
+ const value = raw.slice(eqIdx + 1);
4308
+ if (vendor.trim() === "") throw new TokenParseError(`invalid --token value "${raw}": vendor name must not be empty`);
4309
+ if (/\s/.test(vendor)) throw new TokenParseError(`invalid --token value "${raw}": vendor name must not contain whitespace`);
4310
+ if (value === "") throw new TokenParseError(`invalid --token value "${raw}": token value must not be empty`);
4311
+ cliTokens[vendor] = value;
4312
+ }
4313
+ if (bare.length > 1) throw new TokenParseError(`only one bare --token value is allowed; got ${bare.length.toString()} — use vendor=value form for multiple tokens`);
4314
+ const result = {};
4315
+ if (bare.length === 1) result.cliToken = bare[0];
4316
+ if (Object.keys(cliTokens).length > 0) result.cliTokens = cliTokens;
4317
+ return result;
4318
+ }
4319
+ //#endregion
4320
+ //#region src/update-notifier.ts
4321
+ const PACKAGE_NAME = "@theholocron/cli";
4322
+ const CACHE_TTL_MS = 1440 * 60 * 1e3;
4323
+ const FETCH_TIMEOUT_MS = 3e3;
4324
+ function getCacheDir() {
4325
+ return process.env["HOLOCRON_CACHE_DIR"] ?? join(homedir(), ".cache", "holocron");
4326
+ }
4327
+ function getCachePath() {
4328
+ return join(getCacheDir(), "update-check.json");
4329
+ }
4330
+ function readCache() {
4331
+ try {
4332
+ return JSON.parse(readFileSync(getCachePath(), "utf8"));
4333
+ } catch {
4334
+ return null;
4335
+ }
4336
+ }
4337
+ function writeCache(entry) {
4338
+ try {
4339
+ mkdirSync(getCacheDir(), { recursive: true });
4340
+ writeFileSync(getCachePath(), JSON.stringify(entry));
4341
+ } catch {}
4342
+ }
4343
+ async function fetchLatestVersion(channel) {
4344
+ try {
4345
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
4346
+ if (!res.ok) return null;
4347
+ const data = await res.json();
4348
+ return data["dist-tags"][channel] ?? data["dist-tags"]["latest"] ?? null;
4349
+ } catch {
4350
+ return null;
4351
+ }
4352
+ }
4353
+ function getChannel(version) {
4354
+ return /^[^-]+-([a-zA-Z]+)/.exec(version)?.[1] ?? "latest";
4355
+ }
4356
+ function isUpdateAvailable(current, latest) {
4357
+ const normalize = (v) => v.replace(/^v/, "");
4358
+ const c = normalize(current);
4359
+ const l = normalize(latest);
4360
+ if (c === l) return false;
4361
+ const splitPre = (v) => {
4362
+ const idx = v.indexOf("-");
4363
+ return idx === -1 ? [v, ""] : [v.slice(0, idx), v.slice(idx + 1)];
4364
+ };
4365
+ const [cRelease, cPre] = splitPre(c);
4366
+ const [lRelease, lPre] = splitPre(l);
4367
+ const parseRelease = (r) => r.split(".").map(Number);
4368
+ const cParts = parseRelease(cRelease);
4369
+ const lParts = parseRelease(lRelease);
4370
+ for (let i = 0; i < Math.max(cParts.length, lParts.length); i++) {
4371
+ const cv = cParts[i] ?? 0;
4372
+ const lv = lParts[i] ?? 0;
4373
+ if (lv > cv) return true;
4374
+ if (lv < cv) return false;
4375
+ }
4376
+ if (!lPre && cPre) return true;
4377
+ if (lPre && !cPre) return false;
4378
+ const cPreParts = cPre.split(".");
4379
+ const lPreParts = lPre.split(".");
4380
+ for (let i = 0; i < Math.max(cPreParts.length, lPreParts.length); i++) {
4381
+ const cv = cPreParts[i] ?? "";
4382
+ const lv = lPreParts[i] ?? "";
4383
+ const cvNum = Number(cv);
4384
+ const lvNum = Number(lv);
4385
+ if (!isNaN(cvNum) && !isNaN(lvNum)) {
4386
+ if (lvNum > cvNum) return true;
4387
+ if (lvNum < cvNum) return false;
4388
+ } else {
4389
+ if (lv > cv) return true;
4390
+ if (lv < cv) return false;
4391
+ }
4392
+ }
4393
+ return false;
4394
+ }
4395
+ function formatNotice(current, latest) {
4396
+ const installCmd = `npm install -g ${PACKAGE_NAME}`;
4397
+ const raw1 = `Update available: ${current} → ${latest}`;
4398
+ const raw2 = `Run ${installCmd} to update`;
4399
+ const width = Math.max(raw1.length, raw2.length) + 4;
4400
+ const bar = chalk.yellow("─".repeat(width));
4401
+ const border = chalk.yellow("│");
4402
+ const pad = (raw, styled) => `${border} ${styled}${" ".repeat(width - 2 - raw.length)} ${border}`;
4403
+ return [
4404
+ "",
4405
+ chalk.yellow(`╭${bar}╮`),
4406
+ pad(raw1, `Update available: ${chalk.dim(current)} → ${chalk.green(latest)}`),
4407
+ pad(raw2, `Run ${chalk.cyan(installCmd)} to update`),
4408
+ chalk.yellow(`╰${bar}╯`),
4409
+ ""
4410
+ ].join("\n");
4411
+ }
4412
+ async function checkForUpdates(currentVersion) {
4413
+ if (process.env["CI"] || process.env["NO_UPDATE_NOTIFIER"]) return null;
4414
+ const channel = getChannel(currentVersion);
4415
+ const cache = readCache();
4416
+ const now = Date.now();
4417
+ let latestVersion = null;
4418
+ if (cache && now - cache.checkedAt < CACHE_TTL_MS) latestVersion = cache.latestVersion;
4419
+ else {
4420
+ latestVersion = await fetchLatestVersion(channel);
4421
+ if (latestVersion) writeCache({
4422
+ latestVersion,
4423
+ checkedAt: now
4424
+ });
4425
+ }
4426
+ if (!latestVersion || !isUpdateAvailable(currentVersion, latestVersion)) return null;
4427
+ return () => {
4428
+ process.stderr.write(formatNotice(currentVersion, latestVersion) + "\n");
4429
+ };
4430
+ }
4431
+ //#endregion
3346
4432
  //#region src/cli.ts
3347
4433
  const { version: CLI_VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
4434
+ /** Parses --token values and returns the context spread, or null on parse error (exits with code 1). */
4435
+ function tokenContext(rawTokens) {
4436
+ if (!rawTokens?.length) return {};
4437
+ try {
4438
+ return parseTokenArgs(rawTokens);
4439
+ } catch (err) {
4440
+ if (err instanceof TokenParseError) {
4441
+ console.error(`--token: ${err.message}`);
4442
+ process.exitCode = 1;
4443
+ return null;
4444
+ }
4445
+ throw err;
4446
+ }
4447
+ }
4448
+ const updateCheckPromise = checkForUpdates(CLI_VERSION);
3348
4449
  await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [options]").option("dry-run", {
3349
4450
  type: "boolean",
3350
4451
  default: false,
3351
4452
  describe: "Print what would be mutated without calling capability mutators. Commands branch on this; read-only commands ignore it."
3352
4453
  }).option("token", {
3353
4454
  type: "string",
3354
- describe: "Vendor token passed to plugins as `cliToken`, taking precedence over env vars. Unambiguous for single-plugin commands; for multi-plugin flows pass per-vendor env vars instead."
4455
+ array: true,
4456
+ describe: "Vendor token override for plugins. Bare form: --token <value> (fallback for all plugins, single-plugin commands). Keyed form: --token vendor=value (targets a specific provider; repeat for each). Example: --token github=ghp_xxx --token vercel=v_yyy"
3355
4457
  }).option("cwd", {
3356
4458
  type: "string",
3357
4459
  default: process.cwd(),
@@ -3362,29 +4464,64 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3362
4464
  type: "string",
3363
4465
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
3364
4466
  }), async (argv) => {
4467
+ const tokens = tokenContext(argv.token);
4468
+ if (!tokens) return;
3365
4469
  if ((await runDoctor({
3366
4470
  loaded: await loadConfig(argv.cwd),
3367
4471
  context: {
3368
4472
  repoRoot: argv.cwd,
3369
4473
  dryRun: argv.dryRun,
3370
4474
  ...argv.repo ? { repo: argv.repo } : {},
3371
- ...argv.token ? { cliToken: argv.token } : {}
4475
+ ...tokens
3372
4476
  }
3373
4477
  })).summary.fail > 0) process.exitCode = 1;
3374
4478
  }).command("setup", "Apply infra setup actions across every configured capability", (y) => y.option("repo", {
3375
4479
  type: "string",
3376
4480
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
3377
4481
  }), async (argv) => {
4482
+ const tokens = tokenContext(argv.token);
4483
+ if (!tokens) return;
3378
4484
  if ((await runSetup({
3379
4485
  loaded: await loadConfig(argv.cwd),
3380
4486
  context: {
3381
4487
  repoRoot: argv.cwd,
3382
4488
  dryRun: argv.dryRun,
3383
4489
  ...argv.repo ? { repo: argv.repo } : {},
3384
- ...argv.token ? { cliToken: argv.token } : {}
4490
+ ...tokens
3385
4491
  }
3386
4492
  })).summary.fail > 0) process.exitCode = 1;
3387
- }).command("secret set <name> [value]", "Set a single secret via the configured `secrets` capability", (y) => y.positional("name", {
4493
+ }).command("skills", "Manage agent skills from the @theholocron/skills registry", (y) => y.command("install", "Copy skills from @theholocron/skills into .agents/ with agent symlinks", () => {}, async (argv) => {
4494
+ await runSkillsInstall({
4495
+ loaded: await loadConfig(argv.cwd),
4496
+ context: {
4497
+ repoRoot: argv.cwd,
4498
+ dryRun: argv.dryRun
4499
+ }
4500
+ });
4501
+ }).command("remove [names..]", "Remove installed skills via npx skills remove", (yy) => yy.positional("names", {
4502
+ type: "string",
4503
+ array: true,
4504
+ describe: "Skill name(s) to remove (omit to remove all installed skills)"
4505
+ }), (argv) => {
4506
+ if (runSkillsRemove({
4507
+ context: {
4508
+ repoRoot: argv.cwd,
4509
+ dryRun: argv.dryRun
4510
+ },
4511
+ ...argv.names?.length ? { names: argv.names } : {}
4512
+ }).status === "fail") process.exitCode = 1;
4513
+ }).command("update [name]", "Update installed skills to their latest upstream versions via npx skills update", (yy) => yy.positional("name", {
4514
+ type: "string",
4515
+ describe: "Skill name to update (omit to update all installed skills)"
4516
+ }), (argv) => {
4517
+ if (runSkillsUpdate({
4518
+ context: {
4519
+ repoRoot: argv.cwd,
4520
+ dryRun: argv.dryRun
4521
+ },
4522
+ ...argv.name ? { name: argv.name } : {}
4523
+ }).status === "fail") process.exitCode = 1;
4524
+ }).demandCommand(1, "Run `holocron skills --help` to see available skills subcommands."), () => {}).command("secret set <name> [value]", "Set a single secret via the configured `secrets` capability", (y) => y.positional("name", {
3388
4525
  type: "string",
3389
4526
  demandOption: true,
3390
4527
  describe: "Secret name (e.g., NPM_TOKEN)"
@@ -3403,6 +4540,8 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3403
4540
  default: "repo",
3404
4541
  describe: "Scope: \"repo\" (default), \"env=<name>\", or \"org=<name>\""
3405
4542
  }), async (argv) => {
4543
+ const tokens = tokenContext(argv.token);
4544
+ if (!tokens) return;
3406
4545
  const scopeArg = argv.scope;
3407
4546
  const scope = parseScope(scopeArg);
3408
4547
  if ((await runSecretSet({
@@ -3410,7 +4549,7 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3410
4549
  context: {
3411
4550
  repoRoot: argv.cwd,
3412
4551
  dryRun: argv.dryRun,
3413
- ...argv.token ? { cliToken: argv.token } : {}
4552
+ ...tokens
3414
4553
  },
3415
4554
  name: argv.name,
3416
4555
  ...argv.value ? { value: argv.value } : {},
@@ -3430,12 +4569,14 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3430
4569
  default: ["production", "preview"],
3431
4570
  describe: "Deployment targets to sync to. Defaults to production + preview."
3432
4571
  }), async (argv) => {
4572
+ const tokens = tokenContext(argv.token);
4573
+ if (!tokens) return;
3433
4574
  if ((await runSecretsSync({
3434
4575
  loaded: await loadConfig(argv.cwd),
3435
4576
  context: {
3436
4577
  repoRoot: argv.cwd,
3437
4578
  dryRun: argv.dryRun,
3438
- ...argv.token ? { cliToken: argv.token } : {}
4579
+ ...tokens
3439
4580
  },
3440
4581
  environmentId: argv.environmentId,
3441
4582
  ...argv.projectId ? { projectId: argv.projectId } : {},
@@ -3454,12 +4595,14 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3454
4595
  choices: ["production", "staging"],
3455
4596
  describe: "Named environment to deploy into. Omit for a branch preview."
3456
4597
  }), async (argv) => {
4598
+ const tokens = tokenContext(argv.token);
4599
+ if (!tokens) return;
3457
4600
  if ((await runDeploy({
3458
4601
  loaded: await loadConfig(argv.cwd),
3459
4602
  context: {
3460
4603
  repoRoot: argv.cwd,
3461
4604
  dryRun: argv.dryRun,
3462
- ...argv.token ? { cliToken: argv.token } : {}
4605
+ ...tokens
3463
4606
  },
3464
4607
  projectId: argv.projectId,
3465
4608
  branch: argv.branch,
@@ -3489,15 +4632,48 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3489
4632
  dryRun: argv.dryRun,
3490
4633
  ...argv.otp ? { otp: argv.otp } : {}
3491
4634
  })).status === "fail") process.exitCode = 1;
3492
- }).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", {
4635
+ }).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("sync [steps..]", "Sync state from config to the provider and local files (labels, properties, topics, keywords, description)", (y) => y.positional("steps", {
4636
+ type: "string",
4637
+ array: true,
4638
+ describe: "Steps to run: labels, properties, topics, keywords, description (default: all)"
4639
+ }).option("repo", {
4640
+ type: "string",
4641
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
4642
+ }), async (argv) => {
4643
+ const tokens = tokenContext(argv.token);
4644
+ if (!tokens) return;
4645
+ if ((await runSync({
4646
+ loaded: await loadConfig(argv.cwd),
4647
+ context: {
4648
+ repoRoot: argv.cwd,
4649
+ dryRun: argv.dryRun,
4650
+ ...argv.repo ? { repo: argv.repo } : {},
4651
+ ...tokens
4652
+ },
4653
+ ...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
4654
+ })).summary.fail > 0) process.exitCode = 1;
4655
+ }).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github via the GitHub API", (y) => y.option("repo", {
3493
4656
  type: "string",
3494
4657
  default: "theholocron/.github",
3495
4658
  describe: "Target org/repo (default: theholocron/.github)"
4659
+ }).option("branch", {
4660
+ type: "string",
4661
+ describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
4662
+ }).option("pr", {
4663
+ type: "boolean",
4664
+ default: false,
4665
+ describe: "Open a PR after pushing to --branch (no-op without --branch)"
3496
4666
  }).option("message", {
3497
4667
  type: "string",
3498
4668
  describe: "Commit message (default: chore: sync from theholocron/holocron)"
4669
+ }).option("output-dir", {
4670
+ type: "string",
4671
+ describe: "Write generated files to this local directory instead of pushing (for validation)"
3499
4672
  }), async (argv) => {
3500
- const token = argv.token ?? process.env.GITHUB_TOKEN ?? process.env.HOLOCRON_GITHUB_TOKEN;
4673
+ const outputDir = argv["output-dir"];
4674
+ const parsed = tokenContext(argv.token);
4675
+ if (!parsed) return;
4676
+ const token = outputDir ? "no-token-needed" : parsed.cliTokens?.["github"] ?? parsed.cliToken ?? process.env.GITHUB_TOKEN ?? process.env.HOLOCRON_GITHUB_TOKEN;
3501
4677
  if (!token) {
3502
4678
  console.error("sync-github: GitHub token required — pass --token or set GITHUB_TOKEN");
3503
4679
  process.exitCode = 1;
@@ -3507,11 +4683,80 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3507
4683
  token,
3508
4684
  repo: argv.repo,
3509
4685
  dryRun: argv.dryRun,
3510
- ...argv.message ? { message: argv.message } : {}
4686
+ ...argv.branch ? { branch: argv.branch } : {},
4687
+ ...argv.pr ? { createPr: true } : {},
4688
+ ...argv.message ? { message: argv.message } : {},
4689
+ ...outputDir ? { outputDir } : {}
3511
4690
  })).status === "fail") process.exitCode = 1;
3512
4691
  }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
3513
4692
  const loaded = await loadConfig(argv.cwd);
3514
4693
  console.log(JSON.stringify(loaded.resolved, null, 2));
4694
+ }).command("new [type] [name]", "Scaffold a new repo from a GitHub template (e.g. cli, react, nextjs, node, monorepo, base)", (y) => y.positional("type", {
4695
+ type: "string",
4696
+ describe: "Template type — maps to theholocron/<type>-template (e.g. cli, react, nextjs, node, monorepo, base)"
4697
+ }).positional("name", {
4698
+ type: "string",
4699
+ describe: "New repo name (kebab-case, e.g. my-tool)"
4700
+ }).option("description", {
4701
+ type: "string",
4702
+ describe: "Short description — replaces <description> placeholders in the template"
4703
+ }).option("org", {
4704
+ type: "string",
4705
+ default: "theholocron",
4706
+ describe: "GitHub org that owns the template and will own the new repo"
4707
+ }).option("verify", {
4708
+ type: "boolean",
4709
+ default: true,
4710
+ describe: "Run pnpm install after bootstrapping (default true; --no-verify skips)"
4711
+ }), async (argv) => {
4712
+ try {
4713
+ let type = argv.type;
4714
+ let name = argv.name;
4715
+ let description = argv.description;
4716
+ if (!type || !name || description === void 0) {
4717
+ const rl = createInterface({
4718
+ input: stdin,
4719
+ output: stdout
4720
+ });
4721
+ const ask = (question) => new Promise((resolve) => rl.question(` ${question} `, (answer) => resolve(answer.trim())));
4722
+ try {
4723
+ if (!type) {
4724
+ console.log(" Known types: base, cli, monorepo, nextjs, node, react");
4725
+ type = await ask("Template type:");
4726
+ }
4727
+ if (!name) name = await ask("Repo name (kebab-case):");
4728
+ if (description === void 0) description = await ask("Short description (Enter to skip):");
4729
+ } finally {
4730
+ rl.close();
4731
+ }
4732
+ }
4733
+ if (!type) {
4734
+ console.error("new: template type is required");
4735
+ process.exitCode = 1;
4736
+ return;
4737
+ }
4738
+ if (!name) {
4739
+ console.error("new: repo name is required");
4740
+ process.exitCode = 1;
4741
+ return;
4742
+ }
4743
+ if ((await runNew({
4744
+ type,
4745
+ name,
4746
+ ...description ? { description } : {},
4747
+ org: argv.org,
4748
+ dryRun: argv.dryRun,
4749
+ noVerify: !argv.verify,
4750
+ cwd: argv.cwd
4751
+ })).status === "fail") process.exitCode = 1;
4752
+ } catch (err) {
4753
+ if (err instanceof NewError) {
4754
+ console.error(`new: ${err.message}`);
4755
+ process.exitCode = 1;
4756
+ return;
4757
+ }
4758
+ throw err;
4759
+ }
3515
4760
  }).command("plugin create <slug> <vendor>", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
3516
4761
  type: "string",
3517
4762
  demandOption: true,
@@ -3536,15 +4781,40 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3536
4781
  type: "boolean",
3537
4782
  default: true,
3538
4783
  describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
3539
- }), (argv) => {
4784
+ }), async (argv) => {
3540
4785
  try {
3541
- 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.");
4786
+ const capabilityKeys = Object.keys(CARDINALITY).join(", ");
4787
+ const needsPrompt = !argv.capability || !argv.vendorEnv || !argv.baseUrl;
4788
+ let capability;
4789
+ let vendorEnv;
4790
+ let baseUrl;
4791
+ if (needsPrompt) {
4792
+ const rl = createInterface({
4793
+ input: stdin,
4794
+ output: stdout
4795
+ });
4796
+ const ask = (question) => new Promise((resolve) => rl.question(` ${question} `, (answer) => resolve(answer.trim())));
4797
+ try {
4798
+ if (!argv.capability) {
4799
+ console.log(` Available capabilities: ${capabilityKeys}`);
4800
+ capability = await ask("Capability:");
4801
+ } else capability = argv.capability;
4802
+ vendorEnv = argv.vendorEnv ? argv.vendorEnv : await ask(`Vendor-native env var for the ${argv.vendor} token (e.g. MYVENDOR_API_KEY):`);
4803
+ baseUrl = argv.baseUrl ? argv.baseUrl : await ask(`REST base URL for the ${argv.vendor} API (e.g. https://api.myvendor.com):`);
4804
+ } finally {
4805
+ rl.close();
4806
+ }
4807
+ } else {
4808
+ capability = argv.capability;
4809
+ vendorEnv = argv.vendorEnv;
4810
+ baseUrl = argv.baseUrl;
4811
+ }
3542
4812
  if (runPluginCreate({
3543
4813
  slug: argv.slug,
3544
4814
  vendorName: argv.vendor,
3545
- capability: argv.capability,
3546
- vendorEnv: argv.vendorEnv,
3547
- baseUrl: argv.baseUrl,
4815
+ capability,
4816
+ vendorEnv,
4817
+ baseUrl,
3548
4818
  ...argv.tokenEnv ? { tokenEnv: argv.tokenEnv } : {},
3549
4819
  dryRun: argv.dryRun,
3550
4820
  noVerify: !argv.verify,
@@ -3558,7 +4828,32 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3558
4828
  }
3559
4829
  throw err;
3560
4830
  }
3561
- }).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", {
4831
+ }).command("upgrade", "Upgrade toolchain version pins across the repo", (y) => y.command("node <to>", "Scan the repo and update every Node.js version pin to a new major", (yy) => yy.positional("to", {
4832
+ type: "number",
4833
+ demandOption: true,
4834
+ describe: "Target Node.js major version (e.g., 22)"
4835
+ }).option("from", {
4836
+ type: "number",
4837
+ describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
4838
+ }), async (argv) => {
4839
+ let extra = [];
4840
+ try {
4841
+ const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
4842
+ const upgradeNode = JSON.parse(raw).upgrade?.node;
4843
+ if (Array.isArray(upgradeNode?.extra)) extra = upgradeNode.extra;
4844
+ } catch {}
4845
+ const report = await runUpgradeNode({
4846
+ to: argv.to,
4847
+ ...argv.from != null ? { from: argv.from } : {},
4848
+ cwd: argv.cwd,
4849
+ dryRun: argv.dryRun,
4850
+ extra
4851
+ });
4852
+ if (report.status === "fail") {
4853
+ if (report.message) console.error(`upgrade node: ${report.message}`);
4854
+ process.exitCode = 1;
4855
+ }
4856
+ }).demandCommand(1, "Run `holocron upgrade --help` to see available upgrade subcommands."), () => {}).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", {
3562
4857
  type: "string",
3563
4858
  demandOption: true
3564
4859
  }).positional("token", { type: "string" }), async (argv) => {
@@ -3579,6 +4874,7 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
3579
4874
  }).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
3580
4875
  await runAuthList();
3581
4876
  }).demandCommand(1, "Run `holocron auth --help` to see available auth subcommands."), () => {}).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
4877
+ (await updateCheckPromise)?.();
3582
4878
  /**
3583
4879
  * Parse `--scope` strings: `repo` | `env=NAME` | `org=NAME`.
3584
4880
  */