@theholocron/cli 2.0.0-alpha.7 → 2.0.0-alpha.70

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,260 @@
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 { createHash } from "node:crypto";
14
+ import { createGitHubClient } from "@theholocron/github-client";
15
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
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
+ //#region src/capabilities/index.ts
20
+ const CARDINALITY = {
21
+ source: "single",
22
+ ci: "single",
23
+ secrets: "single",
24
+ environments: "single",
25
+ issues: "single",
26
+ deployment: "single",
27
+ storage: "single",
28
+ auth: "single",
29
+ vault: "single",
30
+ dns: "single",
31
+ tooling: "many",
32
+ notifications: "many",
33
+ analytics: "many",
34
+ observability: "many"
35
+ };
36
+ /**
37
+ * No capabilities are strictly required — repos without secrets (e.g. org
38
+ * community health repos) legitimately omit vault. Plugins validate their
39
+ * own requirements at call time.
40
+ */
41
+ const REQUIRED_CAPABILITIES = [];
42
+ //#endregion
43
+ //#region src/config.ts
44
+ /**
45
+ * `holocron.config.json` schema, parser, and provider resolution.
46
+ *
47
+ * ESLint-style entry forms:
48
+ *
49
+ * "source": "github" ← single, short
50
+ * "deployment": ["vercel", { team: "rando" }] ← single, with options
51
+ * "notifications": ["slack", "discord"] ← multi, short
52
+ * "notifications": [
53
+ * ["slack", { channel: "#ops" }],
54
+ * ["discord", { webhook: "env:HOOK" }]
55
+ * ] ← multi, with options
56
+ *
57
+ * Discriminator: an array entry is a `[provider, options]` tuple when
58
+ * the length is 2 AND element[1] is a non-array, non-null object.
59
+ * Otherwise it's a multi-provider list (string[] or tuple[]).
60
+ *
61
+ * Validation rules:
62
+ * - `vault` is REQUIRED (every project has secrets somewhere)
63
+ * - Entries for `'many'` capabilities are normalized to an array of
64
+ * normalized tuples; entries for `'single'` capabilities are
65
+ * normalized to one tuple
66
+ * - Tokens / secret values never appear in config — providers read
67
+ * them from env (or pull from `vault` at runtime)
68
+ */
69
+ var ConfigError = class extends Error {
70
+ name = "ConfigError";
71
+ };
72
+ const PLUGIN_PREFIX = "@theholocron/holocron-plugin-";
73
+ const COMMUNITY_PREFIX = "holocron-plugin-";
74
+ /**
75
+ * Resolve `"github"` → `"@theholocron/holocron-plugin-github"`.
76
+ * Fully-qualified names (scoped or not) are honored verbatim, which
77
+ * is how third-party plugins published outside the org work.
78
+ */
79
+ function resolvePluginPackage(provider) {
80
+ if (!provider) throw new ConfigError("provider name is empty");
81
+ if (provider.startsWith("@")) return provider;
82
+ if (provider.startsWith(COMMUNITY_PREFIX)) return provider;
83
+ if (provider.includes("/")) return provider;
84
+ return PLUGIN_PREFIX + provider;
85
+ }
86
+ /** A bare `[provider, options]` tuple, with both elements present? */
87
+ function isOptionsTuple(value) {
88
+ if (!Array.isArray(value)) return false;
89
+ if (value.length !== 2) return false;
90
+ if (typeof value[0] !== "string") return false;
91
+ const opt = value[1];
92
+ return typeof opt === "object" && opt !== null && !Array.isArray(opt);
93
+ }
94
+ function normalizeEntry(entry) {
95
+ if (typeof entry === "string") return {
96
+ provider: entry,
97
+ packageName: resolvePluginPackage(entry),
98
+ options: {}
99
+ };
100
+ const [provider, options] = entry;
101
+ return {
102
+ provider,
103
+ packageName: resolvePluginPackage(provider),
104
+ options
105
+ };
106
+ }
107
+ function resolveEntry(key, raw) {
108
+ const cardinality = CARDINALITY[key];
109
+ if (typeof raw === "string") {
110
+ if (cardinality === "many") throw new ConfigError(`\`${key}\` accepts multiple providers; wrap a single one in an array: ["${raw}"]`);
111
+ return {
112
+ cardinality: "single",
113
+ tuple: normalizeEntry(raw)
114
+ };
115
+ }
116
+ if (!Array.isArray(raw)) throw new ConfigError(`\`${key}\` entry must be a string or array, got ${typeof raw}`);
117
+ if (isOptionsTuple(raw)) {
118
+ if (cardinality === "many") return {
119
+ cardinality: "many",
120
+ tuples: [normalizeEntry(raw)]
121
+ };
122
+ return {
123
+ cardinality: "single",
124
+ tuple: normalizeEntry(raw)
125
+ };
126
+ }
127
+ if (cardinality === "single") throw new ConfigError(`\`${key}\` accepts exactly one provider; got a multi-provider list with ${raw.length} entries`);
128
+ return {
129
+ cardinality: "many",
130
+ tuples: raw.map((entry, idx) => {
131
+ if (typeof entry === "string") return normalizeEntry(entry);
132
+ if (isOptionsTuple(entry)) return normalizeEntry(entry);
133
+ throw new ConfigError(`\`${key}[${idx}]\` must be a provider string or [provider, options] tuple`);
134
+ })
135
+ };
136
+ }
137
+ function resolveConfig(raw) {
138
+ if (!raw.name) throw new ConfigError("`name` is required");
139
+ if (!raw.providers || typeof raw.providers !== "object") throw new ConfigError("`providers` block is required");
140
+ const providers = {};
141
+ for (const [key, entry] of Object.entries(raw.providers)) {
142
+ if (entry === void 0) continue;
143
+ providers[key] = resolveEntry(key, entry);
144
+ }
145
+ for (const required of REQUIRED_CAPABILITIES) if (!providers[required]) throw new ConfigError(`required capability \`${required}\` is missing from providers`);
146
+ return {
147
+ name: raw.name,
148
+ description: raw.description,
149
+ repo: raw.repo,
150
+ workflows: raw.workflows,
151
+ providers,
152
+ apps: raw.apps ?? [],
153
+ doctor: raw.doctor ?? {},
154
+ agent: raw.agent,
155
+ skills: raw.skills
156
+ };
157
+ }
158
+ //#endregion
159
+ //#region src/keyring.ts
160
+ /**
161
+ * Keyring-backed bootstrap credential store.
162
+ *
163
+ * Every holocron plugin's bootstrap token (the one it needs before it
164
+ * can talk to its vendor's API) can be stored in the OS keyring under
165
+ * a single reverse-DNS service scope. Managed via `holocron auth`
166
+ * subcommands; consulted at position 4 in every plugin's auth
167
+ * precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
168
+ *
169
+ * See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
170
+ *
171
+ * Failure model: keyring access is best-effort. Platforms without a
172
+ * supported credential store (some Linux CI images, sandboxed
173
+ * environments) will throw from the underlying library. Every export
174
+ * here catches and returns a null/empty result rather than propagating
175
+ * — the plugin's precedence chain then falls through to
176
+ * env-var-only paths, which is exactly how CI is meant to work.
177
+ */
178
+ const SERVICE = "com.theholocron.cli";
179
+ /**
180
+ * Store or overwrite a bootstrap token for a provider. Returns true on
181
+ * success, false when the underlying keyring is unsupported or errored.
182
+ */
183
+ function setToken(provider, token) {
184
+ try {
185
+ new Entry(SERVICE, provider).setPassword(token);
186
+ return true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+ /**
192
+ * Read the bootstrap token for a provider. Returns `null` for both
193
+ * "not stored" and "keyring unavailable" — callers can treat them the
194
+ * same way (fall through to env-var precedence).
195
+ */
196
+ function getToken(provider) {
197
+ try {
198
+ return new Entry(SERVICE, provider).getPassword();
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ /**
204
+ * Delete a stored token. Returns true when a token was removed, false
205
+ * when there was nothing to delete or the keyring is unavailable.
206
+ * Distinguishing the two cases isn't worth the surface area — the
207
+ * command output makes the situation clear either way.
208
+ */
209
+ function deleteToken(provider) {
210
+ try {
211
+ return new Entry(SERVICE, provider).deletePassword();
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+ /**
217
+ * List provider slugs with a stored token in this service scope.
218
+ * Uses the library's `findCredentials(service)` — supported on all
219
+ * platforms the underlying credential store supports.
220
+ */
221
+ function listStoredProviders() {
222
+ try {
223
+ return findCredentials(SERVICE).map((c) => c.account);
224
+ } catch {
225
+ return [];
226
+ }
227
+ }
228
+ //#endregion
229
+ //#region src/ui/progress.ts
230
+ /**
231
+ * Runs `fn`, showing an ora spinner for its duration in TTY environments.
232
+ * In non-TTY environments (CI, pipes, tests) the spinner is skipped entirely.
233
+ */
234
+ async function withSpinner(label, fn) {
235
+ if (!process.stdout.isTTY) return fn();
236
+ const spinner = ora(label).start();
237
+ try {
238
+ const result = await fn();
239
+ spinner.succeed();
240
+ return result;
241
+ } catch (err) {
242
+ spinner.fail();
243
+ throw err;
244
+ }
245
+ }
246
+ //#endregion
247
+ //#region src/ui/style.ts
248
+ const style = {
249
+ success: (msg) => `${chalk.green("✓")} ${msg}`,
250
+ warn: (msg) => `${chalk.yellow("⚠")} ${msg}`,
251
+ fail: (msg) => `${chalk.red("✗")} ${msg}`,
252
+ step: (msg) => `${chalk.cyan("→")} ${msg}`,
253
+ hint: (msg) => chalk.dim(msg),
254
+ dim: (msg) => chalk.dim(msg),
255
+ header: (msg) => chalk.bold(msg)
256
+ };
257
+ //#endregion
10
258
  //#region src/commands/auth.ts
11
259
  /**
12
260
  * `holocron auth <subcommand>` — manage bootstrap credentials in the
@@ -49,11 +297,11 @@ async function runAuthSet(input) {
49
297
  });
50
298
  const packageName = resolvePluginPackage(provider);
51
299
  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`);
300
+ print(style.fail(`no token supplied for \`${provider}\`.`));
301
+ print(style.hint(` pass as positional arg: holocron auth set ${provider} <token>`));
302
+ print(style.hint(` or via env: HOLOCRON_${provider.toUpperCase()}_TOKEN / ${provider.toUpperCase()}_TOKEN`));
55
303
  const hint = await tryLoadHint(importer, packageName);
56
- if (hint) print(` hint: ${hint}`);
304
+ if (hint) print(style.hint(` hint: ${hint}`));
57
305
  return {
58
306
  status: "fail",
59
307
  message: "no token supplied"
@@ -65,27 +313,28 @@ async function runAuthSet(input) {
65
313
  if (typeof module.verifyToken === "function") {
66
314
  const verified = await module.verifyToken(token);
67
315
  if (!verified.ok) {
68
- print(`token rejected by ${provider}: ${verified.message}`);
69
- if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
316
+ print(style.fail(`token rejected by ${provider}: ${verified.message}`));
317
+ if (module.AUTH_HINT) print(style.hint(` hint: ${module.AUTH_HINT}`));
70
318
  return {
71
319
  status: "fail",
72
320
  message: verified.message
73
321
  };
74
322
  }
75
323
  subject = verified.subject;
76
- } else print(`(${provider} plugin has no verifyToken; storing without verification)`);
324
+ } else print(style.warn(`${provider} plugin has no verifyToken; storing without verification`));
77
325
  } 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`);
326
+ const msg = err instanceof Error ? err.message : String(err);
327
+ print(style.warn(`cannot verify token failed to load ${packageName}: ${msg}`));
328
+ print(style.hint(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`));
80
329
  }
81
330
  if (!setToken(provider, token)) {
82
- print(`keyring unavailable — token not stored. Use env vars instead.`);
331
+ print(style.fail(`keyring unavailable — token not stored. Use env vars instead.`));
83
332
  return {
84
333
  status: "fail",
85
334
  message: "keyring unavailable"
86
335
  };
87
336
  }
88
- print(`stored ${provider} token${subject ? ` (${subject})` : ""}`);
337
+ print(style.success(`stored ${provider} token${subject ? ` (${subject})` : ""}`));
89
338
  return {
90
339
  status: "ok",
91
340
  ...subject ? { message: subject } : {}
@@ -94,10 +343,10 @@ async function runAuthSet(input) {
94
343
  function runAuthUnset(input) {
95
344
  const print = input.print ?? ((l) => console.log(l));
96
345
  if (deleteToken(input.provider)) {
97
- print(`removed ${input.provider} token`);
346
+ print(style.success(`removed ${input.provider} token`));
98
347
  return { status: "ok" };
99
348
  }
100
- print(`no stored token for ${input.provider}`);
349
+ print(style.dim(`no stored token for ${input.provider}`));
101
350
  return {
102
351
  status: "skip",
103
352
  message: "nothing to remove"
@@ -109,7 +358,7 @@ async function runAuthCheck(input) {
109
358
  const { provider } = input;
110
359
  const token = getToken(provider);
111
360
  if (!token) {
112
- print(`no stored token for ${provider}`);
361
+ print(style.dim(`no stored token for ${provider}`));
113
362
  return {
114
363
  status: "skip",
115
364
  message: "no stored token"
@@ -119,29 +368,30 @@ async function runAuthCheck(input) {
119
368
  try {
120
369
  const module = await importer(packageName);
121
370
  if (typeof module.verifyToken !== "function") {
122
- print(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`);
371
+ print(style.warn(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`));
123
372
  return {
124
373
  status: "ok",
125
374
  message: "stored, unverified"
126
375
  };
127
376
  }
128
- const verified = await module.verifyToken(token);
377
+ const verify = () => module.verifyToken(token);
378
+ const verified = input.showSpinner !== false ? await withSpinner(`Verifying ${provider} token…`, verify) : await verify();
129
379
  if (verified.ok) {
130
- print(`${provider}: ok — ${verified.subject}`);
380
+ print(style.success(`${provider}: ok — ${verified.subject}`));
131
381
  return {
132
382
  status: "ok",
133
383
  message: verified.subject
134
384
  };
135
385
  }
136
- print(`${provider}: rejected — ${verified.message}`);
137
- if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
386
+ print(style.fail(`${provider}: rejected — ${verified.message}`));
387
+ if (module.AUTH_HINT) print(style.hint(` hint: ${module.AUTH_HINT}`));
138
388
  return {
139
389
  status: "fail",
140
390
  message: verified.message
141
391
  };
142
392
  } catch (err) {
143
393
  const msg = err instanceof Error ? err.message : String(err);
144
- print(`${provider}: cannot verify — ${msg}`);
394
+ print(style.fail(`${provider}: cannot verify — ${msg}`));
145
395
  return {
146
396
  status: "fail",
147
397
  message: msg
@@ -153,8 +403,8 @@ async function runAuthList(input = {}) {
153
403
  const importer = input.importer ?? defaultImporter$1;
154
404
  const providers = listStoredProviders();
155
405
  if (providers.length === 0) {
156
- print("no stored tokens.");
157
- print("run: holocron auth set <provider> <token>");
406
+ print(style.dim("no stored tokens."));
407
+ print(style.hint("run: holocron auth set <provider> <token>"));
158
408
  return {
159
409
  status: "ok",
160
410
  message: "none"
@@ -164,9 +414,13 @@ async function runAuthList(input = {}) {
164
414
  const check = await runAuthCheck({
165
415
  provider,
166
416
  importer,
167
- print: () => {}
417
+ print: () => {},
418
+ showSpinner: false
168
419
  });
169
- print(` ${check.status === "ok" ? "✓" : check.status === "fail" ? "✗" : "·"} ${provider}${check.message ? ` — ${check.message}` : ""}`);
420
+ const label = `${provider}${check.message ? ` — ${check.message}` : ""}`;
421
+ if (check.status === "ok") print(` ${style.success(label)}`);
422
+ else if (check.status === "fail") print(` ${style.fail(label)}`);
423
+ else print(` ${style.dim(`· ${label}`)}`);
170
424
  }
171
425
  return { status: "ok" };
172
426
  }
@@ -225,17 +479,30 @@ var PluginLoader = class {
225
479
  }
226
480
  /** Internal — invoke a plugin's capability factory and return the impl. */
227
481
  async loadOne(key, tuple) {
228
- const module = await this.importer(tuple.packageName).catch((err) => {
482
+ const mod = await this.importer(tuple.packageName).catch((err) => {
229
483
  throw new LoaderError(`failed to import \`${tuple.packageName}\` for capability \`${key}\`: ${err instanceof Error ? err.message : String(err)}`);
230
484
  });
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();
485
+ if (isPluginModule(mod)) {
486
+ const factory = mod.createPlugin({
487
+ ...this.projectDefaults(),
488
+ ...this.context,
489
+ ...tuple.options
490
+ }).capabilities[key];
491
+ if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
492
+ return factory();
493
+ }
494
+ if (isCapabilityConfigModule(mod)) {
495
+ const cap = mod.default;
496
+ return this.loadOne(key, {
497
+ provider: cap.provider,
498
+ packageName: resolvePluginPackage(cap.provider),
499
+ options: {
500
+ ...cap.options,
501
+ ...tuple.options
502
+ }
503
+ });
504
+ }
505
+ throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\` or a capability config ({ provider, options? })`);
239
506
  }
240
507
  /**
241
508
  * Project-level defaults that get merged into every plugin's options
@@ -244,14 +511,20 @@ var PluginLoader = class {
244
511
  */
245
512
  projectDefaults() {
246
513
  const defaults = {};
247
- if (this.config.project.repo) defaults.repo = this.config.project.repo;
514
+ if (this.config.repo?.name) defaults.repo = this.config.repo.name;
248
515
  return defaults;
249
516
  }
250
517
  };
251
518
  /** Default importer — native dynamic import. */
252
519
  const defaultImporter = async (pkg) => {
253
- return await import(pkg);
520
+ return import(pkg);
254
521
  };
522
+ function isPluginModule(mod) {
523
+ return typeof mod.createPlugin === "function";
524
+ }
525
+ function isCapabilityConfigModule(mod) {
526
+ return typeof mod.default?.provider === "string";
527
+ }
255
528
  //#endregion
256
529
  //#region src/commands/deploy.ts
257
530
  async function runDeploy(input) {
@@ -259,12 +532,12 @@ async function runDeploy(input) {
259
532
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
260
533
  await loader.load();
261
534
  const dryRun = input.context.dryRun ?? false;
262
- print(`Holocron deploy — branch=${input.branch}${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`);
535
+ print(style.header(`Holocron deploy — branch=${input.branch}${input.target ? `, target=${input.target}` : " (preview)"}${dryRun ? " (dry-run)" : ""}`));
263
536
  if (!loader.has("deployment")) throw new Error("deployment capability is not configured — add a `deployment` provider to holocron.config.json");
264
537
  const deploy = loader.get("deployment");
265
538
  if (dryRun) {
266
539
  const message = `would: ${deploy.providerName}.triggerDeployment(projectId=${input.projectId}, branch=${input.branch}${input.target ? `, target=${input.target}` : ""})`;
267
- print(` ${message}`);
540
+ print(` ${style.dim(`… ${message}`)}`);
268
541
  return {
269
542
  deployment: null,
270
543
  status: "dry-run",
@@ -272,19 +545,19 @@ async function runDeploy(input) {
272
545
  };
273
546
  }
274
547
  try {
275
- const record = await deploy.triggerDeployment({
548
+ const record = await withSpinner(`Deploying ${input.branch}${input.target ? ` → ${input.target}` : ""}…`, () => deploy.triggerDeployment({
276
549
  projectId: input.projectId,
277
550
  branch: input.branch,
278
551
  ...input.target ? { target: input.target } : {}
279
- });
280
- print(` ${record.status} — ${record.url}`);
552
+ }));
553
+ print(` ${style.success(`${record.status} — ${record.url}`)}`);
281
554
  return {
282
555
  deployment: record,
283
556
  status: "ok"
284
557
  };
285
558
  } catch (err) {
286
559
  const message = err instanceof Error ? err.message : String(err);
287
- print(` ${message}`);
560
+ print(` ${style.fail(message)}`);
288
561
  return {
289
562
  deployment: null,
290
563
  status: "fail",
@@ -297,11 +570,11 @@ async function runDeploy(input) {
297
570
  async function runDoctor(input) {
298
571
  const print = input.print ?? ((line) => console.log(line));
299
572
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
300
- await loader.load();
573
+ await withSpinner("Loading plugins…", () => loader.load());
301
574
  const rows = [];
302
575
  const config = input.loaded.resolved;
303
- print(`Holocron doctor — ${config.project.name}`);
304
- print(` config: ${input.loaded.filepath}`);
576
+ print(style.header(`Holocron doctor — ${config.name}`));
577
+ print(style.dim(` config: ${input.loaded.filepath}`));
305
578
  print("");
306
579
  for (const key of loader.loadedKeys()) {
307
580
  const cardinality = CARDINALITY[key];
@@ -321,7 +594,12 @@ async function runDoctor(input) {
321
594
  rows.push(row);
322
595
  }
323
596
  }
324
- for (const row of rows) print(` ${row.status === "ok" ? "✓" : row.status === "fail" ? "✗" : "·"} ${pad(row.capability, 14)} via ${pad(row.provider, 14)} ${row.message}`);
597
+ for (const row of rows) {
598
+ const label = `${pad(row.capability, 14)} via ${pad(row.provider, 14)} ${row.message}`;
599
+ if (row.status === "ok") print(` ${style.success(label)}`);
600
+ else if (row.status === "fail") print(` ${style.fail(label)}`);
601
+ else print(` ${style.dim(`· ${label}`)}`);
602
+ }
325
603
  const summary = rows.reduce((acc, r) => {
326
604
  acc[r.status] += 1;
327
605
  return acc;
@@ -330,8 +608,9 @@ async function runDoctor(input) {
330
608
  fail: 0,
331
609
  skip: 0
332
610
  });
611
+ const summaryLine = `${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`;
333
612
  print("");
334
- print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped`);
613
+ print(summary.fail > 0 ? style.fail(summaryLine) : style.success(summaryLine));
335
614
  return {
336
615
  rows,
337
616
  summary
@@ -446,6 +725,669 @@ async function runNpmBumpVersions(input) {
446
725
  };
447
726
  }
448
727
  //#endregion
728
+ //#region src/commands/upgrade-node.ts
729
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
730
+ "node_modules",
731
+ ".git",
732
+ "dist",
733
+ "coverage",
734
+ "build",
735
+ ".turbo",
736
+ ".next",
737
+ "out"
738
+ ]);
739
+ function patchPackageJson(content, from, to) {
740
+ let pkg;
741
+ try {
742
+ pkg = JSON.parse(content);
743
+ } catch {
744
+ return null;
745
+ }
746
+ let changed = false;
747
+ const engines = pkg.engines;
748
+ if (engines?.node && /^>=\d+(?:\.0\.0)?$/.test(engines.node)) {
749
+ if (parseInt(engines.node.match(/\d+/)[0], 10) === from) {
750
+ engines.node = `>=${to}.0.0`;
751
+ changed = true;
752
+ }
753
+ }
754
+ for (const field of ["devDependencies", "dependencies"]) {
755
+ const deps = pkg[field];
756
+ if (!deps?.["@types/node"]) continue;
757
+ if (deps["@types/node"] === `^${from}.0.0`) {
758
+ deps["@types/node"] = `^${to}.0.0`;
759
+ changed = true;
760
+ }
761
+ }
762
+ return changed ? JSON.stringify(pkg, null, 2) + "\n" : null;
763
+ }
764
+ function patchYaml(content, from, to) {
765
+ const updated = content.replace(/node-version:\s+['"]?(\d+)['"]?/g, (match, ver) => ver === String(from) ? match.replace(String(from), String(to)) : match);
766
+ return updated !== content ? updated : null;
767
+ }
768
+ function patchPinFile(content, from, to) {
769
+ const trimmed = content.trim();
770
+ if (trimmed === String(from) || trimmed.startsWith(`${from}.`)) return `${to}\n`;
771
+ return null;
772
+ }
773
+ function patchDockerfile(content, from, to) {
774
+ const updated = content.replace(/^(FROM\s+node:)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
775
+ return updated !== content ? updated : null;
776
+ }
777
+ function patchToolVersions(content, from, to) {
778
+ const updated = content.replace(/^(nodejs\s+)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
779
+ return updated !== content ? updated : null;
780
+ }
781
+ const PATTERNS = [
782
+ {
783
+ matches: (n) => n === "package.json",
784
+ patch: patchPackageJson
785
+ },
786
+ {
787
+ matches: (n) => n.endsWith(".yml") || n.endsWith(".yaml"),
788
+ patch: patchYaml
789
+ },
790
+ {
791
+ matches: (n) => n === ".nvmrc" || n === ".node-version",
792
+ patch: patchPinFile
793
+ },
794
+ {
795
+ matches: (n) => n === "Dockerfile" || n.startsWith("Dockerfile."),
796
+ patch: patchDockerfile
797
+ },
798
+ {
799
+ matches: (n) => n === ".tool-versions",
800
+ patch: patchToolVersions
801
+ }
802
+ ];
803
+ function detectFrom(cwd, _readFile) {
804
+ for (const name of [".nvmrc", ".node-version"]) try {
805
+ const major = parseInt(_readFile(join(cwd, name)).trim(), 10);
806
+ if (!isNaN(major)) return major;
807
+ } catch {}
808
+ try {
809
+ const node = JSON.parse(_readFile(join(cwd, "package.json"))).engines?.node;
810
+ if (node) {
811
+ const m = node.match(/(\d+)/);
812
+ if (m) return parseInt(m[1], 10);
813
+ }
814
+ } catch {}
815
+ return null;
816
+ }
817
+ function defaultWalkFiles(dir) {
818
+ const results = [];
819
+ function walk(current) {
820
+ let entries;
821
+ try {
822
+ entries = readdirSync(current);
823
+ } catch {
824
+ return;
825
+ }
826
+ for (const entry of entries) {
827
+ if (SKIP_DIRS.has(entry)) continue;
828
+ const abs = join(current, entry);
829
+ try {
830
+ if (statSync(abs).isDirectory()) walk(abs);
831
+ else results.push(abs);
832
+ } catch {}
833
+ }
834
+ }
835
+ walk(dir);
836
+ return results;
837
+ }
838
+ async function runUpgradeNode(input) {
839
+ const print = input.print ?? ((line) => console.log(line));
840
+ const cwd = input.cwd ?? process.cwd();
841
+ const { to, dryRun = false, extra = [] } = input;
842
+ const _readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
843
+ const _writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
844
+ const _walkFiles = input.walkFiles ?? defaultWalkFiles;
845
+ const from = input.from ?? detectFrom(cwd, _readFile);
846
+ if (from === null) return {
847
+ status: "fail",
848
+ updated: [],
849
+ message: "could not detect current Node version — pass --from <major>"
850
+ };
851
+ if (from === to) {
852
+ print(`Already at Node.js ${to} — nothing to do.`);
853
+ return {
854
+ status: "ok",
855
+ updated: []
856
+ };
857
+ }
858
+ print(`Upgrading Node.js ${from} → ${to}${dryRun ? " (dry-run)" : ""}…`);
859
+ const updated = [];
860
+ const scanned = [..._walkFiles(cwd), ...extra.map((p) => join(cwd, p))];
861
+ for (const abs of scanned) {
862
+ const name = basename(abs);
863
+ const pattern = PATTERNS.find((p) => p.matches(name));
864
+ if (!pattern) continue;
865
+ let content;
866
+ try {
867
+ content = _readFile(abs);
868
+ } catch {
869
+ continue;
870
+ }
871
+ const patched = pattern.patch(content, from, to);
872
+ if (patched === null) continue;
873
+ const rel = abs.startsWith(cwd + "/") ? abs.slice(cwd.length + 1) : abs;
874
+ if (!dryRun) _writeFile(abs, patched);
875
+ print(` ${dryRun ? "~" : "✓"} ${rel}`);
876
+ updated.push(rel);
877
+ }
878
+ if (updated.length === 0) print(` · no files contained Node.js ${from} pins`);
879
+ return {
880
+ status: dryRun ? "dry-run" : "ok",
881
+ updated
882
+ };
883
+ }
884
+ //#endregion
885
+ //#region src/templates/actions/setup.yml
886
+ 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";
887
+ //#endregion
888
+ //#region src/templates/actions/install.yml
889
+ 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";
890
+ //#endregion
891
+ //#region src/templates/actions/setup-node.yml
892
+ 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";
893
+ //#endregion
894
+ //#region src/templates/workflows/audit.yml
895
+ 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";
896
+ //#endregion
897
+ //#region src/templates/workflows/bookkeeping.yml
898
+ 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";
899
+ //#endregion
900
+ //#region src/templates/workflows/codeql.yml
901
+ 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";
902
+ //#endregion
903
+ //#region src/templates/workflows/dependencies.yml
904
+ 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";
905
+ //#endregion
906
+ //#region src/templates/workflows/greetings.yml
907
+ 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";
908
+ //#endregion
909
+ //#region src/templates/workflows/lint.yml
910
+ 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";
911
+ //#endregion
912
+ //#region src/templates/workflows/release.yml
913
+ 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 SYNC_TOKEN:\n description: >\n Optional PAT with Contents write access. Required when the default\n branch is protected by a ruleset — github.token cannot push through\n rulesets, but a PAT with admin bypass can. Falls back to github.token.\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.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.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 SYNC_TOKEN (a PAT with Contents write) when available —\n # the built-in github.token cannot push to protected default branches\n # because rulesets block non-PAT pushes. Falls back to github.token\n # for repos without branch protection.\n GITHUB_TOKEN: ${{ secrets.SYNC_TOKEN || github.token }}\n NPM_CONFIG_PROVENANCE: true\n";
914
+ //#endregion
915
+ //#region src/templates/workflows/review.yml
916
+ 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 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 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\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 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\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 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 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";
917
+ //#endregion
918
+ //#region src/templates/workflows/stale.yml
919
+ 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";
920
+ //#endregion
921
+ //#region src/templates/workflows/sync-github.yml
922
+ 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";
923
+ //#endregion
924
+ //#region src/templates/workflows/test.yml
925
+ 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";
926
+ //#endregion
927
+ //#region src/templates/workflows/typecheck.yml
928
+ 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";
929
+ //#endregion
930
+ //#region src/templates/index.ts
931
+ /**
932
+ * All reusable workflow and composite action content bundled as string
933
+ * constants so the CLI can push them to theholocron/.github without
934
+ * needing filesystem access at runtime.
935
+ *
936
+ * Content lives in standalone .yml files; the rawYml rollup plugin
937
+ * inlines them as string exports at build time.
938
+ */
939
+ const ACTIONS = {
940
+ "setup/action": setup_default,
941
+ "install/action": install_default,
942
+ "setup-node/action": setup_node_default
943
+ };
944
+ const REUSABLE_WORKFLOWS = {
945
+ audit: audit_default$1,
946
+ bookkeeping: bookkeeping_default$1,
947
+ codeql: codeql_default$1,
948
+ dependencies: dependencies_default$1,
949
+ greetings: greetings_default$1,
950
+ lint: lint_default$1,
951
+ release: release_default$1,
952
+ review: review_default$1,
953
+ stale: stale_default$1,
954
+ "sync-github": sync_github_default$1,
955
+ test: test_default$1,
956
+ typecheck: typecheck_default$1
957
+ };
958
+ const WORKFLOW_TEMPLATE_PROPERTIES = {
959
+ bookkeeping: JSON.stringify({
960
+ name: "Bookkeeping",
961
+ description: "Label and track issues and pull requests.",
962
+ iconName: "octicon tag"
963
+ }, null, 2),
964
+ "sync-github": JSON.stringify({
965
+ name: "Sync GitHub Templates",
966
+ description: "Sync workflow templates and composite actions from the holocron CLI.",
967
+ iconName: "octicon sync"
968
+ }, null, 2)
969
+ };
970
+ //#endregion
971
+ //#region src/commands/dependabot.yml
972
+ 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";
973
+ //#endregion
974
+ //#region src/commands/workflows/audit.yml
975
+ 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";
976
+ //#endregion
977
+ //#region src/commands/workflows/bookkeeping.yml
978
+ 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";
979
+ //#endregion
980
+ //#region src/commands/workflows/codeql.yml
981
+ 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";
982
+ //#endregion
983
+ //#region src/commands/workflows/dependencies.yml
984
+ 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";
985
+ //#endregion
986
+ //#region src/commands/workflows/greetings.yml
987
+ 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";
988
+ //#endregion
989
+ //#region src/commands/workflows/lint.yml
990
+ 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";
991
+ //#endregion
992
+ //#region src/commands/workflows/release.yml
993
+ 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";
994
+ //#endregion
995
+ //#region src/commands/workflows/review.yml
996
+ 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";
997
+ //#endregion
998
+ //#region src/commands/workflows/stale.yml
999
+ 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";
1000
+ //#endregion
1001
+ //#region src/commands/workflows/sync-github.yml
1002
+ 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";
1003
+ //#endregion
1004
+ //#region src/commands/workflows/test.yml
1005
+ 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";
1006
+ //#endregion
1007
+ //#region src/commands/workflows/typecheck.yml
1008
+ 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";
1009
+ //#endregion
1010
+ //#region src/commands/setup-workflows.ts
1011
+ /** Header prepended when holocron setup writes a thin caller to a repo. */
1012
+ function workflowHeader() {
1013
+ return [
1014
+ `# AUTO-GENERATED — do not edit directly.`,
1015
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
1016
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1017
+ `# Tool: holocron setup`,
1018
+ `# Changes: run \`holocron setup\` to regenerate.`,
1019
+ ``
1020
+ ].join("\n");
1021
+ }
1022
+ const WORKFLOW_TEMPLATES = {
1023
+ lint: lint_default,
1024
+ test: test_default,
1025
+ typecheck: typecheck_default,
1026
+ codeql: codeql_default,
1027
+ review: review_default,
1028
+ release: release_default,
1029
+ stale: stale_default,
1030
+ greetings: greetings_default,
1031
+ dependencies: dependencies_default,
1032
+ bookkeeping: bookkeeping_default,
1033
+ audit: audit_default,
1034
+ "sync-github": sync_github_default
1035
+ };
1036
+ const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
1037
+ /**
1038
+ * GitHub check context name each CI workflow produces on a PR.
1039
+ *
1040
+ * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
1041
+ * job's own `name:` field does NOT appear in the external check name — only
1042
+ * the calling workflow's top-level `name:` and the inner reusable-workflow
1043
+ * job name matter. Only workflows that gate merges are listed here.
1044
+ */
1045
+ const WORKFLOW_CHECK_CONTEXTS = {
1046
+ lint: "Lint / Lint entire codebase",
1047
+ test: "Test / Run tests and collect coverage",
1048
+ typecheck: "Typecheck / tsc --noEmit"
1049
+ };
1050
+ /**
1051
+ * Generate the thin caller content for a workflow, optionally injecting or
1052
+ * merging `with:` overrides into the jobs block.
1053
+ *
1054
+ * Two strategies are used depending on the template:
1055
+ * - Templates that already have a `with:` block (e.g. lint, sync-github):
1056
+ * the override entries are merged in, replacing existing keys and appending
1057
+ * new ones.
1058
+ * - Templates that end with ` secrets: inherit`: a new `with:` block is
1059
+ * injected immediately before `secrets: inherit`.
1060
+ * If neither pattern matches the template, a warning is emitted and the
1061
+ * base template is returned unchanged.
1062
+ */
1063
+ function generateThinCallerContent(name, withOverrides) {
1064
+ const base = WORKFLOW_TEMPLATES[name];
1065
+ if (!base) return "";
1066
+ if (!withOverrides || Object.keys(withOverrides).length === 0) return base;
1067
+ const fmt = (k, v) => ` ${k}: ${v === true ? "true" : v === false ? "false" : String(v)}`;
1068
+ const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
1069
+ const existingMatch = base.match(withBlockRe);
1070
+ if (existingMatch) {
1071
+ const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).map((line) => {
1072
+ const m = line.match(/^ {6}([^:]+):\s*(.*)/);
1073
+ return m ? [m[1].trim(), m[2].trim()] : null;
1074
+ }).filter((e) => e !== null));
1075
+ for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, v === true ? "true" : v === false ? "false" : String(v));
1076
+ const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
1077
+ return base.replace(withBlockRe, ` with:\n${merged}\n`);
1078
+ }
1079
+ const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
1080
+ const result = base.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
1081
+ if (result === base) console.warn(`[generateThinCallerContent] could not inject with: overrides into "${name}" template`);
1082
+ return result;
1083
+ }
1084
+ //#endregion
1085
+ //#region src/commands/sync-github.ts
1086
+ const DEFAULT_REPO = "theholocron/.github";
1087
+ /**
1088
+ * Extracts the `workflows` array from a `holocron.config.ts` source string.
1089
+ * Handles both plain string entries and `{ name, with }` object entries.
1090
+ * Falls back to an empty array if the array cannot be found or parsed.
1091
+ */
1092
+ function parseWorkflowsFromTs(source) {
1093
+ const keyMatch = source.match(/\bworkflows\s*:\s*\[/);
1094
+ if (!keyMatch) return [];
1095
+ const start = keyMatch.index + keyMatch[0].length;
1096
+ let depth = 1;
1097
+ let i = start;
1098
+ while (i < source.length && depth > 0) {
1099
+ if (source[i] === "[") depth++;
1100
+ else if (source[i] === "]") depth--;
1101
+ i++;
1102
+ }
1103
+ const body = source.slice(start, i - 1);
1104
+ const entries = [];
1105
+ const objSpans = [];
1106
+ const objRe = /\{\s*name\s*:\s*"([^"]+)"(?:\s*,\s*with\s*:\s*(\{[^}]*\}))?\s*\}/g;
1107
+ let m;
1108
+ while ((m = objRe.exec(body)) !== null) {
1109
+ objSpans.push([m.index, m.index + m[0].length]);
1110
+ let withObj;
1111
+ if (m[2]) try {
1112
+ withObj = JSON.parse(m[2]);
1113
+ } catch {}
1114
+ entries.push({
1115
+ pos: m.index,
1116
+ entry: {
1117
+ name: m[1],
1118
+ ...withObj && { with: withObj }
1119
+ }
1120
+ });
1121
+ }
1122
+ const strRe = /"([^"]+)"/g;
1123
+ while ((m = strRe.exec(body)) !== null) if (!objSpans.some(([s, e]) => m.index >= s && m.index < e)) entries.push({
1124
+ pos: m.index,
1125
+ entry: { name: m[1] }
1126
+ });
1127
+ entries.sort((a, b) => a.pos - b.pos);
1128
+ return entries.map(({ entry }) => entry);
1129
+ }
1130
+ function reusableHeader(source) {
1131
+ return [
1132
+ `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
1133
+ `# Source: theholocron/holocron · ${source}`,
1134
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1135
+ `# Tool: holocron sync-github`,
1136
+ `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1137
+ ``
1138
+ ].join("\n");
1139
+ }
1140
+ function thinCallerHeader(forPrimary = false) {
1141
+ return [
1142
+ forPrimary ? `# AUTO-GENERATED — do not edit in theholocron/.github directly.` : `# AUTO-GENERATED — do not edit directly.`,
1143
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
1144
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1145
+ `# Tool: holocron sync-github`,
1146
+ `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1147
+ ``
1148
+ ].join("\n");
1149
+ }
1150
+ function buildBatch(repo, allowedWorkflows, withOverrides) {
1151
+ const files = [];
1152
+ const isPrimaryGithubRepo = repo === DEFAULT_REPO;
1153
+ if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(ACTIONS)) files.push({
1154
+ path: `.github/actions/${name}.yml`,
1155
+ content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1156
+ });
1157
+ if (isPrimaryGithubRepo) {
1158
+ for (const [name, content] of Object.entries(REUSABLE_WORKFLOWS)) files.push({
1159
+ path: `.github/workflows/${name}.yml`,
1160
+ content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1161
+ });
1162
+ for (const [name, content] of Object.entries(WORKFLOW_TEMPLATES)) {
1163
+ files.push({
1164
+ path: `workflow-templates/${name}.yml`,
1165
+ content: thinCallerHeader(true) + content
1166
+ });
1167
+ const props = WORKFLOW_TEMPLATE_PROPERTIES[name];
1168
+ if (props) files.push({
1169
+ path: `workflow-templates/${name}.properties.json`,
1170
+ content: props
1171
+ });
1172
+ }
1173
+ } else for (const name of Object.keys(REUSABLE_WORKFLOWS)) {
1174
+ if (allowedWorkflows && !allowedWorkflows.has(name)) continue;
1175
+ const content = generateThinCallerContent(name, withOverrides?.get(name));
1176
+ if (!content) continue;
1177
+ files.push({
1178
+ path: `.github/workflows/${name}.yml`,
1179
+ content: thinCallerHeader() + content
1180
+ });
1181
+ }
1182
+ return files;
1183
+ }
1184
+ /** Git blob SHA: sha1("blob {len}\0{content}") — used to detect unchanged files. */
1185
+ function gitBlobSha(content) {
1186
+ const buf = Buffer.from(content, "utf8");
1187
+ return createHash("sha1").update(`blob ${buf.length}\0`).update(buf).digest("hex");
1188
+ }
1189
+ async function runSyncGithub(input) {
1190
+ const print = input.print ?? ((line) => console.log(line));
1191
+ const repo = input.repo ?? DEFAULT_REPO;
1192
+ const { token, dryRun = false, branch, createPr = false } = input;
1193
+ const message = input.message ?? `chore: sync from theholocron/holocron`;
1194
+ const client = createGitHubClient({
1195
+ token,
1196
+ fetch: input.fetch
1197
+ });
1198
+ print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
1199
+ print(` repo: ${repo}`);
1200
+ if (branch) print(` branch: ${branch}`);
1201
+ print("");
1202
+ if (input.outputDir) {
1203
+ const batch = buildBatch(repo);
1204
+ for (const file of batch) {
1205
+ const dest = join(input.outputDir, file.path);
1206
+ mkdirSync(dirname(dest), { recursive: true });
1207
+ writeFileSync(dest, file.content, "utf8");
1208
+ }
1209
+ print(` ${batch.length} files written to ${input.outputDir}`);
1210
+ return {
1211
+ status: "ok",
1212
+ created: batch.length,
1213
+ updated: 0,
1214
+ unchanged: 0
1215
+ };
1216
+ }
1217
+ let targetBranch = branch;
1218
+ let defaultBranch;
1219
+ if (!targetBranch || createPr) try {
1220
+ defaultBranch = (await client.repos.getRepo(repo)).default_branch;
1221
+ if (!targetBranch) targetBranch = defaultBranch;
1222
+ } catch {
1223
+ const msg = "failed to fetch repo metadata";
1224
+ print(` ✗ ${msg}`);
1225
+ return {
1226
+ status: "fail",
1227
+ created: 0,
1228
+ updated: 0,
1229
+ unchanged: 0,
1230
+ message: msg
1231
+ };
1232
+ }
1233
+ const baseBranch = createPr && defaultBranch ? defaultBranch : targetBranch;
1234
+ let headSha;
1235
+ let baseTreeSha;
1236
+ let existingBlobs;
1237
+ try {
1238
+ headSha = (await client.git.getRef(repo, baseBranch)).object.sha;
1239
+ baseTreeSha = (await client.git.getCommit(repo, headSha)).tree.sha;
1240
+ const treeData = await client.git.getTree(repo, baseTreeSha, true);
1241
+ existingBlobs = new Map(treeData.tree.filter((i) => i.type === "blob").map((i) => [i.path, i.sha]));
1242
+ } catch (err) {
1243
+ const msg = err instanceof Error ? err.message : `Branch ${baseBranch} not found`;
1244
+ print(` ✗ ${msg}`);
1245
+ return {
1246
+ status: "fail",
1247
+ created: 0,
1248
+ updated: 0,
1249
+ unchanged: 0,
1250
+ message: msg
1251
+ };
1252
+ }
1253
+ let allowedWorkflows;
1254
+ let withOverrides;
1255
+ if (repo !== DEFAULT_REPO) try {
1256
+ let entries = [];
1257
+ try {
1258
+ const data = await client.git.getContents(repo, "holocron.config.json");
1259
+ entries = (JSON.parse(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"))?.workflows ?? []).map((w) => typeof w === "string" ? { name: w } : w);
1260
+ } catch (err) {
1261
+ if (!(err instanceof ProviderApiError) || err.status !== 404) throw err;
1262
+ try {
1263
+ const data = await client.git.getContents(repo, "holocron.config.ts");
1264
+ entries = parseWorkflowsFromTs(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"));
1265
+ } catch {}
1266
+ }
1267
+ if (entries.length > 0) {
1268
+ allowedWorkflows = new Set(entries.map((e) => e.name));
1269
+ const overrideEntries = entries.filter((e) => e.with != null).map((e) => [e.name, e.with]);
1270
+ if (overrideEntries.length > 0) withOverrides = new Map(overrideEntries);
1271
+ }
1272
+ } catch {}
1273
+ const batch = buildBatch(repo, allowedWorkflows, withOverrides);
1274
+ let created = 0;
1275
+ let updated = 0;
1276
+ let unchanged = 0;
1277
+ const changedFiles = [];
1278
+ for (const file of batch) {
1279
+ const localSha = gitBlobSha(file.content);
1280
+ const existingSha = existingBlobs.get(file.path);
1281
+ if (existingSha === localSha) {
1282
+ print(` · unchanged ${file.path}`);
1283
+ unchanged++;
1284
+ } else if (existingSha) {
1285
+ print(` ${dryRun ? "~" : "✓"} updated ${file.path}`);
1286
+ updated++;
1287
+ if (!dryRun) changedFiles.push(file);
1288
+ } else {
1289
+ print(` ${dryRun ? "~" : "✓"} created ${file.path}`);
1290
+ created++;
1291
+ if (!dryRun) changedFiles.push(file);
1292
+ }
1293
+ }
1294
+ print("");
1295
+ print(` ${created} created, ${updated} updated, ${unchanged} unchanged`);
1296
+ if (dryRun || changedFiles.length === 0) return {
1297
+ status: dryRun ? "dry-run" : "ok",
1298
+ created,
1299
+ updated,
1300
+ unchanged
1301
+ };
1302
+ const treeEntries = [];
1303
+ for (const file of changedFiles) try {
1304
+ const blob = await client.git.createBlob(repo, file.content);
1305
+ treeEntries.push({
1306
+ path: file.path,
1307
+ mode: "100644",
1308
+ type: "blob",
1309
+ sha: blob.sha
1310
+ });
1311
+ } catch (err) {
1312
+ const msg = `failed to create blob for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
1313
+ print(` ✗ ${msg}`);
1314
+ return {
1315
+ status: "fail",
1316
+ created,
1317
+ updated,
1318
+ unchanged,
1319
+ message: msg
1320
+ };
1321
+ }
1322
+ let newTreeSha;
1323
+ try {
1324
+ newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
1325
+ } catch (err) {
1326
+ const msg = `failed to create tree: ${err instanceof Error ? err.message : String(err)}`;
1327
+ print(` ✗ ${msg}`);
1328
+ return {
1329
+ status: "fail",
1330
+ created,
1331
+ updated,
1332
+ unchanged,
1333
+ message: msg
1334
+ };
1335
+ }
1336
+ let newCommitSha;
1337
+ try {
1338
+ newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
1339
+ } catch (err) {
1340
+ const msg = `failed to create commit: ${err instanceof Error ? err.message : String(err)}`;
1341
+ print(` ✗ ${msg}`);
1342
+ return {
1343
+ status: "fail",
1344
+ created,
1345
+ updated,
1346
+ unchanged,
1347
+ message: msg
1348
+ };
1349
+ }
1350
+ try {
1351
+ if (createPr && branch) try {
1352
+ await client.git.createRef(repo, `refs/heads/${branch}`, newCommitSha);
1353
+ } catch (err) {
1354
+ if (!(err instanceof ProviderApiError) || err.status !== 422) throw err;
1355
+ await client.git.updateRef(repo, `heads/${branch}`, newCommitSha, true);
1356
+ }
1357
+ else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
1358
+ } catch (err) {
1359
+ const msg = `failed to update ref: ${err instanceof Error ? err.message : String(err)}`;
1360
+ print(` ✗ ${msg}`);
1361
+ return {
1362
+ status: "fail",
1363
+ created,
1364
+ updated,
1365
+ unchanged,
1366
+ message: msg
1367
+ };
1368
+ }
1369
+ let prUrl;
1370
+ if (branch && createPr && !dryRun) try {
1371
+ prUrl = (await client.git.createPull(repo, {
1372
+ title: message.split("\n")[0],
1373
+ head: branch,
1374
+ base: "main",
1375
+ body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
1376
+ })).html_url;
1377
+ print(` → PR opened: ${prUrl}`);
1378
+ } catch (err) {
1379
+ if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("already exists")) print(` → PR already open for ${branch} — branch updated, ready to merge`);
1380
+ else print(` ⚠ PR creation failed: ${err instanceof Error ? err.message : String(err)}`);
1381
+ }
1382
+ return {
1383
+ status: "ok",
1384
+ created,
1385
+ updated,
1386
+ unchanged,
1387
+ prUrl
1388
+ };
1389
+ }
1390
+ //#endregion
449
1391
  //#region src/commands/npm-publish-initial.ts
450
1392
  /**
451
1393
  * `holocron npm publish-initial` — bottles up the chicken-and-egg
@@ -488,7 +1430,7 @@ async function runNpmPublishInitial(input = {}) {
488
1430
  const dryRun = input.dryRun ?? false;
489
1431
  const otp = input.otp;
490
1432
  const env = input.env ?? process.env;
491
- const exec = input.exec ?? defaultExec;
1433
+ const exec = input.exec ?? defaultExec$1;
492
1434
  const publishArgs = [
493
1435
  "-r",
494
1436
  "--filter=./packages/*",
@@ -564,7 +1506,7 @@ function printNextSteps$1(print, env) {
564
1506
  print(" https://www.npmjs.com/settings/~/tokens");
565
1507
  }
566
1508
  }
567
- const defaultExec = async (cmd, args, opts) => {
1509
+ const defaultExec$1 = async (cmd, args, opts) => {
568
1510
  const result = spawnSync(cmd, args, {
569
1511
  cwd: opts.cwd,
570
1512
  encoding: "utf8",
@@ -583,7 +1525,7 @@ const defaultExec = async (cmd, args, opts) => {
583
1525
  //#endregion
584
1526
  //#region src/commands/plugin-create/template-inputs.ts
585
1527
  /** Derive the standard defaults from a slug + vendor name. */
586
- function deriveDefaults(input) {
1528
+ function deriveDefaults$1(input) {
587
1529
  const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
588
1530
  const capability = input.capability;
589
1531
  return {
@@ -596,48 +1538,19 @@ function deriveDefaults(input) {
596
1538
  //#endregion
597
1539
  //#region src/commands/plugin-create/templates/auth.ts
598
1540
  function render$17(inputs) {
599
- return `/**
600
- * Token resolution for the ${inputs.vendorName} plugin.
601
- *
602
- * Resolution order (matches the standard 4-step precedence set by
603
- * \`.notes/tech-auth-bootstrap.spec.md\`):
604
- * 1. explicit \`cliToken\` argument (from \`--token\` flag)
605
- * 2. ${inputs.tokenEnv} env var (preferred — explicit intent)
606
- * 3. ${inputs.vendorEnv} env var (vendor-native)
607
- * 4. keyring (com.theholocron.cli / "${inputs.slug}")
608
- * 5. AuthError naming all four options + the bootstrap hint
609
- */
610
-
611
- import { getToken as getKeyringToken } from "@theholocron/cli";
612
-
613
- export class AuthError extends Error {
614
- override name = "AuthError";
615
- }
616
-
617
- export interface ResolveTokenInput {
618
- /** From \`--token\` CLI flag. */
619
- cliToken?: string;
620
- /** Env vars; passed in for testability. Defaults to \`process.env\`. */
621
- env?: NodeJS.ProcessEnv;
622
- /** Keyring lookup fn; passed in for testability. Defaults to \`getToken(provider)\`. */
623
- keyring?: (provider: string) => string | null;
624
- }
625
-
626
- export function resolveToken(input: ResolveTokenInput = {}): string {
627
- const env = input.env ?? process.env;
628
- const keyring = input.keyring ?? getKeyringToken;
629
- // Bracket access so numeric-prefixed slugs (e.g., env.HOLOCRON_1PASSWORD_TOKEN
630
- // which is invalid JS) still produce syntactically valid code.
631
- const token =
632
- input.cliToken || env["${inputs.tokenEnv}"] || env["${inputs.vendorEnv}"] || keyring("${inputs.slug}");
633
- if (!token) {
634
- throw new AuthError(
635
- "no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
636
- "or run: holocron auth set ${inputs.slug} <TOKEN>"
637
- );
638
- }
639
- return token;
640
- }
1541
+ return `import { AuthError, createResolveToken, type ResolveTokenInput } from "@theholocron/cli";
1542
+
1543
+ export { AuthError };
1544
+ export type { ResolveTokenInput };
1545
+
1546
+ export const resolveToken = createResolveToken({
1547
+ \tenvName: "${inputs.tokenEnv}",
1548
+ \tvendorEnvName: "${inputs.vendorEnv}",
1549
+ \tkeyringService: "${inputs.slug}",
1550
+ \terrorMessage:
1551
+ \t\t"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
1552
+ \t\t"or run: holocron auth set ${inputs.slug} <TOKEN>",
1553
+ });
641
1554
  `;
642
1555
  }
643
1556
  //#endregion
@@ -1063,148 +1976,79 @@ Not yet published; capability methods are stubs.
1063
1976
  //#endregion
1064
1977
  //#region src/commands/plugin-create/templates/rest.ts
1065
1978
  function render$7(inputs) {
1066
- const clientClass = `${inputs.vendorName}RestClient`;
1067
- return `/**
1068
- * Thin REST wrapper around ${inputs.baseUrl}.
1069
- *
1070
- * Bearer auth, JSON-only bodies, transport-failure wrapping with
1071
- * \`status: 0\` so orchestrator soft-skip paths see a clear message
1072
- * instead of a generic \`TypeError: fetch failed\`.
1073
- */
1074
-
1075
- import { ProviderApiError } from "@theholocron/cli";
1076
-
1077
- export interface RestClientOptions {
1078
- token: string;
1079
- fetch?: typeof fetch;
1080
- baseUrl?: string;
1081
- }
1082
-
1083
- export interface RequestOptions {
1084
- method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
1085
- body?: unknown;
1086
- query?: Record<string, string>;
1087
- /** Treat this response as void even if 200 is returned. */
1088
- expectNoContent?: boolean;
1089
- }
1090
-
1091
- export class ${clientClass} {
1092
- private readonly token: string;
1093
- private readonly fetchImpl: typeof fetch;
1094
- readonly baseUrl: string;
1095
-
1096
- constructor(opts: RestClientOptions) {
1097
- this.token = opts.token;
1098
- this.fetchImpl = opts.fetch ?? globalThis.fetch;
1099
- // Manual trailing-slash trim — CodeQL flags regex on library
1100
- // input as polynomial ReDoS. O(n) loop, no backtracking risk.
1101
- let url = opts.baseUrl ?? "${inputs.baseUrl}";
1102
- while (url.endsWith("/")) url = url.slice(0, -1);
1103
- this.baseUrl = url;
1104
- }
1105
-
1106
- async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
1107
- const url = new URL(\`\${this.baseUrl}\${path.startsWith("/") ? path : "/" + path}\`);
1108
- for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
1109
- const fullUrl = url.toString();
1110
-
1111
- const headers: Record<string, string> = {
1112
- authorization: \`Bearer \${this.token}\`,
1113
- accept: "application/json",
1114
- };
1115
- const init: RequestInit = {
1116
- method: opts.method ?? "GET",
1117
- headers,
1118
- };
1119
- if (opts.body !== undefined) {
1120
- headers["content-type"] = "application/json";
1121
- init.body = JSON.stringify(opts.body);
1122
- }
1123
-
1124
- let res: Response;
1125
- try {
1126
- res = await this.fetchImpl(fullUrl, init);
1127
- } catch (err) {
1128
- const detail = err instanceof Error ? \`\${err.name}: \${err.message}\` : String(err);
1129
- throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} failed: \${detail}\`, 0, undefined);
1130
- }
1131
- if (!res.ok) {
1132
- const body = await res.text().catch(() => "");
1133
- throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} → \${res.status}\`, res.status, body);
1134
- }
1135
- if (opts.expectNoContent || res.status === 204) return undefined as T;
1136
- const text = await res.text();
1137
- if (!text) return undefined as T;
1138
- return JSON.parse(text) as T;
1139
- }
1979
+ return `import { createRestClient, type RequestOptions, type RestClient } from "@theholocron/cli";
1980
+
1981
+ export type { RequestOptions, RestClient };
1982
+
1983
+ export function ${`create${inputs.vendorName}RestClient`}(opts: {
1984
+ \ttoken: string;
1985
+ \tbaseUrl?: string;
1986
+ \tfetch?: typeof fetch;
1987
+ }): RestClient {
1988
+ \treturn createRestClient({
1989
+ \t\tbaseUrl: opts.baseUrl ?? "${inputs.baseUrl}",
1990
+ \t\ttoken: opts.token,
1991
+ \t\tvendor: "${inputs.vendorName}",
1992
+ \t\tfetch: opts.fetch,
1993
+ \t});
1140
1994
  }
1141
1995
  `;
1142
1996
  }
1143
1997
  //#endregion
1144
1998
  //#region src/commands/plugin-create/templates/rest-test.ts
1145
1999
  function render$6(inputs) {
1146
- const clientClass = `${inputs.vendorName}RestClient`;
2000
+ const factoryName = `create${inputs.vendorName}RestClient`;
1147
2001
  return `import { ProviderApiError } from "@theholocron/cli";
1148
2002
  import { describe, expect, it } from "vitest";
1149
2003
 
1150
- import { ${clientClass} } from "../rest.js";
2004
+ import { ${factoryName} } from "../rest.js";
1151
2005
  import { stubFetch } from "./helpers.js";
1152
2006
 
1153
- describe("${clientClass}", () => {
1154
- it("sends bearer + accept headers and returns the parsed body", async () => {
1155
- const stub = stubFetch([{ status: 200, body: { ok: true } }]);
1156
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
1157
- const res = await client.request<{ ok: boolean }>("/me");
1158
- expect(res.ok).toBe(true);
1159
- expect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
1160
- expect(stub.calls[0]?.headers["accept"]).toBe("application/json");
1161
- });
1162
-
1163
- it("serializes body as JSON and sets content-type when present", async () => {
1164
- const stub = stubFetch([{ status: 200, body: {} }]);
1165
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
1166
- await client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
1167
- expect(stub.calls[0]?.method).toBe("POST");
1168
- expect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
1169
- expect(stub.calls[0]?.body).toEqual({ name: "demo" });
1170
- });
1171
-
1172
- it("returns undefined on 204", async () => {
1173
- const stub = stubFetch([{ status: 204 }]);
1174
- const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
1175
- expect(await client.request<unknown>("/whatever")).toBeUndefined();
1176
- });
1177
-
1178
- it("throws ProviderApiError with the HTTP status on non-2xx", async () => {
1179
- const stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
1180
- const client = new ${clientClass}({ token: "bad", fetch: stub.fetch });
1181
- try {
1182
- await client.request<unknown>("/me");
1183
- throw new Error("should have thrown");
1184
- } catch (err) {
1185
- expect(err).toBeInstanceOf(ProviderApiError);
1186
- expect((err as ProviderApiError).status).toBe(401);
1187
- }
1188
- });
1189
-
1190
- it("wraps transport-level failures with status 0", async () => {
1191
- const throwing: typeof fetch = async () => {
1192
- throw new TypeError("fetch failed");
1193
- };
1194
- const client = new ${clientClass}({ token: "t", fetch: throwing });
1195
- try {
1196
- await client.request<unknown>("/me");
1197
- throw new Error("should have thrown");
1198
- } catch (err) {
1199
- expect(err).toBeInstanceOf(ProviderApiError);
1200
- expect((err as ProviderApiError).status).toBe(0);
1201
- }
1202
- });
1203
-
1204
- it("trims trailing slashes from the base URL", () => {
1205
- const client = new ${clientClass}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
1206
- expect(client.baseUrl).toBe("${inputs.baseUrl}");
1207
- });
2007
+ describe("${factoryName}", () => {
2008
+ \tit("sends bearer + accept headers and returns the parsed body", async () => {
2009
+ \t\tconst stub = stubFetch([{ status: 200, body: { ok: true } }]);
2010
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2011
+ \t\tconst res = await client.request<{ ok: boolean }>("/me");
2012
+ \t\texpect(res.ok).toBe(true);
2013
+ \t\texpect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
2014
+ \t\texpect(stub.calls[0]?.headers["accept"]).toBe("application/json");
2015
+ \t});
2016
+
2017
+ \tit("serializes body as JSON and sets content-type when present", async () => {
2018
+ \t\tconst stub = stubFetch([{ status: 200, body: {} }]);
2019
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2020
+ \t\tawait client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
2021
+ \t\texpect(stub.calls[0]?.method).toBe("POST");
2022
+ \t\texpect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
2023
+ \t\texpect(stub.calls[0]?.body).toEqual({ name: "demo" });
2024
+ \t});
2025
+
2026
+ \tit("returns undefined on 204", async () => {
2027
+ \t\tconst stub = stubFetch([{ status: 204 }]);
2028
+ \t\tconst client = ${factoryName}({ token: "t", fetch: stub.fetch });
2029
+ \t\texpect(await client.request<unknown>("/whatever")).toBeUndefined();
2030
+ \t});
2031
+
2032
+ \tit("throws ProviderApiError with the HTTP status on non-2xx", async () => {
2033
+ \t\tconst stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
2034
+ \t\tconst client = ${factoryName}({ token: "bad", fetch: stub.fetch });
2035
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2036
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2037
+ \t\texpect((err as ProviderApiError).status).toBe(401);
2038
+ \t});
2039
+
2040
+ \tit("wraps transport-level failures with status 0", async () => {
2041
+ \t\tconst throwing: typeof fetch = async () => { throw new TypeError("fetch failed"); };
2042
+ \t\tconst client = ${factoryName}({ token: "t", fetch: throwing });
2043
+ \t\tconst err = await client.request<unknown>("/me").catch((e: unknown) => e);
2044
+ \t\texpect(err).toBeInstanceOf(ProviderApiError);
2045
+ \t\texpect((err as ProviderApiError).status).toBe(0);
2046
+ \t});
2047
+
2048
+ \tit("trims trailing slashes from the base URL", () => {
2049
+ \t\tconst client = ${factoryName}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
2050
+ \t\texpect(client.baseUrl).toBe("${inputs.baseUrl}");
2051
+ \t});
1208
2052
  });
1209
2053
  `;
1210
2054
  }
@@ -1513,13 +2357,11 @@ export default defineConfig({
1513
2357
  * 2. Slug collision — packages/holocron-plugin-<slug>/ must not exist.
1514
2358
  * 3. Capability sanity — must be one of the 14 known keys; warn for
1515
2359
  * many-cardinality caps.
1516
- * 4. Promptfill in any missing flags via cli-utils / inquirer
1517
- * (Phase B; Phase A takes fully-populated input).
1518
- * 5. Generate — for each template, write to
2360
+ * 4. Generatefor each template, write to
1519
2361
  * packages/holocron-plugin-<slug>/<path>.
1520
- * 6. Verify (unless --no-verify) — Phase B; runs pnpm install +
2362
+ * 5. Verify (unless --no-verify) — runs pnpm install +
1521
2363
  * pnpm --filter <pkg> typecheck lint test.
1522
- * 7. Print next steps.
2364
+ * 6. Print next steps.
1523
2365
  */
1524
2366
  var PluginCreateError = class extends Error {
1525
2367
  name = "PluginCreateError";
@@ -1612,7 +2454,7 @@ function runPluginCreate(input) {
1612
2454
  const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
1613
2455
  if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
1614
2456
  validateCapability(input.capability, print);
1615
- const derived = deriveDefaults({
2457
+ const derived = deriveDefaults$1({
1616
2458
  slug: input.slug,
1617
2459
  vendorName: input.vendorName,
1618
2460
  capability: input.capability
@@ -1642,6 +2484,51 @@ function runPluginCreate(input) {
1642
2484
  }
1643
2485
  filesWritten.push(resolvedPath);
1644
2486
  }
2487
+ if (!input.dryRun && !input.noVerify) {
2488
+ const execFn = input.exec ?? defaultExec;
2489
+ const pkg = `@theholocron/holocron-plugin-${inputs.slug}`;
2490
+ print("");
2491
+ print(" Verifying scaffold…");
2492
+ try {
2493
+ execFn("pnpm", ["install", "--frozen-lockfile=false"], {
2494
+ cwd,
2495
+ stdio: "inherit"
2496
+ });
2497
+ execFn("pnpm", [
2498
+ "--filter",
2499
+ pkg,
2500
+ "typecheck"
2501
+ ], {
2502
+ cwd,
2503
+ stdio: "inherit"
2504
+ });
2505
+ execFn("pnpm", [
2506
+ "--filter",
2507
+ pkg,
2508
+ "lint"
2509
+ ], {
2510
+ cwd,
2511
+ stdio: "inherit"
2512
+ });
2513
+ execFn("pnpm", [
2514
+ "--filter",
2515
+ pkg,
2516
+ "test"
2517
+ ], {
2518
+ cwd,
2519
+ stdio: "inherit"
2520
+ });
2521
+ print(" ✓ scaffold verified");
2522
+ } catch (err) {
2523
+ print(` ✗ verify failed — ${err instanceof Error ? err.message : String(err)}`);
2524
+ return {
2525
+ status: "fail",
2526
+ packagePath: packageDir,
2527
+ filesWritten,
2528
+ message: "post-scaffold verify failed; inspect output above"
2529
+ };
2530
+ }
2531
+ }
1645
2532
  if (!input.dryRun) printNextSteps(print, inputs);
1646
2533
  return {
1647
2534
  status: "ok",
@@ -1685,6 +2572,12 @@ function defaultWrite(filepath, content) {
1685
2572
  mkdirSync(path.dirname(filepath), { recursive: true });
1686
2573
  writeFileSync(filepath, content, "utf8");
1687
2574
  }
2575
+ function defaultExec(cmd, args, opts) {
2576
+ execFileSync(cmd, args, {
2577
+ cwd: opts.cwd,
2578
+ stdio: opts.stdio
2579
+ });
2580
+ }
1688
2581
  function printNextSteps(print, inputs) {
1689
2582
  print("");
1690
2583
  print(` Scaffolded @theholocron/holocron-plugin-${inputs.slug} (18 files).`);
@@ -1765,22 +2658,22 @@ function describeScope(scope) {
1765
2658
  async function runSecretsSync(input) {
1766
2659
  const print = input.print ?? ((line) => console.log(line));
1767
2660
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1768
- await loader.load();
2661
+ await withSpinner("Loading plugins…", () => loader.load());
1769
2662
  const dryRun = input.context.dryRun ?? false;
1770
2663
  const targets = input.targets ?? ["production", "preview"];
1771
- print(`Holocron secrets sync — environment ${input.environmentId}${dryRun ? " (dry-run)" : ""}`);
1772
- print(` vault: ${vaultProviderName(loader)}`);
2664
+ print(style.header(`Holocron secrets sync — environment ${input.environmentId}${dryRun ? " (dry-run)" : ""}`));
2665
+ print(style.dim(` vault: ${vaultProviderName(loader)}`));
1773
2666
  print("");
1774
2667
  const vault = loader.get("vault");
1775
2668
  if (!vault.readEnvironment) throw new Error(`vault provider (${vault.providerName}) does not implement readEnvironment — sync needs bulk env reads`);
1776
- const envVars = await vault.readEnvironment(input.environmentId);
2669
+ const envVars = await withSpinner(`Reading vault environment ${input.environmentId}…`, () => vault.readEnvironment(input.environmentId));
1777
2670
  const keys = Object.keys(envVars).sort();
1778
- print(`read ${keys.length} keys from vault`);
2671
+ print(style.step(`read ${keys.length} keys from vault`));
1779
2672
  print("");
1780
2673
  const rows = [];
1781
2674
  if (loader.has("secrets")) {
1782
2675
  const secrets = loader.get("secrets");
1783
- print("secrets (repo scope)");
2676
+ print(style.step("secrets (repo scope)"));
1784
2677
  for (const key of keys) {
1785
2678
  rows.push(await runRow(`secrets:${secrets.providerName}`, "scope=repo", key, dryRun, async () => {
1786
2679
  await secrets.setSecret({ kind: "repo" }, key, envVars[key]);
@@ -1791,7 +2684,7 @@ async function runSecretsSync(input) {
1791
2684
  if (loader.has("deployment")) {
1792
2685
  const deploy = loader.get("deployment");
1793
2686
  if (!input.projectId) {
1794
- print("deployment");
2687
+ print(style.step("deployment"));
1795
2688
  const row = {
1796
2689
  destination: `deployment:${deploy.providerName}`,
1797
2690
  scope: "no projectId",
@@ -1802,7 +2695,7 @@ async function runSecretsSync(input) {
1802
2695
  rows.push(row);
1803
2696
  print(formatRow(row));
1804
2697
  } else for (const target of targets) {
1805
- print(`deployment (target=${target})`);
2698
+ print(style.step(`deployment (target=${target})`));
1806
2699
  for (const key of keys) {
1807
2700
  rows.push(await runRow(`deployment:${deploy.providerName}`, `target=${target}`, key, dryRun, async () => {
1808
2701
  await deploy.setEnvVar(input.projectId, target, key, envVars[key]);
@@ -1824,7 +2717,8 @@ async function runSecretsSync(input) {
1824
2717
  dryRun: 0
1825
2718
  });
1826
2719
  print("");
1827
- print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
2720
+ const summaryLine = `${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`;
2721
+ print(summary.fail > 0 ? style.fail(summaryLine) : style.success(summaryLine));
1828
2722
  return {
1829
2723
  rows,
1830
2724
  summary
@@ -1856,278 +2750,300 @@ async function runRow(destination, scope, key, dryRun, body) {
1856
2750
  }
1857
2751
  }
1858
2752
  function formatRow(row) {
1859
- const icon = row.status === "ok" ? "✓" : row.status === "fail" ? "✗" : row.status === "dry-run" ? "…" : "·";
1860
- const detail = row.message ? ` (${row.message})` : "";
1861
- return ` ${icon} ${row.key}${detail}`;
2753
+ const detail = row.message ? style.dim(` (${row.message})`) : "";
2754
+ const label = `${row.key}${detail}`;
2755
+ if (row.status === "ok") return ` ${style.success(label)}`;
2756
+ if (row.status === "fail") return ` ${style.fail(label)}`;
2757
+ if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
2758
+ return ` ${style.dim(`· ${label}`)}`;
1862
2759
  }
1863
2760
  function vaultProviderName(loader) {
1864
2761
  if (!loader.has("vault")) return "<missing>";
1865
2762
  return loader.get("vault").providerName;
1866
2763
  }
1867
2764
  //#endregion
1868
- //#region src/commands/setup-workflows.ts
2765
+ //#region src/commands/setup.ts
1869
2766
  /**
1870
- * Thin workflow wrapper templates for `holocron setup`.
2767
+ * `holocron setup` orchestrates per-capability setup actions across
2768
+ * every plugin loaded from `holocron.config.json`.
2769
+ *
2770
+ * Per CLAUDE.md soft-skip: each step is wrapped in a try/catch and
2771
+ * failures don't abort subsequent capabilities. The summary at the end
2772
+ * reports counts so the operator can see what worked + what didn't.
2773
+ *
2774
+ * Per the Standards: when `ctx.dryRun` is true, mutating calls are
2775
+ * replaced with "would" log lines. Read-only probes (e.g.,
2776
+ * `vault.list`) still run so the operator sees real state.
1871
2777
  *
1872
- * Each entry is a complete `.github/workflows/<name>.yml` that delegates
1873
- * to the corresponding reusable `ci-<name>.yml` in `theholocron/.github`.
1874
- * Files are overwritten on each setup run they are generated artifacts.
2778
+ * The orchestrator knows about specific capability methods by name
2779
+ * (e.g., `source.enableVulnerabilityAlerts`). This deliberate coupling
2780
+ * makes the "what does setup do" contract explicit and concrete
2781
+ * decoupling via a per-capability `setupSteps()` method would be more
2782
+ * extensible but pushes the same knowledge into N plugins instead of
2783
+ * one central place.
1875
2784
  */
1876
- const WORKFLOW_REPO = "theholocron/.github";
1877
- const WORKFLOW_REF = "main";
1878
- function ref(name) {
1879
- return `${WORKFLOW_REPO}/.github/workflows/${name}.yml@${WORKFLOW_REF}`;
2785
+ function editorconfigContent() {
2786
+ return [
2787
+ `# AUTO-GENERATED — do not edit directly.`,
2788
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup.ts`,
2789
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
2790
+ `# Tool: holocron setup`,
2791
+ `# Changes: run \`holocron setup\` to regenerate.`,
2792
+ ``,
2793
+ `root = true`,
2794
+ ``,
2795
+ `[*]`,
2796
+ `end_of_line = lf`,
2797
+ `charset = utf-8`,
2798
+ `trim_trailing_whitespace = true`,
2799
+ `insert_final_newline = true`,
2800
+ `indent_style = tab`,
2801
+ `indent_size = 4`,
2802
+ ``,
2803
+ `[.gitattributes]`,
2804
+ `indent_style = space`,
2805
+ `indent_size = 2`,
2806
+ ``,
2807
+ `[*.{json,yml,yaml}]`,
2808
+ `indent_style = space`,
2809
+ `indent_size = 2`,
2810
+ ``,
2811
+ `[*.md]`,
2812
+ `trim_trailing_whitespace = false`,
2813
+ `indent_style = space`,
2814
+ `indent_size = 2`,
2815
+ ``,
2816
+ `[.*{rc,ignore}]`,
2817
+ `indent_style = space`,
2818
+ `indent_size = 2`,
2819
+ ``
2820
+ ].join("\n");
2821
+ }
2822
+ function codecovContent(packages) {
2823
+ const header = [
2824
+ `# AUTO-GENERATED by holocron setup — do not edit directly.`,
2825
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup.ts`,
2826
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
2827
+ `# Tool: holocron setup`,
2828
+ `# Changes: run \`holocron setup\` to regenerate.`
2829
+ ].join("\n");
2830
+ const componentLines = packages.flatMap(({ slug, name }) => [
2831
+ ` - component_id: ${slug}`,
2832
+ ` name: "${name}"`,
2833
+ ` paths:`,
2834
+ ` - packages/${slug}/**`
2835
+ ]);
2836
+ return [
2837
+ header,
2838
+ ``,
2839
+ `codecov:`,
2840
+ ` require_ci_to_pass: true`,
2841
+ ``,
2842
+ `coverage:`,
2843
+ ` precision: 2`,
2844
+ ` round: down`,
2845
+ ` status:`,
2846
+ ` project:`,
2847
+ ` default:`,
2848
+ ` target: auto`,
2849
+ ` threshold: 2%`,
2850
+ ` patch:`,
2851
+ ` default:`,
2852
+ ` target: 80%`,
2853
+ ``,
2854
+ `comment:`,
2855
+ ` layout: "reach,diff,flags,components"`,
2856
+ ` behavior: default`,
2857
+ ` require_changes: true`,
2858
+ ``,
2859
+ `component_management:`,
2860
+ ` default_rules:`,
2861
+ ` statuses:`,
2862
+ ` - type: patch`,
2863
+ ` target: 80%`,
2864
+ ` individual_components:`,
2865
+ ...componentLines.length > 0 ? componentLines : [` []`],
2866
+ ``
2867
+ ].join("\n");
2868
+ }
2869
+ async function readWorkspacePackages(repoRoot) {
2870
+ const packagesDir = join(repoRoot, "packages");
2871
+ const entries = await readdir(packagesDir, { withFileTypes: true }).catch(() => null);
2872
+ if (!entries) return [];
2873
+ const packages = [];
2874
+ for (const entry of entries) {
2875
+ if (!entry.isDirectory()) continue;
2876
+ try {
2877
+ const raw = await readFile(join(packagesDir, entry.name, "package.json"), "utf8");
2878
+ const pkg = JSON.parse(raw);
2879
+ if (typeof pkg.name === "string") packages.push({
2880
+ slug: entry.name,
2881
+ name: pkg.name
2882
+ });
2883
+ } catch {}
2884
+ }
2885
+ return packages.sort((a, b) => a.slug.localeCompare(b.slug));
2886
+ }
2887
+ const EDITORCONFIG_CHECKER_CONFIG = JSON.stringify({
2888
+ Version: "v3.7.0",
2889
+ Verbose: false,
2890
+ Format: "",
2891
+ Debug: false,
2892
+ IgnoreDefaults: false,
2893
+ SpacesAfterTabs: false,
2894
+ NoColor: false,
2895
+ Exclude: ["(^|.+/)LICENSE$", "^public/.*"],
2896
+ AllowedContentTypes: [],
2897
+ PassedFiles: [],
2898
+ Disable: {
2899
+ EndOfLine: false,
2900
+ Indentation: false,
2901
+ InsertFinalNewline: false,
2902
+ TrimTrailingWhitespace: false,
2903
+ IndentSize: false,
2904
+ MaxLineLength: false
2905
+ }
2906
+ }, null, 2) + "\n";
2907
+ const ALEX_CONFIG = JSON.stringify({ allow: [
2908
+ "dead",
2909
+ "failure",
2910
+ "failures",
2911
+ "hook",
2912
+ "hooks",
2913
+ "husky",
2914
+ "period"
2915
+ ] }, null, 2) + "\n";
2916
+ const CANONICAL_LABELS = [
2917
+ {
2918
+ name: "bug",
2919
+ color: "d73a4a",
2920
+ description: "Something isn't working"
2921
+ },
2922
+ {
2923
+ name: "chore",
2924
+ color: "ededed",
2925
+ description: "Maintenance, no user-facing change"
2926
+ },
2927
+ {
2928
+ name: "ci",
2929
+ color: "0075ca",
2930
+ description: "CI/CD pipeline changes"
2931
+ },
2932
+ {
2933
+ name: "dependencies",
2934
+ color: "0366d6",
2935
+ description: "Dependency update"
2936
+ },
2937
+ {
2938
+ name: "documentation",
2939
+ color: "0075ca",
2940
+ description: "Documentation only"
2941
+ },
2942
+ {
2943
+ name: "duplicate",
2944
+ color: "cfd3d7",
2945
+ description: "Already reported"
2946
+ },
2947
+ {
2948
+ name: "enhancement",
2949
+ color: "a2eeef",
2950
+ description: "New feature or request"
2951
+ },
2952
+ {
2953
+ name: "good first issue",
2954
+ color: "7057ff",
2955
+ description: "Good for newcomers"
2956
+ },
2957
+ {
2958
+ name: "help wanted",
2959
+ color: "008672",
2960
+ description: "Extra attention needed"
2961
+ },
2962
+ {
2963
+ name: "invalid",
2964
+ color: "e4e669",
2965
+ description: "Doesn't seem right"
2966
+ },
2967
+ {
2968
+ name: "performance",
2969
+ color: "fbca04",
2970
+ description: "Performance improvement"
2971
+ },
2972
+ {
2973
+ name: "question",
2974
+ color: "d876e3",
2975
+ description: "Further information requested"
2976
+ },
2977
+ {
2978
+ name: "refactor",
2979
+ color: "cfd3d7",
2980
+ description: "Code restructuring"
2981
+ },
2982
+ {
2983
+ name: "released",
2984
+ color: "ededed",
2985
+ description: "Included in a release"
2986
+ },
2987
+ {
2988
+ name: "test",
2989
+ color: "bfd4f2",
2990
+ description: "Test-related changes"
2991
+ },
2992
+ {
2993
+ name: "triage",
2994
+ color: "e4e669",
2995
+ description: "Needs investigation"
2996
+ },
2997
+ {
2998
+ name: "wontfix",
2999
+ color: "ffffff",
3000
+ description: "Won't be addressed"
3001
+ }
3002
+ ];
3003
+ const STALE_LABELS = [
3004
+ "github_actions",
3005
+ "javascript",
3006
+ "autorelease: pending",
3007
+ "autorelease: tagged",
3008
+ "released on @alpha"
3009
+ ];
3010
+ function labelerConfig() {
3011
+ return [
3012
+ `# AUTO-GENERATED — do not edit directly.`,
3013
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup.ts`,
3014
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
3015
+ `# Tool: holocron setup`,
3016
+ `# Changes: run \`holocron setup\` to regenerate.`,
3017
+ ``,
3018
+ `bug:`,
3019
+ ` - '^fix'`,
3020
+ ``,
3021
+ `chore:`,
3022
+ ` - '^chore(?!\\(deps)'`,
3023
+ ``,
3024
+ `ci:`,
3025
+ ` - '^ci'`,
3026
+ ``,
3027
+ `dependencies:`,
3028
+ ` - '^chore\\(deps'`,
3029
+ ``,
3030
+ `documentation:`,
3031
+ ` - '^docs'`,
3032
+ ``,
3033
+ `enhancement:`,
3034
+ ` - '^feat'`,
3035
+ ``,
3036
+ `performance:`,
3037
+ ` - '^perf'`,
3038
+ ``,
3039
+ `refactor:`,
3040
+ ` - '^refactor'`,
3041
+ ``,
3042
+ `test:`,
3043
+ ` - '^test'`,
3044
+ ``
3045
+ ].join("\n");
1880
3046
  }
1881
- /** Header prepended when holocron setup writes a thin caller to a repo. */
1882
- const WORKFLOW_HEADER = `\
1883
- # AUTO-GENERATED by holocron — do not edit directly.
1884
- # Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts
1885
- # Run \`holocron setup\` to regenerate.
1886
-
1887
- `;
1888
- const WORKFLOW_TEMPLATES = {
1889
- lint: `\
1890
- name: Lint
1891
-
1892
- on: # yamllint disable-line rule:truthy
1893
- push:
1894
- branches: [main, alpha]
1895
- pull_request:
1896
-
1897
- concurrency:
1898
- group: lint-\${{ github.ref }}
1899
- cancel-in-progress: true
1900
-
1901
- permissions:
1902
- contents: write
1903
- statuses: write
1904
-
1905
- jobs:
1906
- lint:
1907
- name: Lint
1908
- uses: ${ref("lint")}
1909
- secrets: inherit
1910
- with:
1911
- enable-auto-commit: true
1912
- `,
1913
- test: `\
1914
- name: Test
1915
-
1916
- on: # yamllint disable-line rule:truthy
1917
- push:
1918
- branches: [main, alpha]
1919
- pull_request:
1920
-
1921
- concurrency:
1922
- group: test-\${{ github.ref }}
1923
- cancel-in-progress: true
1924
-
1925
- permissions:
1926
- contents: read
1927
-
1928
- jobs:
1929
- test:
1930
- name: Test
1931
- uses: ${ref("test")}
1932
- secrets: inherit
1933
- `,
1934
- typecheck: `\
1935
- name: Typecheck
1936
-
1937
- on: # yamllint disable-line rule:truthy
1938
- push:
1939
- branches: [main, alpha]
1940
- pull_request:
1941
-
1942
- concurrency:
1943
- group: typecheck-\${{ github.ref }}
1944
- cancel-in-progress: true
1945
-
1946
- permissions:
1947
- contents: read
1948
-
1949
- jobs:
1950
- typecheck:
1951
- name: Typecheck
1952
- uses: ${ref("typecheck")}
1953
- secrets: inherit
1954
- `,
1955
- codeql: `\
1956
- name: CodeQL
1957
-
1958
- on: # yamllint disable-line rule:truthy
1959
- push:
1960
- branches:
1961
- - main
1962
- pull_request:
1963
- branches:
1964
- - main
1965
- schedule:
1966
- - cron: "0 0 * * 1"
1967
-
1968
- permissions:
1969
- actions: read
1970
- contents: read
1971
- security-events: write
1972
-
1973
- jobs:
1974
- codeql:
1975
- uses: ${ref("codeql")}
1976
- secrets: inherit
1977
- `,
1978
- review: `\
1979
- name: Review
1980
-
1981
- on: # yamllint disable-line rule:truthy
1982
- pull_request:
1983
-
1984
- concurrency:
1985
- group: review-\${{ github.ref }}
1986
- cancel-in-progress: true
1987
-
1988
- permissions:
1989
- contents: read
1990
- checks: write
1991
- pull-requests: write
1992
-
1993
- jobs:
1994
- review:
1995
- name: Review
1996
- uses: ${ref("review")}
1997
- secrets: inherit
1998
- `,
1999
- release: `\
2000
- name: Release
2001
-
2002
- on: # yamllint disable-line rule:truthy
2003
- push:
2004
- branches:
2005
- - main
2006
-
2007
- permissions:
2008
- contents: write
2009
- id-token: write
2010
- issues: write
2011
- pull-requests: write
2012
-
2013
- jobs:
2014
- release:
2015
- uses: ${ref("release")}
2016
- secrets: inherit
2017
- `,
2018
- stale: `\
2019
- name: Stale
2020
-
2021
- on: # yamllint disable-line rule:truthy
2022
- schedule:
2023
- - cron: "30 1 * * *"
2024
-
2025
- permissions:
2026
- contents: write
2027
- issues: write
2028
- pull-requests: write
2029
-
2030
- jobs:
2031
- stale:
2032
- uses: ${ref("stale")}
2033
- secrets: inherit
2034
- `,
2035
- greetings: `\
2036
- name: Greetings
2037
-
2038
- on: # yamllint disable-line rule:truthy
2039
- pull_request:
2040
- issues:
2041
-
2042
- permissions:
2043
- issues: write
2044
- pull-requests: write
2045
-
2046
- jobs:
2047
- greetings:
2048
- uses: ${ref("greetings")}
2049
- secrets: inherit
2050
- `,
2051
- dependencies: `\
2052
- name: Dependencies
2053
-
2054
- on: # yamllint disable-line rule:truthy
2055
- pull_request:
2056
-
2057
- permissions:
2058
- contents: write
2059
- pull-requests: write
2060
-
2061
- jobs:
2062
- dependencies:
2063
- uses: ${ref("dependencies")}
2064
- secrets: inherit
2065
- `,
2066
- "bookkeeping-pr": `\
2067
- name: PR Bookkeeping
2068
-
2069
- on: # yamllint disable-line rule:truthy
2070
- pull_request:
2071
- types:
2072
- - opened
2073
- - edited
2074
-
2075
- permissions:
2076
- contents: read
2077
- pull-requests: write
2078
-
2079
- jobs:
2080
- bookkeeping:
2081
- uses: ${ref("bookkeeping-pr")}
2082
- secrets: inherit
2083
- `,
2084
- audit: `\
2085
- name: Audit
2086
-
2087
- on: # yamllint disable-line rule:truthy
2088
- push:
2089
- branches: [main, alpha]
2090
- pull_request:
2091
-
2092
- permissions:
2093
- contents: read
2094
-
2095
- jobs:
2096
- audit:
2097
- uses: ${ref("audit")}
2098
- secrets: inherit
2099
- `
2100
- };
2101
- const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
2102
- //#endregion
2103
- //#region src/commands/setup.ts
2104
- const DEPENDABOT_CONFIG = `\
2105
- # AUTO-GENERATED by holocron — run \`holocron setup\` to regenerate.
2106
- version: 2
2107
- updates:
2108
- - package-ecosystem: npm
2109
- directory: /
2110
- schedule:
2111
- interval: weekly
2112
- groups:
2113
- security-patches:
2114
- applies-to: security-updates
2115
- patterns:
2116
- - "*"
2117
- all-dependencies:
2118
- update-types:
2119
- - minor
2120
- - patch
2121
-
2122
- - package-ecosystem: github-actions
2123
- directory: /
2124
- schedule:
2125
- interval: weekly
2126
- groups:
2127
- all-actions:
2128
- patterns:
2129
- - "*"
2130
- `;
2131
3047
  const RULESET_NAME = "holocron-default-branch";
2132
3048
  const BALANCED_REPO_SETTINGS = {
2133
3049
  allow_squash_merge: true,
@@ -2169,7 +3085,7 @@ function buildRulesetPayload(requiredChecks = []) {
2169
3085
  dismiss_stale_reviews_on_push: false,
2170
3086
  require_code_owner_review: false,
2171
3087
  require_last_push_approval: false,
2172
- required_review_thread_resolution: false
3088
+ required_review_thread_resolution: true
2173
3089
  }
2174
3090
  }
2175
3091
  ];
@@ -2184,6 +3100,11 @@ function buildRulesetPayload(requiredChecks = []) {
2184
3100
  name: RULESET_NAME,
2185
3101
  target: "branch",
2186
3102
  enforcement: "active",
3103
+ bypass_actors: [{
3104
+ actor_id: 4,
3105
+ actor_type: "RepositoryRole",
3106
+ bypass_mode: "always"
3107
+ }],
2187
3108
  conditions: { ref_name: {
2188
3109
  include: ["~DEFAULT_BRANCH"],
2189
3110
  exclude: []
@@ -2217,7 +3138,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
2217
3138
  message: "created"
2218
3139
  };
2219
3140
  } catch (err) {
2220
- if (!(err instanceof ProviderApiError) || err.status !== 403) return {
3141
+ if (!(err instanceof ProviderApiError$1) || err.status !== 403) return {
2221
3142
  capability: "source",
2222
3143
  step,
2223
3144
  status: "fail",
@@ -2234,7 +3155,7 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
2234
3155
  message: `classic protection on ${repo.defaultBranch}`
2235
3156
  };
2236
3157
  } catch (err) {
2237
- if (err instanceof ProviderApiError && err.status === 403) return {
3158
+ if (err instanceof ProviderApiError$1 && err.status === 403) return {
2238
3159
  capability: "source",
2239
3160
  step,
2240
3161
  status: "skip",
@@ -2251,16 +3172,18 @@ async function upsertBranchProtection(source, dryRun, requiredChecks) {
2251
3172
  async function runSetup(input) {
2252
3173
  const print = input.print ?? ((line) => console.log(line));
2253
3174
  const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
2254
- await loader.load();
3175
+ await withSpinner("Loading plugins…", () => loader.load());
2255
3176
  const config = input.loaded.resolved;
2256
3177
  const dryRun = input.context.dryRun ?? false;
2257
3178
  const steps = [];
2258
- print(`Holocron setup ${config.project.name}${dryRun ? " (dry-run)" : ""}`);
2259
- print(` config: ${input.loaded.filepath}`);
3179
+ const repo = config.repo;
3180
+ const effectivePreset = repo?.protection;
3181
+ print(style.header(`Holocron setup — ${config.name}${dryRun ? " (dry-run)" : ""}`));
3182
+ print(style.dim(` config: ${input.loaded.filepath}`));
2260
3183
  print("");
2261
3184
  if (loader.has("source")) {
2262
3185
  const source = loader.get("source");
2263
- print("source");
3186
+ print(style.step("source"));
2264
3187
  for (const method of [
2265
3188
  "enableVulnerabilityAlerts",
2266
3189
  "enableAutomatedSecurityFixes",
@@ -2273,27 +3196,37 @@ async function runSetup(input) {
2273
3196
  }));
2274
3197
  print(formatStep(steps[steps.length - 1]));
2275
3198
  }
2276
- steps.push(await runStep("source", "enableCodeScanning", dryRun, async () => {
2277
- return await source.enableCodeScanning();
3199
+ const usesAdvancedCodeQL = (config.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("codeql");
3200
+ steps.push(await runStep("source", usesAdvancedCodeQL ? "disableDefaultCodeScanning" : "enableCodeScanning", dryRun, async () => {
3201
+ if (usesAdvancedCodeQL) await source.disableDefaultCodeScanning();
3202
+ else return await source.enableCodeScanning();
2278
3203
  }));
2279
3204
  print(formatStep(steps[steps.length - 1]));
2280
- const policy = config.project.repoPolicy;
2281
- if (policy && policy.preset !== "none") {
2282
- const preset = policy.preset ?? "balanced";
3205
+ if (effectivePreset && effectivePreset !== "none") {
2283
3206
  steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
2284
3207
  await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
2285
3208
  }));
2286
3209
  print(formatStep(steps[steps.length - 1]));
2287
- const requiredChecks = preset === "strict" ? policy.requiredChecks ?? [] : [];
3210
+ const configuredWorkflowNames = (config.workflows ?? []).map((entry) => typeof entry === "string" ? entry : entry.name);
3211
+ const requiredChecks = effectivePreset === "strict" ? [
3212
+ "DCO",
3213
+ ...configuredWorkflowNames.flatMap((name) => {
3214
+ const ctx = WORKFLOW_CHECK_CONTEXTS[name];
3215
+ return ctx ? [ctx] : [];
3216
+ }),
3217
+ ...repo?.requiredChecks ?? []
3218
+ ] : [];
2288
3219
  steps.push(await upsertBranchProtection(source, dryRun, requiredChecks));
2289
3220
  print(formatStep(steps[steps.length - 1]));
2290
3221
  }
2291
3222
  }
2292
- const workflows = config.project.workflows;
3223
+ const workflows = config.workflows;
2293
3224
  if (loader.has("source") && workflows && workflows.length > 0) {
2294
3225
  const source = loader.get("source");
2295
- print("workflows");
2296
- for (const name of workflows) {
3226
+ print(style.step("workflows"));
3227
+ for (const entry of workflows) {
3228
+ const name = typeof entry === "string" ? entry : entry.name;
3229
+ const withOverrides = typeof entry === "object" ? entry.with : void 0;
2297
3230
  if (!KNOWN_WORKFLOWS.has(name)) {
2298
3231
  steps.push({
2299
3232
  capability: "source",
@@ -2305,21 +3238,103 @@ async function runSetup(input) {
2305
3238
  continue;
2306
3239
  }
2307
3240
  steps.push(await runStep("source", `write workflow ${name}`, dryRun, async () => {
2308
- await source.writeWorkflowFile(`${name}.yml`, WORKFLOW_HEADER + WORKFLOW_TEMPLATES[name]);
3241
+ await source.writeWorkflowFile(`${name}.yml`, workflowHeader() + generateThinCallerContent(name, withOverrides));
2309
3242
  }));
2310
3243
  print(formatStep(steps[steps.length - 1]));
2311
3244
  }
2312
3245
  }
2313
- if (loader.has("source") && config.project.repoPolicy?.preset !== "none") {
3246
+ if (loader.has("source") && (config.workflows ?? []).map((e) => typeof e === "string" ? e : e.name).includes("bookkeeping")) {
3247
+ const source = loader.get("source");
3248
+ steps.push(await runStep("source", "write .github/labeler.yml", dryRun, async () => {
3249
+ await source.writeRepoFile(".github/labeler.yml", labelerConfig());
3250
+ }));
3251
+ print(formatStep(steps[steps.length - 1]));
3252
+ }
3253
+ if (loader.has("source") && effectivePreset !== "none") {
2314
3254
  const source = loader.get("source");
2315
3255
  steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
2316
- await source.writeRepoFile(".github/dependabot.yml", DEPENDABOT_CONFIG);
3256
+ await source.writeRepoFile(".github/dependabot.yml", dependabot_default);
3257
+ }));
3258
+ print(formatStep(steps[steps.length - 1]));
3259
+ }
3260
+ if (loader.has("source")) {
3261
+ const source = loader.get("source");
3262
+ steps.push(await runStep("source", "write .alexrc.json", dryRun, async () => {
3263
+ await source.writeRepoFile(".alexrc.json", ALEX_CONFIG);
3264
+ }));
3265
+ print(formatStep(steps[steps.length - 1]));
3266
+ steps.push(await runStep("source", "write .editorconfig", dryRun, async () => {
3267
+ await source.writeRepoFile(".editorconfig", editorconfigContent());
3268
+ }));
3269
+ print(formatStep(steps[steps.length - 1]));
3270
+ steps.push(await runStep("source", "write .editorconfig-checker.json", dryRun, async () => {
3271
+ await source.writeRepoFile(".editorconfig-checker.json", EDITORCONFIG_CHECKER_CONFIG);
3272
+ }));
3273
+ print(formatStep(steps[steps.length - 1]));
3274
+ steps.push(await runStep("source", "write codecov.yml", dryRun, async () => {
3275
+ const packages = await readWorkspacePackages(input.context.repoRoot);
3276
+ await source.writeRepoFile("codecov.yml", codecovContent(packages));
3277
+ return packages.length > 0 ? `${packages.length} components` : "no components";
2317
3278
  }));
2318
3279
  print(formatStep(steps[steps.length - 1]));
3280
+ if (source.syncLabels) {
3281
+ steps.push(await runStep("source", "sync labels", dryRun, async () => {
3282
+ return source.syncLabels(CANONICAL_LABELS, STALE_LABELS);
3283
+ }));
3284
+ print(formatStep(steps[steps.length - 1]));
3285
+ }
3286
+ const properties = {};
3287
+ if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
3288
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
3289
+ properties["monorepo"] = String(isMonorepo);
3290
+ const manual = repo?.properties ?? {};
3291
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
3292
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
3293
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
3294
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
3295
+ if (source.syncProperties) {
3296
+ steps.push(await runStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
3297
+ print(formatStep(steps[steps.length - 1]));
3298
+ }
3299
+ const topics = repo?.topics ?? [];
3300
+ if (topics.length > 0 && source.syncTopics) {
3301
+ steps.push(await runStep("source", "sync topics", dryRun, () => source.syncTopics(topics)));
3302
+ print(formatStep(steps[steps.length - 1]));
3303
+ }
3304
+ const teams = repo?.teams ?? [];
3305
+ if (teams.length > 0) if (source.syncTeams) {
3306
+ steps.push(await runStep("source", "sync teams", dryRun, () => source.syncTeams(teams)));
3307
+ print(formatStep(steps[steps.length - 1]));
3308
+ const repoCoord = input.context.repo ?? repo?.name ?? "";
3309
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
3310
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
3311
+ slug: t,
3312
+ permission: "push"
3313
+ } : t).filter((t) => [
3314
+ "push",
3315
+ "maintain",
3316
+ "admin"
3317
+ ].includes(t.permission));
3318
+ if (org && writeableTeams.length > 0) {
3319
+ steps.push(await runStep("source", "write .github/CODEOWNERS", dryRun, async () => {
3320
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
3321
+ await source.writeRepoFile(".github/CODEOWNERS", content);
3322
+ }));
3323
+ print(formatStep(steps[steps.length - 1]));
3324
+ }
3325
+ } else {
3326
+ steps.push({
3327
+ capability: "source",
3328
+ step: "sync teams",
3329
+ status: "skip",
3330
+ message: "provider does not implement syncTeams"
3331
+ });
3332
+ print(formatStep(steps[steps.length - 1]));
3333
+ }
2319
3334
  }
2320
3335
  if (loader.has("environments")) {
2321
3336
  const envs = loader.get("environments");
2322
- print("environments");
3337
+ print(style.step("environments"));
2323
3338
  for (const envName of ["staging", "production"]) {
2324
3339
  steps.push(await runStep("environments", `upsert ${envName}`, dryRun, async () => {
2325
3340
  await envs.upsertEnvironment({ name: envName });
@@ -2329,15 +3344,15 @@ async function runSetup(input) {
2329
3344
  }
2330
3345
  if (loader.has("deployment")) {
2331
3346
  const deploy = loader.get("deployment");
2332
- print("deployment");
2333
- steps.push(await runStep("deployment", `ensureProject ${config.project.name}`, dryRun, async () => {
2334
- await deploy.ensureProject({ name: config.project.name });
3347
+ print(style.step("deployment"));
3348
+ steps.push(await runStep("deployment", `ensureProject ${config.name}`, dryRun, async () => {
3349
+ await deploy.ensureProject({ name: config.name });
2335
3350
  }));
2336
3351
  print(formatStep(steps[steps.length - 1]));
2337
3352
  }
2338
3353
  if (loader.has("auth")) {
2339
3354
  const auth = loader.get("auth");
2340
- print("auth");
3355
+ print(style.step("auth"));
2341
3356
  if (auth.ensureWebhookApp) {
2342
3357
  steps.push(await runStep("auth", "ensureWebhookApp", dryRun, async () => {
2343
3358
  return `webhook ${(await auth.ensureWebhookApp()).alreadyExists ? "exists" : "created"}`;
@@ -2355,10 +3370,10 @@ async function runSetup(input) {
2355
3370
  }
2356
3371
  if (loader.has("vault")) {
2357
3372
  const vault = loader.get("vault");
2358
- print("vault");
3373
+ print(style.step("vault"));
2359
3374
  if (vault.ensureProject) {
2360
- steps.push(await runStep("vault", `ensureProject ${config.project.name}`, dryRun, async () => {
2361
- return `project ${(await vault.ensureProject(config.project.name)).alreadyExists ? "exists" : "created"}`;
3375
+ steps.push(await runStep("vault", `ensureProject ${config.name}`, dryRun, async () => {
3376
+ return `project ${(await vault.ensureProject(config.name)).alreadyExists ? "exists" : "created"}`;
2362
3377
  }));
2363
3378
  print(formatStep(steps[steps.length - 1]));
2364
3379
  }
@@ -2368,7 +3383,7 @@ async function runSetup(input) {
2368
3383
  "prd"
2369
3384
  ]) {
2370
3385
  steps.push(await runStep("vault", `ensureEnvironment ${envName}`, dryRun, async () => {
2371
- return `${envName} ${(await vault.ensureEnvironment(config.project.name, envName)).alreadyExists ? "exists" : "created"}`;
3386
+ return `${envName} ${(await vault.ensureEnvironment(config.name, envName)).alreadyExists ? "exists" : "created"}`;
2372
3387
  }));
2373
3388
  print(formatStep(steps[steps.length - 1]));
2374
3389
  }
@@ -2392,7 +3407,7 @@ async function runSetup(input) {
2392
3407
  }
2393
3408
  if (loader.has("tooling")) {
2394
3409
  const tools = loader.get("tooling");
2395
- print("tooling");
3410
+ print(style.step("tooling"));
2396
3411
  for (const tool of tools) {
2397
3412
  steps.push(await runStep("tooling", `${tool.providerName}.sync`, dryRun, async () => {
2398
3413
  await tool.sync();
@@ -2400,6 +3415,23 @@ async function runSetup(input) {
2400
3415
  print(formatStep(steps[steps.length - 1]));
2401
3416
  }
2402
3417
  }
3418
+ if (config.skills && config.skills.length > 0 && config.agent) {
3419
+ print(style.step("skills"));
3420
+ if (!(config.agent in AGENT_SYMLINK_PATHS)) steps.push({
3421
+ capability: "skills",
3422
+ step: "install skills",
3423
+ status: "skip",
3424
+ message: `agent "${config.agent}" has no known skill install path`
3425
+ });
3426
+ else steps.push(await runStep("skills", "install skills", dryRun, async () => {
3427
+ return await installSkills({
3428
+ agent: config.agent,
3429
+ skills: config.skills,
3430
+ repoRoot: input.context.repoRoot
3431
+ });
3432
+ }));
3433
+ print(formatStep(steps[steps.length - 1]));
3434
+ }
2403
3435
  const summary = steps.reduce((acc, s) => {
2404
3436
  if (s.status === "ok") acc.ok += 1;
2405
3437
  else if (s.status === "fail") acc.fail += 1;
@@ -2413,12 +3445,118 @@ async function runSetup(input) {
2413
3445
  dryRun: 0
2414
3446
  });
2415
3447
  print("");
2416
- print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
3448
+ const summaryLine = ` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`;
3449
+ print(summary.fail > 0 ? style.fail(summaryLine.trim()) : style.success(summaryLine.trim()));
3450
+ if (steps.some((s) => s.reason === "permissions")) {
3451
+ print("");
3452
+ print(style.warn("Some steps failed with 403 (insufficient token permissions)."));
3453
+ print(style.hint(" These operations require a temporary fine-grained PAT with the"));
3454
+ print(style.hint(" following repository permissions:"));
3455
+ print("");
3456
+ print(style.hint(" · Administration — read and write"));
3457
+ print(style.hint(" · Code scanning alerts — read and write"));
3458
+ print(style.hint(" · Contents — read and write"));
3459
+ print(style.hint(" · Secret scanning alerts — read and write"));
3460
+ print(style.hint(" · Workflows — read and write"));
3461
+ print(style.hint(" · Metadata — read (added automatically)"));
3462
+ print("");
3463
+ print(style.hint(" Create one at: https://github.com/settings/personal-access-tokens/new"));
3464
+ print(style.hint(" Then re-run: holocron setup --token <your-temp-pat>"));
3465
+ print(style.hint(" You can revoke it immediately after setup completes."));
3466
+ }
2417
3467
  return {
2418
3468
  steps,
2419
3469
  summary
2420
3470
  };
2421
3471
  }
3472
+ const AGENTS_SKILLS_ROOT = ".agents/skills";
3473
+ /** Relative path of the agent-specific symlink. undefined = unsupported agent. */
3474
+ const AGENT_SYMLINK_PATHS = { claude: (name) => `.claude/skills/${name}` };
3475
+ const GITIGNORE_BLOCK_START = "# managed by holocron setup — skills";
3476
+ const GITIGNORE_BLOCK_END = "# end managed by holocron setup — skills";
3477
+ async function installSkills({ agent, skills, repoRoot }) {
3478
+ const symlinkFn = AGENT_SYMLINK_PATHS[agent];
3479
+ if (!symlinkFn) return `agent "${agent}" has no known skill install path — skipping`;
3480
+ const require = createRequire(pathToFileURL(join(repoRoot, "package.json")));
3481
+ let skillsRoot;
3482
+ try {
3483
+ skillsRoot = dirname(require.resolve("@theholocron/skills/package.json"));
3484
+ } catch {
3485
+ throw new Error("@theholocron/skills not found — run: pnpm add -D @theholocron/skills");
3486
+ }
3487
+ const gitignorePath = join(repoRoot, ".gitignore");
3488
+ const existingContent = await readFile(gitignorePath, "utf8").catch(() => "");
3489
+ const previouslyInstalled = parsePreviousSkills(existingContent, symlinkFn);
3490
+ const currentSet = new Set(skills);
3491
+ const stale = previouslyInstalled.filter((n) => !currentSet.has(n));
3492
+ for (const name of stale) {
3493
+ await rm(join(repoRoot, symlinkFn(name)), { force: true }).catch(() => void 0);
3494
+ await rm(join(repoRoot, AGENTS_SKILLS_ROOT, name), {
3495
+ recursive: true,
3496
+ force: true
3497
+ }).catch(() => void 0);
3498
+ }
3499
+ const installed = [];
3500
+ const missing = [];
3501
+ for (const name of skills) {
3502
+ const srcDir = join(skillsRoot, "skills", name);
3503
+ try {
3504
+ await stat(srcDir);
3505
+ } catch {
3506
+ missing.push(name);
3507
+ continue;
3508
+ }
3509
+ const agentsDir = join(repoRoot, AGENTS_SKILLS_ROOT, name);
3510
+ await copyDirRecursive(srcDir, agentsDir);
3511
+ const symlinkPath = join(repoRoot, symlinkFn(name));
3512
+ await mkdir(dirname(symlinkPath), { recursive: true });
3513
+ try {
3514
+ await unlink(symlinkPath);
3515
+ } catch {}
3516
+ await symlink(relative(dirname(symlinkPath), agentsDir).replace(/\\/g, "/"), symlinkPath);
3517
+ installed.push(name);
3518
+ }
3519
+ if (installed.length > 0 || stale.length > 0 || missing.length > 0) await updateSkillsGitignore(gitignorePath, existingContent, [...installed, ...missing], symlinkFn);
3520
+ const parts = [`installed ${installed.length}`];
3521
+ if (stale.length > 0) parts.push(`pruned: ${stale.join(", ")}`);
3522
+ if (missing.length > 0) parts.push(`unknown: ${missing.join(", ")}`);
3523
+ return parts.join("; ");
3524
+ }
3525
+ /** Extract skill names from the previous gitignore block so stale dirs can be pruned. */
3526
+ function parsePreviousSkills(gitignoreContent, symlinkFn) {
3527
+ if (!gitignoreContent.includes(GITIGNORE_BLOCK_START)) return [];
3528
+ const startIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_START);
3529
+ const endIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_END, startIdx);
3530
+ const block = endIdx !== -1 ? gitignoreContent.slice(startIdx, endIdx) : gitignoreContent.slice(startIdx);
3531
+ const placeholder = "__placeholder__";
3532
+ const symlinkPrefix = `/${symlinkFn(placeholder)}`.replace(placeholder, "");
3533
+ return block.split("\n").filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
3534
+ }
3535
+ async function copyDirRecursive(src, dest) {
3536
+ await mkdir(dest, { recursive: true });
3537
+ const entries = await readdir(src, { withFileTypes: true });
3538
+ for (const entry of entries) {
3539
+ const srcPath = join(src, entry.name);
3540
+ const destPath = join(dest, entry.name);
3541
+ if (entry.isDirectory()) await copyDirRecursive(srcPath, destPath);
3542
+ else await copyFile(srcPath, destPath);
3543
+ }
3544
+ }
3545
+ async function updateSkillsGitignore(gitignorePath, existingContent, skills, symlinkFn) {
3546
+ const block = [
3547
+ GITIGNORE_BLOCK_START,
3548
+ ...[`/${AGENTS_SKILLS_ROOT}/`, ...skills.map((n) => `/${symlinkFn(n)}`)],
3549
+ GITIGNORE_BLOCK_END
3550
+ ].join("\n");
3551
+ let content;
3552
+ if (existingContent.includes(GITIGNORE_BLOCK_START)) {
3553
+ const start = existingContent.indexOf(GITIGNORE_BLOCK_START);
3554
+ const end = existingContent.indexOf(GITIGNORE_BLOCK_END, start);
3555
+ const afterBlock = end !== -1 ? existingContent.slice(end + 40) : "\n";
3556
+ content = existingContent.slice(0, start) + block + afterBlock;
3557
+ } else content = (existingContent.trimEnd() ? existingContent.trimEnd() + "\n\n" : "") + block + "\n";
3558
+ await writeFile(gitignorePath, content, "utf8");
3559
+ }
2422
3560
  async function runStep(capability, step, dryRun, body) {
2423
3561
  if (dryRun) return {
2424
3562
  capability,
@@ -2435,6 +3573,16 @@ async function runStep(capability, step, dryRun, body) {
2435
3573
  if (typeof note === "string") result.message = note;
2436
3574
  return result;
2437
3575
  } catch (err) {
3576
+ if (err instanceof ProviderApiError$1 && err.status === 403) {
3577
+ const reason = classify403(err);
3578
+ return {
3579
+ capability,
3580
+ step,
3581
+ status: "fail",
3582
+ message: err.message,
3583
+ reason
3584
+ };
3585
+ }
2438
3586
  return {
2439
3587
  capability,
2440
3588
  step,
@@ -2443,26 +3591,335 @@ async function runStep(capability, step, dryRun, body) {
2443
3591
  };
2444
3592
  }
2445
3593
  }
3594
+ function classify403(err) {
3595
+ const detailText = typeof err.details === "string" ? err.details : typeof err.details === "object" && err.details !== null && "message" in err.details ? String(err.details.message) : "";
3596
+ const text = `${err.message} ${detailText}`.toLowerCase();
3597
+ if (text.includes("advanced security") || text.includes("not enabled for this repository") || text.includes("upgrade") || text.includes("not available on")) return "plan";
3598
+ return "permissions";
3599
+ }
2446
3600
  function formatStep(step) {
3601
+ const tag = step.reason === "permissions" ? " [permissions]" : step.reason === "plan" ? " [plan restriction]" : "";
3602
+ const detail = step.message ? style.dim(` (${step.message})`) : "";
3603
+ const label = `${step.step}${tag}${detail}`;
3604
+ if (step.status === "ok") return ` ${style.success(label)}`;
3605
+ if (step.status === "fail") return ` ${style.fail(label)}`;
3606
+ if (step.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3607
+ return ` ${style.dim(`· ${label}`)}`;
3608
+ }
3609
+ //#endregion
3610
+ //#region src/commands/skills.ts
3611
+ async function runSkillsInstall(input) {
3612
+ const print = input.print ?? ((line) => console.log(line));
3613
+ const config = input.loaded.resolved;
3614
+ if (!config.agent || !config.skills?.length) {
3615
+ print("Nothing to install — set `agent` and `skills` in holocron.config.ts");
3616
+ return;
3617
+ }
3618
+ if (input.context.dryRun) {
3619
+ print(`Would install ${config.skills.length} skill(s) for agent: ${config.agent}`);
3620
+ for (const name of config.skills) print(` → would install: ${name}`);
3621
+ return;
3622
+ }
3623
+ print(`Installing ${config.skills.length} skill(s) for agent: ${config.agent}`);
3624
+ try {
3625
+ print(` → ${await installSkills({
3626
+ agent: config.agent,
3627
+ skills: config.skills,
3628
+ repoRoot: input.context.repoRoot
3629
+ })}`);
3630
+ } catch (err) {
3631
+ print(` ✗ ${err instanceof Error ? err.message : String(err)}`);
3632
+ }
3633
+ }
3634
+ //#endregion
3635
+ //#region src/commands/sync.ts
3636
+ const SYNC_STEPS = [
3637
+ "labels",
3638
+ "properties",
3639
+ "teams",
3640
+ "topics",
3641
+ "keywords",
3642
+ "description"
3643
+ ];
3644
+ const LOCAL_STEPS = /* @__PURE__ */ new Set(["keywords", "description"]);
3645
+ async function runSync(input) {
3646
+ const print = input.print ?? ((line) => console.log(line));
3647
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3648
+ const config = input.loaded.resolved;
3649
+ const dryRun = input.context.dryRun ?? false;
3650
+ const requestedSteps = input.steps;
3651
+ const steps = [];
3652
+ if (!requestedSteps || requestedSteps.some((s) => !LOCAL_STEPS.has(s))) await loader.load();
3653
+ else try {
3654
+ await loader.load();
3655
+ } catch (err) {
3656
+ if (!(err instanceof AuthError)) throw err;
3657
+ }
3658
+ print(`Holocron sync — ${config.name}${dryRun ? " (dry-run)" : ""}`);
3659
+ print(` config: ${input.loaded.filepath}`);
3660
+ print("");
3661
+ if (loader.has("source")) {
3662
+ const source = loader.get("source");
3663
+ print(" → source");
3664
+ for (const stepName of SYNC_STEPS) {
3665
+ if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
3666
+ if (LOCAL_STEPS.has(stepName)) continue;
3667
+ if (stepName === "labels") if (source.syncLabels) {
3668
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
3669
+ print(formatSyncStep(steps[steps.length - 1]));
3670
+ } else {
3671
+ steps.push({
3672
+ capability: "source",
3673
+ step: "sync labels",
3674
+ status: "skip",
3675
+ message: "provider does not implement syncLabels"
3676
+ });
3677
+ print(formatSyncStep(steps[steps.length - 1]));
3678
+ }
3679
+ if (stepName === "properties") if (source.syncProperties) {
3680
+ const repo = config.repo;
3681
+ const properties = {};
3682
+ const effectivePreset = repo?.protection;
3683
+ if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
3684
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
3685
+ properties["monorepo"] = String(isMonorepo);
3686
+ const manual = repo?.properties ?? {};
3687
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
3688
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
3689
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
3690
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
3691
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
3692
+ print(formatSyncStep(steps[steps.length - 1]));
3693
+ } else {
3694
+ steps.push({
3695
+ capability: "source",
3696
+ step: "sync properties",
3697
+ status: "skip",
3698
+ message: "provider does not implement syncProperties"
3699
+ });
3700
+ print(formatSyncStep(steps[steps.length - 1]));
3701
+ }
3702
+ if (stepName === "teams") {
3703
+ const teams = config.repo?.teams ?? [];
3704
+ if (teams.length === 0) {
3705
+ steps.push({
3706
+ capability: "source",
3707
+ step: "sync teams",
3708
+ status: "skip",
3709
+ message: "no teams configured"
3710
+ });
3711
+ print(formatSyncStep(steps[steps.length - 1]));
3712
+ } else if (source.syncTeams) {
3713
+ steps.push(await runSyncStep("source", "sync teams", dryRun, () => source.syncTeams(teams)));
3714
+ print(formatSyncStep(steps[steps.length - 1]));
3715
+ const repoCoord = input.context.repo ?? config.repo?.name ?? "";
3716
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
3717
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
3718
+ slug: t,
3719
+ permission: "push"
3720
+ } : t).filter((t) => [
3721
+ "push",
3722
+ "maintain",
3723
+ "admin"
3724
+ ].includes(t.permission));
3725
+ if (org && writeableTeams.length > 0) {
3726
+ steps.push(await runSyncStep("source", "write .github/CODEOWNERS", dryRun, async () => {
3727
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
3728
+ await source.writeRepoFile(".github/CODEOWNERS", content);
3729
+ }));
3730
+ print(formatSyncStep(steps[steps.length - 1]));
3731
+ }
3732
+ } else {
3733
+ steps.push({
3734
+ capability: "source",
3735
+ step: "sync teams",
3736
+ status: "skip",
3737
+ message: "provider does not implement syncTeams"
3738
+ });
3739
+ print(formatSyncStep(steps[steps.length - 1]));
3740
+ }
3741
+ }
3742
+ if (stepName === "topics") {
3743
+ const topics = config.repo?.topics ?? [];
3744
+ if (topics.length === 0) {
3745
+ steps.push({
3746
+ capability: "source",
3747
+ step: "sync topics",
3748
+ status: "skip",
3749
+ message: "no topics configured"
3750
+ });
3751
+ print(formatSyncStep(steps[steps.length - 1]));
3752
+ } else if (source.syncTopics) {
3753
+ steps.push(await runSyncStep("source", "sync topics", dryRun, () => source.syncTopics(topics)));
3754
+ print(formatSyncStep(steps[steps.length - 1]));
3755
+ } else {
3756
+ steps.push({
3757
+ capability: "source",
3758
+ step: "sync topics",
3759
+ status: "skip",
3760
+ message: "provider does not implement syncTopics"
3761
+ });
3762
+ print(formatSyncStep(steps[steps.length - 1]));
3763
+ }
3764
+ }
3765
+ }
3766
+ if (requestedSteps) {
3767
+ for (const name of requestedSteps) if (!SYNC_STEPS.includes(name)) {
3768
+ steps.push({
3769
+ capability: "source",
3770
+ step: `sync ${name}`,
3771
+ status: "skip",
3772
+ message: `unknown step "${name}"`
3773
+ });
3774
+ print(formatSyncStep(steps[steps.length - 1]));
3775
+ }
3776
+ }
3777
+ }
3778
+ for (const stepName of ["keywords", "description"]) {
3779
+ if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
3780
+ if (stepName === "keywords") {
3781
+ const topics = config.repo?.topics ?? [];
3782
+ if (topics.length === 0) {
3783
+ steps.push({
3784
+ capability: "local",
3785
+ step: "sync keywords",
3786
+ status: "skip",
3787
+ message: "no topics configured"
3788
+ });
3789
+ print(formatSyncStep(steps[steps.length - 1]));
3790
+ } else {
3791
+ steps.push(await runSyncStep("local", "sync keywords", dryRun, async () => {
3792
+ return await writePackageJsonField(input.context.repoRoot, "keywords", topics) ? `${topics.length} keywords written` : `${topics.length} topics (no package.json)`;
3793
+ }));
3794
+ print(formatSyncStep(steps[steps.length - 1]));
3795
+ }
3796
+ }
3797
+ if (stepName === "description") {
3798
+ const description = config.description;
3799
+ if (!description) {
3800
+ steps.push({
3801
+ capability: "local",
3802
+ step: "sync description",
3803
+ status: "skip",
3804
+ message: "no description configured"
3805
+ });
3806
+ print(formatSyncStep(steps[steps.length - 1]));
3807
+ } else {
3808
+ const source = loader.has("source") ? loader.get("source") : null;
3809
+ steps.push(await runSyncStep("local", "sync description", dryRun, async () => {
3810
+ const pkgWrote = await writePackageJsonField(input.context.repoRoot, "description", description);
3811
+ const readmeWrote = await updateReadmeDescription(input.context.repoRoot, description);
3812
+ if (source?.syncDescription) await source.syncDescription(description);
3813
+ const parts = [];
3814
+ if (pkgWrote) parts.push("package.json");
3815
+ if (readmeWrote) parts.push("README.md");
3816
+ if (source?.syncDescription) parts.push("GitHub");
3817
+ return parts.length > 0 ? parts.join(", ") + " updated" : "description synced";
3818
+ }));
3819
+ print(formatSyncStep(steps[steps.length - 1]));
3820
+ }
3821
+ }
3822
+ }
3823
+ const summary = steps.reduce((acc, s) => {
3824
+ if (s.status === "ok") acc.ok += 1;
3825
+ else if (s.status === "fail") acc.fail += 1;
3826
+ else if (s.status === "skip") acc.skip += 1;
3827
+ else if (s.status === "dry-run") acc.dryRun += 1;
3828
+ return acc;
3829
+ }, {
3830
+ ok: 0,
3831
+ fail: 0,
3832
+ skip: 0,
3833
+ dryRun: 0
3834
+ });
3835
+ print("");
3836
+ print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
3837
+ return {
3838
+ steps,
3839
+ summary
3840
+ };
3841
+ }
3842
+ async function runSyncStep(capability, step, dryRun, body) {
3843
+ if (dryRun) return {
3844
+ capability,
3845
+ step,
3846
+ status: "dry-run"
3847
+ };
3848
+ try {
3849
+ const note = await body();
3850
+ const result = {
3851
+ capability,
3852
+ step,
3853
+ status: "ok"
3854
+ };
3855
+ if (typeof note === "string") result.message = note;
3856
+ return result;
3857
+ } catch (err) {
3858
+ return {
3859
+ capability,
3860
+ step,
3861
+ status: "fail",
3862
+ message: err instanceof Error ? err.message : String(err)
3863
+ };
3864
+ }
3865
+ }
3866
+ function formatSyncStep(step) {
2447
3867
  const icon = step.status === "ok" ? "✓" : step.status === "fail" ? "✗" : step.status === "dry-run" ? "…" : "·";
2448
3868
  const detail = step.message ? ` (${step.message})` : "";
2449
3869
  return ` ${icon} ${step.step}${detail}`;
2450
3870
  }
3871
+ async function writePackageJsonField(repoRoot, field, value) {
3872
+ const pkgPath = join(repoRoot, "package.json");
3873
+ let content;
3874
+ try {
3875
+ content = await readFile(pkgPath, "utf8");
3876
+ } catch {
3877
+ return false;
3878
+ }
3879
+ const pkg = JSON.parse(content);
3880
+ pkg[field] = value;
3881
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
3882
+ return true;
3883
+ }
3884
+ const README_DESC_START = "<!-- holocron:description -->";
3885
+ const README_DESC_END = "<!-- /holocron:description -->";
3886
+ async function updateReadmeDescription(repoRoot, description) {
3887
+ const readmePath = join(repoRoot, "README.md");
3888
+ let content;
3889
+ try {
3890
+ content = await readFile(readmePath, "utf8");
3891
+ } catch {
3892
+ return false;
3893
+ }
3894
+ const lines = content.split("\n");
3895
+ const startIdx = lines.findIndex((l) => l.trim() === README_DESC_START);
3896
+ const endIdx = lines.findIndex((l) => l.trim() === README_DESC_END);
3897
+ if (startIdx !== -1) {
3898
+ if (endIdx === -1 || endIdx <= startIdx) return false;
3899
+ lines.splice(startIdx + 1, endIdx - startIdx - 1, description);
3900
+ await writeFile(readmePath, lines.join("\n"), "utf8");
3901
+ return true;
3902
+ }
3903
+ const h1Index = lines.findIndex((l) => /^# /.test(l));
3904
+ if (h1Index === -1) return false;
3905
+ lines.splice(h1Index + 1, 0, "", README_DESC_START, description, README_DESC_END);
3906
+ await writeFile(readmePath, lines.join("\n"), "utf8");
3907
+ return true;
3908
+ }
2451
3909
  //#endregion
2452
3910
  //#region src/load-config.ts
2453
3911
  /**
2454
3912
  * `holocron.config.{json,js,ts}` file loader.
2455
3913
  *
2456
- * v2.0 only documents the JSON form, but the loader looks up all three
2457
- * extensions in priority order (json js ts) that's the schema
2458
- * commitment captured in [`.notes/tech-architecture.spec.md` Roadmap
2459
- * Shareable configs] so the JS/TS preset story (issue #75) can land
2460
- * later without a breaking change.
3914
+ * Search order: json js ts. JSON is parsed directly; JS is loaded
3915
+ * via native dynamic import; TS is loaded via `tsImport` from tsx (a
3916
+ * runtime dep) so operators can write typed configs with `defineConfig`
3917
+ * without needing a separate build step.
2461
3918
  *
2462
- * The JS/TS forms aren't actually parsed today; they trigger a clear
2463
- * error telling the operator to use JSON for v2.0. The contract is the
2464
- * lookup order, not the interpretation.
3919
+ * All three forms are validated through the same `resolveConfig` path.
3920
+ * Implements the lookup-order contract from issue #75 / #81.
2465
3921
  */
3922
+ const execFileAsync = promisify(execFile);
2466
3923
  const CANDIDATE_FILENAMES = [
2467
3924
  "holocron.config.json",
2468
3925
  "holocron.config.js",
@@ -2474,7 +3931,7 @@ var ConfigFileError = class extends Error {
2474
3931
  /**
2475
3932
  * Read + parse + resolve `holocron.config.*` from the given directory.
2476
3933
  * Search order: json → js → ts. Throws `ConfigFileError` if nothing
2477
- * found, or `ConfigError` if the JSON is malformed / invalid.
3934
+ * found, or `ConfigError` if the config is malformed / invalid.
2478
3935
  */
2479
3936
  async function loadConfig(cwd) {
2480
3937
  for (const filename of CANDIDATE_FILENAMES) {
@@ -2484,7 +3941,14 @@ async function loadConfig(cwd) {
2484
3941
  resolved: await loadJson(fullPath),
2485
3942
  filepath: fullPath
2486
3943
  };
2487
- 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).`);
3944
+ if (filename.endsWith(".ts")) return {
3945
+ resolved: await loadTs(fullPath),
3946
+ filepath: fullPath
3947
+ };
3948
+ return {
3949
+ resolved: await loadJs(fullPath),
3950
+ filepath: fullPath
3951
+ };
2488
3952
  }
2489
3953
  }
2490
3954
  throw new ConfigFileError(`no holocron.config.{json,js,ts} found in ${cwd}. Create one — see the README for the schema.`);
@@ -2497,7 +3961,61 @@ async function loadJson(filepath) {
2497
3961
  } catch (err) {
2498
3962
  throw new ConfigError(`${filepath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
2499
3963
  }
2500
- return resolveConfig(parsed);
3964
+ return resolveConfig(await deriveDefaults(dirname(filepath), parsed));
3965
+ }
3966
+ async function loadJs(filepath) {
3967
+ const mod = await import(pathToFileURL(filepath).href);
3968
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
3969
+ }
3970
+ async function loadTs(filepath) {
3971
+ const { tsImport } = await import("tsx/esm/api");
3972
+ const mod = await tsImport(pathToFileURL(filepath).href, import.meta.url);
3973
+ return resolveConfig(await deriveDefaults(dirname(filepath), extractRaw(filepath, mod)));
3974
+ }
3975
+ function extractRaw(filepath, mod) {
3976
+ const outer = mod.default;
3977
+ const raw = outer?.__esModule === true ? outer.default : outer;
3978
+ if (raw === void 0 || raw === null) throw new ConfigFileError(`${filepath} must have a default export (use \`export default defineConfig({…})\`)`);
3979
+ return raw;
3980
+ }
3981
+ async function deriveDefaults(configDir, raw) {
3982
+ const result = { ...raw };
3983
+ if (!result.name) result.name = await readPackageJsonName(configDir) ?? basename(configDir);
3984
+ if (result.repo && !result.repo.name) {
3985
+ const repoName = await readGitRemote(configDir);
3986
+ if (repoName) result.repo = {
3987
+ ...result.repo,
3988
+ name: repoName
3989
+ };
3990
+ }
3991
+ return result;
3992
+ }
3993
+ async function readPackageJsonName(dir) {
3994
+ try {
3995
+ const content = await readFile(join(dir, "package.json"), "utf8");
3996
+ const pkg = JSON.parse(content);
3997
+ return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
3998
+ } catch {
3999
+ return;
4000
+ }
4001
+ }
4002
+ async function readGitRemote(dir) {
4003
+ try {
4004
+ const { stdout } = await execFileAsync("git", [
4005
+ "remote",
4006
+ "get-url",
4007
+ "origin"
4008
+ ], { cwd: dir });
4009
+ return parseGitRemoteUrl(stdout.trim());
4010
+ } catch {
4011
+ return;
4012
+ }
4013
+ }
4014
+ function parseGitRemoteUrl(url) {
4015
+ const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
4016
+ if (httpsMatch) return httpsMatch[1];
4017
+ const sshMatch = url.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
4018
+ if (sshMatch) return sshMatch[1];
2501
4019
  }
2502
4020
  async function fileExists(path) {
2503
4021
  try {
@@ -2548,6 +4066,18 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
2548
4066
  ...argv.token ? { cliToken: argv.token } : {}
2549
4067
  }
2550
4068
  })).summary.fail > 0) process.exitCode = 1;
4069
+ }).command("skills <action>", "Manage agent skills from the @theholocron/skills registry", (y) => y.positional("action", {
4070
+ type: "string",
4071
+ choices: ["install"],
4072
+ describe: "install — copy skills from @theholocron/skills into .agents/ with agent symlinks"
4073
+ }), async (argv) => {
4074
+ if (argv.action === "install") await runSkillsInstall({
4075
+ loaded: await loadConfig(argv.cwd),
4076
+ context: {
4077
+ repoRoot: argv.cwd,
4078
+ dryRun: argv.dryRun
4079
+ }
4080
+ });
2551
4081
  }).command("secret set <name> [value]", "Set a single secret via the configured `secrets` capability", (y) => y.positional("name", {
2552
4082
  type: "string",
2553
4083
  demandOption: true,
@@ -2653,7 +4183,59 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
2653
4183
  dryRun: argv.dryRun,
2654
4184
  ...argv.otp ? { otp: argv.otp } : {}
2655
4185
  })).status === "fail") process.exitCode = 1;
2656
- }).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
4186
+ }).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", {
4187
+ type: "string",
4188
+ array: true,
4189
+ describe: "Steps to run: labels, properties, topics, keywords, description (default: all)"
4190
+ }).option("repo", {
4191
+ type: "string",
4192
+ describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
4193
+ }), async (argv) => {
4194
+ if ((await runSync({
4195
+ loaded: await loadConfig(argv.cwd),
4196
+ context: {
4197
+ repoRoot: argv.cwd,
4198
+ dryRun: argv.dryRun,
4199
+ ...argv.repo ? { repo: argv.repo } : {},
4200
+ ...argv.token ? { cliToken: argv.token } : {}
4201
+ },
4202
+ ...argv.steps && argv.steps.length > 0 ? { steps: argv.steps } : {}
4203
+ })).summary.fail > 0) process.exitCode = 1;
4204
+ }).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github via the GitHub API", (y) => y.option("repo", {
4205
+ type: "string",
4206
+ default: "theholocron/.github",
4207
+ describe: "Target org/repo (default: theholocron/.github)"
4208
+ }).option("branch", {
4209
+ type: "string",
4210
+ describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
4211
+ }).option("pr", {
4212
+ type: "boolean",
4213
+ default: false,
4214
+ describe: "Open a PR after pushing to --branch (no-op without --branch)"
4215
+ }).option("message", {
4216
+ type: "string",
4217
+ describe: "Commit message (default: chore: sync from theholocron/holocron)"
4218
+ }).option("output-dir", {
4219
+ type: "string",
4220
+ describe: "Write generated files to this local directory instead of pushing (for validation)"
4221
+ }), async (argv) => {
4222
+ const outputDir = argv["output-dir"];
4223
+ const token = outputDir ? "no-token-needed" : argv.token ?? process.env.GITHUB_TOKEN ?? process.env.HOLOCRON_GITHUB_TOKEN;
4224
+ if (!token) {
4225
+ console.error("sync-github: GitHub token required — pass --token or set GITHUB_TOKEN");
4226
+ process.exitCode = 1;
4227
+ return;
4228
+ }
4229
+ if ((await runSyncGithub({
4230
+ token,
4231
+ repo: argv.repo,
4232
+ dryRun: argv.dryRun,
4233
+ ...argv.branch ? { branch: argv.branch } : {},
4234
+ ...argv.pr ? { createPr: true } : {},
4235
+ ...argv.message ? { message: argv.message } : {},
4236
+ ...outputDir ? { outputDir } : {}
4237
+ })).status === "fail") process.exitCode = 1;
4238
+ }).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
2657
4239
  const loaded = await loadConfig(argv.cwd);
2658
4240
  console.log(JSON.stringify(loaded.resolved, null, 2));
2659
4241
  }).command("plugin create <slug> <vendor>", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
@@ -2680,15 +4262,40 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
2680
4262
  type: "boolean",
2681
4263
  default: true,
2682
4264
  describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
2683
- }), (argv) => {
4265
+ }), async (argv) => {
2684
4266
  try {
2685
- if (!argv.capability || !argv.vendorEnv || !argv.baseUrl) throw new PluginCreateError("Phase A: --capability, --vendor-env, and --base-url are required. Interactive prompts land in Phase B.");
4267
+ const capabilityKeys = Object.keys(CARDINALITY).join(", ");
4268
+ const needsPrompt = !argv.capability || !argv.vendorEnv || !argv.baseUrl;
4269
+ let capability;
4270
+ let vendorEnv;
4271
+ let baseUrl;
4272
+ if (needsPrompt) {
4273
+ const rl = createInterface({
4274
+ input: stdin,
4275
+ output: stdout
4276
+ });
4277
+ const ask = (question) => new Promise((resolve) => rl.question(` ${question} `, (answer) => resolve(answer.trim())));
4278
+ try {
4279
+ if (!argv.capability) {
4280
+ console.log(` Available capabilities: ${capabilityKeys}`);
4281
+ capability = await ask("Capability:");
4282
+ } else capability = argv.capability;
4283
+ vendorEnv = argv.vendorEnv ? argv.vendorEnv : await ask(`Vendor-native env var for the ${argv.vendor} token (e.g. MYVENDOR_API_KEY):`);
4284
+ baseUrl = argv.baseUrl ? argv.baseUrl : await ask(`REST base URL for the ${argv.vendor} API (e.g. https://api.myvendor.com):`);
4285
+ } finally {
4286
+ rl.close();
4287
+ }
4288
+ } else {
4289
+ capability = argv.capability;
4290
+ vendorEnv = argv.vendorEnv;
4291
+ baseUrl = argv.baseUrl;
4292
+ }
2686
4293
  if (runPluginCreate({
2687
4294
  slug: argv.slug,
2688
4295
  vendorName: argv.vendor,
2689
- capability: argv.capability,
2690
- vendorEnv: argv.vendorEnv,
2691
- baseUrl: argv.baseUrl,
4296
+ capability,
4297
+ vendorEnv,
4298
+ baseUrl,
2692
4299
  ...argv.tokenEnv ? { tokenEnv: argv.tokenEnv } : {},
2693
4300
  dryRun: argv.dryRun,
2694
4301
  noVerify: !argv.verify,
@@ -2702,7 +4309,32 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
2702
4309
  }
2703
4310
  throw err;
2704
4311
  }
2705
- }).command("auth <subcommand>", "Manage bootstrap credentials in the OS keyring", (y) => y.command("set <provider> [token]", "Verify + store a bootstrap token for a provider", (yy) => yy.positional("provider", {
4312
+ }).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", {
4313
+ type: "number",
4314
+ demandOption: true,
4315
+ describe: "Target Node.js major version (e.g., 22)"
4316
+ }).option("from", {
4317
+ type: "number",
4318
+ describe: "Current major version to replace. Auto-detected from .nvmrc / engines.node when omitted."
4319
+ }), async (argv) => {
4320
+ let extra = [];
4321
+ try {
4322
+ const raw = readFileSync(join(argv.cwd, "holocron.config.json"), "utf8");
4323
+ const upgradeNode = JSON.parse(raw).upgrade?.node;
4324
+ if (Array.isArray(upgradeNode?.extra)) extra = upgradeNode.extra;
4325
+ } catch {}
4326
+ const report = await runUpgradeNode({
4327
+ to: argv.to,
4328
+ ...argv.from != null ? { from: argv.from } : {},
4329
+ cwd: argv.cwd,
4330
+ dryRun: argv.dryRun,
4331
+ extra
4332
+ });
4333
+ if (report.status === "fail") {
4334
+ if (report.message) console.error(`upgrade node: ${report.message}`);
4335
+ process.exitCode = 1;
4336
+ }
4337
+ }).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", {
2706
4338
  type: "string",
2707
4339
  demandOption: true
2708
4340
  }).positional("token", { type: "string" }), async (argv) => {