@theholocron/cli 3.0.1 → 3.0.2

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.
Files changed (2) hide show
  1. package/dist/cli.mjs +1696 -1696
  2. package/package.json +2 -2
package/dist/cli.mjs CHANGED
@@ -2,21 +2,124 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
4
  import path, { basename, dirname, join, relative, resolve } from "node:path";
5
+ import { stdin, stdout } from "node:process";
6
+ import { createInterface } from "node:readline";
5
7
  import yargs from "yargs";
6
8
  import { hideBin } from "yargs/helpers";
7
- import { createInterface } from "node:readline";
8
- import { stdin, stdout } from "node:process";
9
9
  import { AuthError, ProviderApiError, ProviderApiError as ProviderApiError$1 } from "@theholocron/http-client";
10
10
  import { Entry, findCredentials } from "@napi-rs/keyring";
11
11
  import ora from "ora";
12
12
  import chalk from "chalk";
13
- import { homedir } from "node:os";
14
13
  import { execFile, execFileSync, spawnSync } from "node:child_process";
14
+ import { homedir } from "node:os";
15
15
  import { createHash } from "node:crypto";
16
- import { createGitHubClient } from "@theholocron/github-client";
17
16
  import { access, copyFile, mkdir, readFile, readdir, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
18
17
  import { pathToFileURL } from "node:url";
18
+ import { createGitHubClient } from "@theholocron/github-client";
19
19
  import { promisify } from "node:util";
20
+ //#region src/env.ts
21
+ function createEnvLookup(source = process.env) {
22
+ return {
23
+ get(key) {
24
+ return source[key] || void 0;
25
+ },
26
+ first(...keys) {
27
+ for (const key of keys) {
28
+ const val = source[key];
29
+ if (val) return val;
30
+ }
31
+ }
32
+ };
33
+ }
34
+ //#endregion
35
+ //#region src/keyring.ts
36
+ /**
37
+ * Keyring-backed bootstrap credential store.
38
+ *
39
+ * Every holocron plugin's bootstrap token (the one it needs before it
40
+ * can talk to its vendor's API) can be stored in the OS keyring under
41
+ * a single reverse-DNS service scope. Managed via `holocron auth`
42
+ * subcommands; consulted at position 4 in every plugin's auth
43
+ * precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
44
+ *
45
+ * See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
46
+ *
47
+ * Failure model: keyring access is best-effort. Platforms without a
48
+ * supported credential store (some Linux CI images, sandboxed
49
+ * environments) will throw from the underlying library. Every export
50
+ * here catches and returns a null/empty result rather than propagating
51
+ * — the plugin's precedence chain then falls through to
52
+ * env-var-only paths, which is exactly how CI is meant to work.
53
+ */
54
+ const SERVICE = "com.theholocron.cli";
55
+ /**
56
+ * Store or overwrite a bootstrap token for a provider. Returns true on
57
+ * success, false when the underlying keyring is unsupported or errored.
58
+ */
59
+ function setToken(provider, token) {
60
+ try {
61
+ new Entry(SERVICE, provider).setPassword(token);
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+ /**
68
+ * Read the bootstrap token for a provider. Returns `null` for both
69
+ * "not stored" and "keyring unavailable" — callers can treat them the
70
+ * same way (fall through to env-var precedence).
71
+ */
72
+ function getToken(provider) {
73
+ try {
74
+ return new Entry(SERVICE, provider).getPassword();
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Delete a stored token. Returns true when a token was removed, false
81
+ * when there was nothing to delete or the keyring is unavailable.
82
+ * Distinguishing the two cases isn't worth the surface area — the
83
+ * command output makes the situation clear either way.
84
+ */
85
+ function deleteToken(provider) {
86
+ try {
87
+ return new Entry(SERVICE, provider).deletePassword();
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * List provider slugs with a stored token in this service scope.
94
+ * Uses the library's `findCredentials(service)` — supported on all
95
+ * platforms the underlying credential store supports.
96
+ */
97
+ function listStoredProviders() {
98
+ try {
99
+ return findCredentials(SERVICE).map((c) => c.account);
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+ //#endregion
105
+ //#region src/auth-resolver.ts
106
+ /**
107
+ * Build a strict, single-feature token resolver.
108
+ *
109
+ * Resolution chain: `--token flag → envName env var → keyring(keyringKey)`.
110
+ * No broad-token fallback — if the feature-specific token is absent the
111
+ * operation fails with a message naming the exact env var to set.
112
+ */
113
+ function createFeatureResolver(config) {
114
+ return function resolveFeatureToken(input = {}) {
115
+ const env = createEnvLookup(input.env);
116
+ const keyring = input.keyring ?? getToken;
117
+ const token = input.cliToken || env.get(config.envName) || keyring(config.keyringKey);
118
+ if (!token) throw new AuthError(`no GitHub token found for this operation. Pass --token <PAT>, set ${config.envName}, or run: holocron auth set ${config.keyringKey} <PAT>`);
119
+ return token;
120
+ };
121
+ }
122
+ //#endregion
20
123
  //#region src/capabilities/index.ts
21
124
  const CARDINALITY = {
22
125
  source: "single",
@@ -157,76 +260,6 @@ function resolveConfig(raw) {
157
260
  };
158
261
  }
159
262
  //#endregion
160
- //#region src/keyring.ts
161
- /**
162
- * Keyring-backed bootstrap credential store.
163
- *
164
- * Every holocron plugin's bootstrap token (the one it needs before it
165
- * can talk to its vendor's API) can be stored in the OS keyring under
166
- * a single reverse-DNS service scope. Managed via `holocron auth`
167
- * subcommands; consulted at position 4 in every plugin's auth
168
- * precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
169
- *
170
- * See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
171
- *
172
- * Failure model: keyring access is best-effort. Platforms without a
173
- * supported credential store (some Linux CI images, sandboxed
174
- * environments) will throw from the underlying library. Every export
175
- * here catches and returns a null/empty result rather than propagating
176
- * — the plugin's precedence chain then falls through to
177
- * env-var-only paths, which is exactly how CI is meant to work.
178
- */
179
- const SERVICE = "com.theholocron.cli";
180
- /**
181
- * Store or overwrite a bootstrap token for a provider. Returns true on
182
- * success, false when the underlying keyring is unsupported or errored.
183
- */
184
- function setToken(provider, token) {
185
- try {
186
- new Entry(SERVICE, provider).setPassword(token);
187
- return true;
188
- } catch {
189
- return false;
190
- }
191
- }
192
- /**
193
- * Read the bootstrap token for a provider. Returns `null` for both
194
- * "not stored" and "keyring unavailable" — callers can treat them the
195
- * same way (fall through to env-var precedence).
196
- */
197
- function getToken(provider) {
198
- try {
199
- return new Entry(SERVICE, provider).getPassword();
200
- } catch {
201
- return null;
202
- }
203
- }
204
- /**
205
- * Delete a stored token. Returns true when a token was removed, false
206
- * when there was nothing to delete or the keyring is unavailable.
207
- * Distinguishing the two cases isn't worth the surface area — the
208
- * command output makes the situation clear either way.
209
- */
210
- function deleteToken(provider) {
211
- try {
212
- return new Entry(SERVICE, provider).deletePassword();
213
- } catch {
214
- return false;
215
- }
216
- }
217
- /**
218
- * List provider slugs with a stored token in this service scope.
219
- * Uses the library's `findCredentials(service)` — supported on all
220
- * platforms the underlying credential store supports.
221
- */
222
- function listStoredProviders() {
223
- try {
224
- return findCredentials(SERVICE).map((c) => c.account);
225
- } catch {
226
- return [];
227
- }
228
- }
229
- //#endregion
230
263
  //#region src/ui/progress.ts
231
264
  /**
232
265
  * Runs `fn`, showing an ora spinner for its duration in TTY environments.
@@ -545,223 +578,19 @@ async function runClone(input) {
545
578
  };
546
579
  }
547
580
  //#endregion
548
- //#region src/commands/new.ts
549
- /**
550
- * `holocron new <type> <name>` create a GitHub repo from a template and
551
- * bootstrap it by replacing all template-slug casing variants with the new
552
- * project name.
553
- *
554
- * Flow:
555
- * 1. Preflight — verify `gh` CLI is available.
556
- * 2. Resolve type, name, description (prompt via readline if missing).
557
- * 3. `gh repo create <org>/<name> --template <org>/<type>-template --private --clone`
558
- * → clones to `<cwd>/<name>/`
559
- * 4. Detect template slug from cloned package.json.
560
- * 5. Replace all casing variants of the slug across every text file.
561
- * 6. Replace `<description>` placeholder if a description was given.
562
- * 7. Commit the patched files (-s for DCO).
563
- * 8. Unless --no-verify: `pnpm install` in the new repo.
564
- * 9. Print next steps.
565
- */
566
- var NewError = class extends Error {
567
- name = "NewError";
581
+ //#region src/loader.ts
582
+ var LoaderError = class extends Error {
583
+ name = "LoaderError";
568
584
  };
569
- function cap(word) {
570
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
571
- }
572
- /**
573
- * Derive all common casing variants of a kebab-case slug (e.g.
574
- * "cli-template") and map each to the corresponding form of a
575
- * kebab-case name (e.g. "my-tool").
576
- *
577
- * Returns search→replacement pairs, deduplicating single-word slugs
578
- * that would otherwise produce identical entries.
579
- */
580
- function deriveVariants(slug, name) {
581
- const sw = slug.split("-");
582
- const nw = name.split("-");
583
- const pairs = [
584
- [slug, name],
585
- [sw.join("_"), nw.join("_")],
586
- [sw.join("_").toUpperCase(), nw.join("_").toUpperCase()],
587
- [sw.map(cap).join(""), nw.map(cap).join("")],
588
- [sw[0].toLowerCase() + sw.slice(1).map(cap).join(""), nw[0].toLowerCase() + nw.slice(1).map(cap).join("")],
589
- [sw.map(cap).join(" "), nw.map(cap).join(" ")]
590
- ];
591
- const seen = /* @__PURE__ */ new Set();
592
- return pairs.filter(([s]) => {
593
- if (seen.has(s)) return false;
594
- seen.add(s);
595
- return true;
596
- });
597
- }
598
- const SKIP_DIRS$1 = /* @__PURE__ */ new Set([
599
- ".git",
600
- "node_modules",
601
- "dist",
602
- ".turbo"
603
- ]);
604
- function defaultWalkFiles$1(dir) {
605
- const results = [];
606
- for (const entry of readdirSync(dir)) {
607
- if (SKIP_DIRS$1.has(entry)) continue;
608
- const full = path.join(dir, entry);
609
- const stat = statSync(full);
610
- if (stat.isDirectory()) results.push(...defaultWalkFiles$1(full));
611
- else if (stat.isFile()) results.push(full);
612
- }
613
- return results;
614
- }
615
- function isBinary(content) {
616
- for (let i = 0; i < Math.min(content.length, 8e3); i++) if (content.charCodeAt(i) === 0) return true;
617
- return false;
618
- }
619
- function patchFiles(dir, variants, description, print, readFn, writeFn, walkFn) {
620
- const patched = [];
621
- for (const filepath of walkFn(dir)) {
622
- let content;
623
- try {
624
- content = readFn(filepath);
625
- } catch {
626
- continue;
627
- }
628
- if (isBinary(content)) continue;
629
- const original = content;
630
- for (const [search, replacement] of variants) content = content.split(search).join(replacement);
631
- if (description !== void 0) content = content.split("<description>").join(description);
632
- if (content !== original) {
633
- writeFn(filepath, content);
634
- print(` ✓ ${path.relative(dir, filepath)}`);
635
- patched.push(filepath);
636
- }
637
- }
638
- return patched;
639
- }
640
- function preflight$1() {
641
- const result = spawnSync("gh", ["--version"], { encoding: "utf8" });
642
- if (result.error != null || result.status !== 0) throw new NewError("`gh` CLI is not installed or not on PATH. Install it from https://cli.github.com");
643
- }
644
- function defaultExec$3(cmd, args, opts) {
645
- execFileSync(cmd, args, {
646
- cwd: opts.cwd,
647
- stdio: opts.stdio
648
- });
649
- }
650
- function defaultReadFile(filepath) {
651
- return readFileSync(filepath, "utf-8");
652
- }
653
- function defaultWriteFile(filepath, content) {
654
- mkdirSync(path.dirname(filepath), { recursive: true });
655
- writeFileSync(filepath, content, "utf-8");
656
- }
657
- async function runNew(input) {
658
- const cwd = input.cwd ?? process.cwd();
659
- const org = input.org ?? "theholocron";
660
- const print = input.print ?? ((line) => console.log(line));
661
- const execFn = input.exec ?? defaultExec$3;
662
- const readFn = input.readFile ?? defaultReadFile;
663
- const writeFn = input.writeFile ?? defaultWriteFile;
664
- const walkFn = input.walkFiles ?? defaultWalkFiles$1;
665
- preflight$1();
666
- const templateRepo = `${org}/${input.type}-template`;
667
- const newRepo = `${org}/${input.name}`;
668
- const repoDir = path.join(cwd, input.name);
669
- if (input.dryRun) {
670
- print(` Would create ${newRepo} from template ${templateRepo}`);
671
- print(` Would clone to ${repoDir}`);
672
- print(` Would patch all casing variants of "${input.type}-template" → "${input.name}"`);
673
- if (input.description) print(` Would replace <description> → "${input.description}"`);
674
- return { status: "dry-run" };
675
- }
676
- if (existsSync(repoDir)) throw new NewError(`\`${repoDir}\` already exists — delete it or pick a different name.`);
677
- print(` Creating ${newRepo} from template ${templateRepo}…`);
678
- try {
679
- execFn("gh", [
680
- "repo",
681
- "create",
682
- newRepo,
683
- `--template=${templateRepo}`,
684
- "--private",
685
- "--clone"
686
- ], {
687
- cwd,
688
- stdio: "inherit"
689
- });
690
- } catch (err) {
691
- throw new NewError(`gh repo create failed: ${err instanceof Error ? err.message : String(err)}`);
692
- }
693
- let templateSlug = `${input.type}-template`;
694
- const pkgJsonPath = path.join(repoDir, "package.json");
695
- if (existsSync(pkgJsonPath)) try {
696
- const pkg = JSON.parse(readFn(pkgJsonPath));
697
- if (typeof pkg.name === "string") templateSlug = pkg.name.split("/").pop() ?? templateSlug;
698
- } catch {}
699
- print(` Detected template slug: ${templateSlug}`);
700
- print(` Patching files…`);
701
- const filesPatched = patchFiles(repoDir, deriveVariants(templateSlug, input.name), input.description, print, readFn, writeFn, walkFn);
702
- print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
703
- if (filesPatched.length > 0) {
704
- execFn("git", ["add", "-A"], {
705
- cwd: repoDir,
706
- stdio: "inherit"
707
- });
708
- execFn("git", [
709
- "commit",
710
- "-s",
711
- "-m",
712
- `chore: bootstrap from ${templateSlug}`
713
- ], {
714
- cwd: repoDir,
715
- stdio: "inherit"
716
- });
717
- }
718
- if (!input.noVerify) {
719
- print("");
720
- print(" Installing dependencies…");
721
- try {
722
- execFn("pnpm", ["install"], {
723
- cwd: repoDir,
724
- stdio: "inherit"
725
- });
726
- } catch (err) {
727
- print(` ✗ pnpm install failed — ${err instanceof Error ? err.message : String(err)}`);
728
- return {
729
- status: "fail",
730
- repoDir,
731
- filesPatched,
732
- message: "pnpm install failed; inspect output above"
733
- };
734
- }
735
- }
736
- print("");
737
- print(` Scaffolded ${newRepo} (${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched).`);
738
- print("");
739
- print(" Next:");
740
- print(` 1. cd ${repoDir}`);
741
- if (input.noVerify) print(` 2. pnpm install`);
742
- const step = input.noVerify ? 3 : 2;
743
- print(` ${step}. holocron setup # wire up secrets, teams, labels, etc.`);
744
- print(` ${step + 1}. git push -u origin HEAD`);
745
- return {
746
- status: "ok",
747
- repoDir,
748
- filesPatched
749
- };
750
- }
751
- //#endregion
752
- //#region src/loader.ts
753
- var LoaderError = class extends Error {
754
- name = "LoaderError";
755
- };
756
- var PluginLoader = class {
757
- config;
758
- context;
759
- importer;
760
- registry = /* @__PURE__ */ new Map();
761
- constructor(config, context, importer = defaultImporter) {
762
- this.config = config;
763
- this.context = context;
764
- this.importer = importer;
585
+ var PluginLoader = class {
586
+ config;
587
+ context;
588
+ importer;
589
+ registry = /* @__PURE__ */ new Map();
590
+ constructor(config, context, importer = defaultImporter) {
591
+ this.config = config;
592
+ this.context = context;
593
+ this.importer = importer;
765
594
  }
766
595
  /** Imports every configured plugin and builds the capability registry. */
767
596
  async load() {
@@ -975,35 +804,239 @@ function pad(s, width) {
975
804
  return s.length >= width ? s : s + " ".repeat(width - s.length);
976
805
  }
977
806
  //#endregion
978
- //#region src/commands/npm-bump-versions.ts
979
- async function runNpmBumpVersions(input) {
980
- const print = input.print ?? ((line) => console.log(line));
981
- const cwd = input.cwd ?? process.cwd();
982
- const { version, dryRun = false } = input;
983
- const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
984
- const writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
985
- const listDir = input.listDir ?? ((p) => readdirSync(p));
986
- const isDir = input.isDir ?? ((p) => statSync(p).isDirectory());
987
- const bumped = [];
988
- const skipped = [];
989
- print(`Bumping monorepo to ${version}${dryRun ? " (dry-run)" : ""}…`);
990
- function bumpFile(absPath, label) {
991
- let pkg;
807
+ //#region src/commands/new.ts
808
+ /**
809
+ * `holocron new <type> <name>` create a GitHub repo from a template and
810
+ * bootstrap it by replacing all template-slug casing variants with the new
811
+ * project name.
812
+ *
813
+ * Flow:
814
+ * 1. Preflight verify `gh` CLI is available.
815
+ * 2. Resolve type, name, description (prompt via readline if missing).
816
+ * 3. `gh repo create <org>/<name> --template <org>/<type>-template --private --clone`
817
+ * → clones to `<cwd>/<name>/`
818
+ * 4. Detect template slug from cloned package.json.
819
+ * 5. Replace all casing variants of the slug across every text file.
820
+ * 6. Replace `<description>` placeholder if a description was given.
821
+ * 7. Commit the patched files (-s for DCO).
822
+ * 8. Unless --no-verify: `pnpm install` in the new repo.
823
+ * 9. Print next steps.
824
+ */
825
+ var NewError = class extends Error {
826
+ name = "NewError";
827
+ };
828
+ function cap(word) {
829
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
830
+ }
831
+ /**
832
+ * Derive all common casing variants of a kebab-case slug (e.g.
833
+ * "cli-template") and map each to the corresponding form of a
834
+ * kebab-case name (e.g. "my-tool").
835
+ *
836
+ * Returns search→replacement pairs, deduplicating single-word slugs
837
+ * that would otherwise produce identical entries.
838
+ */
839
+ function deriveVariants(slug, name) {
840
+ const sw = slug.split("-");
841
+ const nw = name.split("-");
842
+ const pairs = [
843
+ [slug, name],
844
+ [sw.join("_"), nw.join("_")],
845
+ [sw.join("_").toUpperCase(), nw.join("_").toUpperCase()],
846
+ [sw.map(cap).join(""), nw.map(cap).join("")],
847
+ [sw[0].toLowerCase() + sw.slice(1).map(cap).join(""), nw[0].toLowerCase() + nw.slice(1).map(cap).join("")],
848
+ [sw.map(cap).join(" "), nw.map(cap).join(" ")]
849
+ ];
850
+ const seen = /* @__PURE__ */ new Set();
851
+ return pairs.filter(([s]) => {
852
+ if (seen.has(s)) return false;
853
+ seen.add(s);
854
+ return true;
855
+ });
856
+ }
857
+ const SKIP_DIRS$1 = /* @__PURE__ */ new Set([
858
+ ".git",
859
+ "node_modules",
860
+ "dist",
861
+ ".turbo"
862
+ ]);
863
+ function defaultWalkFiles$1(dir) {
864
+ const results = [];
865
+ for (const entry of readdirSync(dir)) {
866
+ if (SKIP_DIRS$1.has(entry)) continue;
867
+ const full = path.join(dir, entry);
868
+ const stat = statSync(full);
869
+ if (stat.isDirectory()) results.push(...defaultWalkFiles$1(full));
870
+ else if (stat.isFile()) results.push(full);
871
+ }
872
+ return results;
873
+ }
874
+ function isBinary(content) {
875
+ for (let i = 0; i < Math.min(content.length, 8e3); i++) if (content.charCodeAt(i) === 0) return true;
876
+ return false;
877
+ }
878
+ function patchFiles(dir, variants, description, print, readFn, writeFn, walkFn) {
879
+ const patched = [];
880
+ for (const filepath of walkFn(dir)) {
881
+ let content;
992
882
  try {
993
- pkg = JSON.parse(readFile(absPath));
883
+ content = readFn(filepath);
994
884
  } catch {
995
- print(` ✗ could not parse ${label}`);
996
- return;
997
- }
998
- const old = pkg.version;
999
- if (!dryRun) {
1000
- pkg.version = version;
1001
- writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
885
+ continue;
1002
886
  }
1003
- print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
1004
- bumped.push(label);
1005
- }
1006
- bumpFile(join(cwd, "package.json"), "root");
887
+ if (isBinary(content)) continue;
888
+ const original = content;
889
+ for (const [search, replacement] of variants) content = content.split(search).join(replacement);
890
+ if (description !== void 0) content = content.split("<description>").join(description);
891
+ if (content !== original) {
892
+ writeFn(filepath, content);
893
+ print(` ✓ ${path.relative(dir, filepath)}`);
894
+ patched.push(filepath);
895
+ }
896
+ }
897
+ return patched;
898
+ }
899
+ function preflight$1() {
900
+ const result = spawnSync("gh", ["--version"], { encoding: "utf8" });
901
+ if (result.error != null || result.status !== 0) throw new NewError("`gh` CLI is not installed or not on PATH. Install it from https://cli.github.com");
902
+ }
903
+ function defaultExec$3(cmd, args, opts) {
904
+ execFileSync(cmd, args, {
905
+ cwd: opts.cwd,
906
+ stdio: opts.stdio
907
+ });
908
+ }
909
+ function defaultReadFile(filepath) {
910
+ return readFileSync(filepath, "utf-8");
911
+ }
912
+ function defaultWriteFile(filepath, content) {
913
+ mkdirSync(path.dirname(filepath), { recursive: true });
914
+ writeFileSync(filepath, content, "utf-8");
915
+ }
916
+ async function runNew(input) {
917
+ const cwd = input.cwd ?? process.cwd();
918
+ const org = input.org ?? "theholocron";
919
+ const print = input.print ?? ((line) => console.log(line));
920
+ const execFn = input.exec ?? defaultExec$3;
921
+ const readFn = input.readFile ?? defaultReadFile;
922
+ const writeFn = input.writeFile ?? defaultWriteFile;
923
+ const walkFn = input.walkFiles ?? defaultWalkFiles$1;
924
+ preflight$1();
925
+ const templateRepo = `${org}/${input.type}-template`;
926
+ const newRepo = `${org}/${input.name}`;
927
+ const repoDir = path.join(cwd, input.name);
928
+ if (input.dryRun) {
929
+ print(` Would create ${newRepo} from template ${templateRepo}`);
930
+ print(` Would clone to ${repoDir}`);
931
+ print(` Would patch all casing variants of "${input.type}-template" → "${input.name}"`);
932
+ if (input.description) print(` Would replace <description> → "${input.description}"`);
933
+ return { status: "dry-run" };
934
+ }
935
+ if (existsSync(repoDir)) throw new NewError(`\`${repoDir}\` already exists — delete it or pick a different name.`);
936
+ print(` Creating ${newRepo} from template ${templateRepo}…`);
937
+ try {
938
+ execFn("gh", [
939
+ "repo",
940
+ "create",
941
+ newRepo,
942
+ `--template=${templateRepo}`,
943
+ "--private",
944
+ "--clone"
945
+ ], {
946
+ cwd,
947
+ stdio: "inherit"
948
+ });
949
+ } catch (err) {
950
+ throw new NewError(`gh repo create failed: ${err instanceof Error ? err.message : String(err)}`);
951
+ }
952
+ let templateSlug = `${input.type}-template`;
953
+ const pkgJsonPath = path.join(repoDir, "package.json");
954
+ if (existsSync(pkgJsonPath)) try {
955
+ const pkg = JSON.parse(readFn(pkgJsonPath));
956
+ if (typeof pkg.name === "string") templateSlug = pkg.name.split("/").pop() ?? templateSlug;
957
+ } catch {}
958
+ print(` Detected template slug: ${templateSlug}`);
959
+ print(` Patching files…`);
960
+ const filesPatched = patchFiles(repoDir, deriveVariants(templateSlug, input.name), input.description, print, readFn, writeFn, walkFn);
961
+ print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
962
+ if (filesPatched.length > 0) {
963
+ execFn("git", ["add", "-A"], {
964
+ cwd: repoDir,
965
+ stdio: "inherit"
966
+ });
967
+ execFn("git", [
968
+ "commit",
969
+ "-s",
970
+ "-m",
971
+ `chore: bootstrap from ${templateSlug}`
972
+ ], {
973
+ cwd: repoDir,
974
+ stdio: "inherit"
975
+ });
976
+ }
977
+ if (!input.noVerify) {
978
+ print("");
979
+ print(" Installing dependencies…");
980
+ try {
981
+ execFn("pnpm", ["install"], {
982
+ cwd: repoDir,
983
+ stdio: "inherit"
984
+ });
985
+ } catch (err) {
986
+ print(` ✗ pnpm install failed — ${err instanceof Error ? err.message : String(err)}`);
987
+ return {
988
+ status: "fail",
989
+ repoDir,
990
+ filesPatched,
991
+ message: "pnpm install failed; inspect output above"
992
+ };
993
+ }
994
+ }
995
+ print("");
996
+ print(` Scaffolded ${newRepo} (${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched).`);
997
+ print("");
998
+ print(" Next:");
999
+ print(` 1. cd ${repoDir}`);
1000
+ if (input.noVerify) print(` 2. pnpm install`);
1001
+ const step = input.noVerify ? 3 : 2;
1002
+ print(` ${step}. holocron setup # wire up secrets, teams, labels, etc.`);
1003
+ print(` ${step + 1}. git push -u origin HEAD`);
1004
+ return {
1005
+ status: "ok",
1006
+ repoDir,
1007
+ filesPatched
1008
+ };
1009
+ }
1010
+ //#endregion
1011
+ //#region src/commands/npm-bump-versions.ts
1012
+ async function runNpmBumpVersions(input) {
1013
+ const print = input.print ?? ((line) => console.log(line));
1014
+ const cwd = input.cwd ?? process.cwd();
1015
+ const { version, dryRun = false } = input;
1016
+ const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1017
+ const writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
1018
+ const listDir = input.listDir ?? ((p) => readdirSync(p));
1019
+ const isDir = input.isDir ?? ((p) => statSync(p).isDirectory());
1020
+ const bumped = [];
1021
+ const skipped = [];
1022
+ print(`Bumping monorepo to ${version}${dryRun ? " (dry-run)" : ""}…`);
1023
+ function bumpFile(absPath, label) {
1024
+ let pkg;
1025
+ try {
1026
+ pkg = JSON.parse(readFile(absPath));
1027
+ } catch {
1028
+ print(` ✗ could not parse ${label}`);
1029
+ return;
1030
+ }
1031
+ const old = pkg.version;
1032
+ if (!dryRun) {
1033
+ pkg.version = version;
1034
+ writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
1035
+ }
1036
+ print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
1037
+ bumped.push(label);
1038
+ }
1039
+ bumpFile(join(cwd, "package.json"), "root");
1007
1040
  const packagesDir = join(cwd, "packages");
1008
1041
  let entries;
1009
1042
  try {
@@ -1045,883 +1078,220 @@ async function runNpmBumpVersions(input) {
1045
1078
  };
1046
1079
  }
1047
1080
  //#endregion
1048
- //#region src/commands/upgrade-node.ts
1049
- const SKIP_DIRS = /* @__PURE__ */ new Set([
1050
- "node_modules",
1051
- ".git",
1052
- "dist",
1053
- "coverage",
1054
- "build",
1055
- ".turbo",
1056
- ".next",
1057
- "out"
1058
- ]);
1059
- function patchPackageJson(content, from, to) {
1060
- let pkg;
1061
- try {
1062
- pkg = JSON.parse(content);
1063
- } catch {
1064
- return null;
1065
- }
1066
- let changed = false;
1067
- const engines = pkg.engines;
1068
- if (engines?.node && /^>=\d+(?:\.0\.0)?$/.test(engines.node)) {
1069
- if (parseInt(engines.node.match(/\d+/)[0], 10) === from) {
1070
- engines.node = `>=${to}.0.0`;
1071
- changed = true;
1072
- }
1073
- }
1074
- for (const field of ["devDependencies", "dependencies"]) {
1075
- const deps = pkg[field];
1076
- if (!deps?.["@types/node"]) continue;
1077
- if (deps["@types/node"] === `^${from}.0.0`) {
1078
- deps["@types/node"] = `^${to}.0.0`;
1079
- changed = true;
1080
- }
1081
+ //#region src/commands/npm-publish-initial.ts
1082
+ /**
1083
+ * `holocron npm publish-initial` — bottles up the chicken-and-egg
1084
+ * bootstrap that every new npm-published holocron monorepo hits.
1085
+ *
1086
+ * npm requires a package to exist before Trusted Publishing can be
1087
+ * configured on it. So the first publish has to happen outside the
1088
+ * OIDC flow — using either a browser-auth session (`npm login
1089
+ * --auth-type=web`) or an ephemeral automation token. This command
1090
+ * runs the publish step + tells you exactly what to do next.
1091
+ *
1092
+ * Workflow:
1093
+ *
1094
+ * $ npm login --auth-type=web # one-time, browser-based
1095
+ * $ pnpm install --frozen-lockfile
1096
+ * $ pnpm build
1097
+ * $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
1098
+ *
1099
+ * The command itself only handles the publish step + the post-publish
1100
+ * Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
1101
+ * stay outside the command (no pnpm-inside-pnpm).
1102
+ *
1103
+ * If `NPM_TOKEN` is detected in env, the command prints a final
1104
+ * "revoke this token at <url>" reminder — same pattern as `rando vc
1105
+ * setup` for the ephemeral GH admin PAT.
1106
+ */
1107
+ const PUBLISHABLE_PACKAGES = [
1108
+ "@theholocron/cli",
1109
+ "@theholocron/holocron-plugin-github",
1110
+ "@theholocron/holocron-plugin-vercel",
1111
+ "@theholocron/holocron-plugin-neon",
1112
+ "@theholocron/holocron-plugin-clerk",
1113
+ "@theholocron/holocron-plugin-1password",
1114
+ "@theholocron/holocron-plugin-postman"
1115
+ ];
1116
+ async function runNpmPublishInitial(input = {}) {
1117
+ const print = input.print ?? ((line) => console.log(line));
1118
+ const cwd = input.cwd ?? process.cwd();
1119
+ const tag = input.tag ?? "alpha";
1120
+ const dryRun = input.dryRun ?? false;
1121
+ const otp = input.otp;
1122
+ const env = input.env ?? process.env;
1123
+ const exec = input.exec ?? defaultExec$2;
1124
+ const publishArgs = [
1125
+ "-r",
1126
+ "--filter=./packages/*",
1127
+ "publish",
1128
+ "--access",
1129
+ "public",
1130
+ "--no-git-checks",
1131
+ "--tag",
1132
+ tag,
1133
+ ...otp ? ["--otp", otp] : []
1134
+ ];
1135
+ print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
1136
+ print(` cwd: ${cwd}`);
1137
+ print(` tag: ${tag}`);
1138
+ if (otp) print(` otp: <${otp.length} chars>`);
1139
+ print("");
1140
+ print(" → verifying npm auth (`npm whoami`)…");
1141
+ const whoami = await exec("npm", ["whoami"], { cwd });
1142
+ if (whoami.exitCode !== 0) {
1143
+ const message = "npm is not authenticated. Run `npm login --auth-type=web` (browser flow, no token stored) or `npm login`, then re-run this command.";
1144
+ print(` ✗ ${message}`);
1145
+ return {
1146
+ status: "fail",
1147
+ message,
1148
+ packageNames: PUBLISHABLE_PACKAGES
1149
+ };
1081
1150
  }
1082
- return changed ? JSON.stringify(pkg, null, 2) + "\n" : null;
1083
- }
1084
- function patchYaml(content, from, to) {
1085
- const updated = content.replace(/node-version:\s+['"]?(\d+)['"]?/g, (match, ver) => ver === String(from) ? match.replace(String(from), String(to)) : match);
1086
- return updated !== content ? updated : null;
1087
- }
1088
- function patchPinFile(content, from, to) {
1089
- const trimmed = content.trim();
1090
- if (trimmed === String(from) || trimmed.startsWith(`${from}.`)) return `${to}\n`;
1091
- return null;
1092
- }
1093
- function patchDockerfile(content, from, to) {
1094
- const updated = content.replace(/^(FROM\s+node:)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
1095
- return updated !== content ? updated : null;
1096
- }
1097
- function patchToolVersions(content, from, to) {
1098
- const updated = content.replace(/^(nodejs\s+)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
1099
- return updated !== content ? updated : null;
1100
- }
1101
- const PATTERNS = [
1102
- {
1103
- matches: (n) => n === "package.json",
1104
- patch: patchPackageJson
1105
- },
1106
- {
1107
- matches: (n) => n.endsWith(".yml") || n.endsWith(".yaml"),
1108
- patch: patchYaml
1109
- },
1110
- {
1111
- matches: (n) => n === ".nvmrc" || n === ".node-version",
1112
- patch: patchPinFile
1113
- },
1114
- {
1115
- matches: (n) => n === "Dockerfile" || n.startsWith("Dockerfile."),
1116
- patch: patchDockerfile
1117
- },
1118
- {
1119
- matches: (n) => n === ".tool-versions",
1120
- patch: patchToolVersions
1121
- }
1122
- ];
1123
- function detectFrom(cwd, _readFile) {
1124
- for (const name of [".nvmrc", ".node-version"]) try {
1125
- const major = parseInt(_readFile(join(cwd, name)).trim(), 10);
1126
- if (!isNaN(major)) return major;
1127
- } catch {}
1128
- try {
1129
- const node = JSON.parse(_readFile(join(cwd, "package.json"))).engines?.node;
1130
- if (node) {
1131
- const m = node.match(/(\d+)/);
1132
- if (m) return parseInt(m[1], 10);
1133
- }
1134
- } catch {}
1135
- return null;
1136
- }
1137
- function defaultWalkFiles(dir) {
1138
- const results = [];
1139
- function walk(current) {
1140
- let entries;
1141
- try {
1142
- entries = readdirSync(current);
1143
- } catch {
1144
- return;
1145
- }
1146
- for (const entry of entries) {
1147
- if (SKIP_DIRS.has(entry)) continue;
1148
- const abs = join(current, entry);
1149
- try {
1150
- if (statSync(abs).isDirectory()) walk(abs);
1151
- else results.push(abs);
1152
- } catch {}
1153
- }
1154
- }
1155
- walk(dir);
1156
- return results;
1157
- }
1158
- async function runUpgradeNode(input) {
1159
- const print = input.print ?? ((line) => console.log(line));
1160
- const cwd = input.cwd ?? process.cwd();
1161
- const { to, dryRun = false, extra = [] } = input;
1162
- const _readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
1163
- const _writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
1164
- const _walkFiles = input.walkFiles ?? defaultWalkFiles;
1165
- const from = input.from ?? detectFrom(cwd, _readFile);
1166
- if (from === null) return {
1167
- status: "fail",
1168
- updated: [],
1169
- message: "could not detect current Node version — pass --from <major>"
1170
- };
1171
- if (from === to) {
1172
- print(`Already at Node.js ${to} — nothing to do.`);
1151
+ print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
1152
+ if (dryRun) {
1153
+ print("");
1154
+ print(" (dry-run) skipping actual publish");
1155
+ print(` would run: pnpm ${publishArgs.join(" ")}`);
1156
+ printNextSteps$1(print, env);
1173
1157
  return {
1174
- status: "ok",
1175
- updated: []
1158
+ status: "dry-run",
1159
+ message: "dry-run — no publish executed",
1160
+ packageNames: PUBLISHABLE_PACKAGES
1176
1161
  };
1177
1162
  }
1178
- print(`Upgrading Node.js ${from} → ${to}${dryRun ? " (dry-run)" : ""}…`);
1179
- const updated = [];
1180
- const scanned = [..._walkFiles(cwd), ...extra.map((p) => join(cwd, p))];
1181
- for (const abs of scanned) {
1182
- const name = basename(abs);
1183
- const pattern = PATTERNS.find((p) => p.matches(name));
1184
- if (!pattern) continue;
1185
- let content;
1186
- try {
1187
- content = _readFile(abs);
1188
- } catch {
1189
- continue;
1163
+ print("");
1164
+ print(" → publishing all public @theholocron/* packages…");
1165
+ const publish = await exec("pnpm", publishArgs, { cwd });
1166
+ if (publish.exitCode !== 0) {
1167
+ const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
1168
+ print(` ✗ ${message}`);
1169
+ if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
1170
+ print("");
1171
+ print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
1172
+ print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
1190
1173
  }
1191
- const patched = pattern.patch(content, from, to);
1192
- if (patched === null) continue;
1193
- const rel = abs.startsWith(cwd + "/") ? abs.slice(cwd.length + 1) : abs;
1194
- if (!dryRun) _writeFile(abs, patched);
1195
- print(` ${dryRun ? "~" : "✓"} ${rel}`);
1196
- updated.push(rel);
1174
+ return {
1175
+ status: "fail",
1176
+ message,
1177
+ packageNames: PUBLISHABLE_PACKAGES
1178
+ };
1197
1179
  }
1198
- if (updated.length === 0) print(` · no files contained Node.js ${from} pins`);
1180
+ print(" ✓ publish complete");
1181
+ printNextSteps$1(print, env);
1199
1182
  return {
1200
- status: dryRun ? "dry-run" : "ok",
1201
- updated
1183
+ status: "ok",
1184
+ packageNames: PUBLISHABLE_PACKAGES
1202
1185
  };
1203
1186
  }
1187
+ function printNextSteps$1(print, env) {
1188
+ print("");
1189
+ print(" → next: configure Trusted Publisher for each package on npm:");
1190
+ for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
1191
+ print(" Publisher: GitHub Actions Org: theholocron Repo: holocron Workflow: release.yml");
1192
+ if (env.NPM_TOKEN) {
1193
+ print("");
1194
+ print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
1195
+ print(" https://www.npmjs.com/settings/~/tokens");
1196
+ }
1197
+ }
1198
+ const defaultExec$2 = async (cmd, args, opts) => {
1199
+ const result = spawnSync(cmd, args, {
1200
+ cwd: opts.cwd,
1201
+ encoding: "utf8",
1202
+ stdio: [
1203
+ "inherit",
1204
+ "pipe",
1205
+ "pipe"
1206
+ ]
1207
+ });
1208
+ return {
1209
+ exitCode: result.status ?? -1,
1210
+ stdout: result.stdout ?? "",
1211
+ stderr: result.stderr ?? ""
1212
+ };
1213
+ };
1204
1214
  //#endregion
1205
- //#region src/templates/actions/setup.yml
1206
- 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";
1207
- //#endregion
1208
- //#region src/templates/actions/install.yml
1209
- 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";
1210
- //#endregion
1211
- //#region src/templates/actions/setup-node.yml
1212
- 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";
1215
+ //#region src/commands/plugin-create/template-inputs.ts
1216
+ /** Derive the standard defaults from a slug + vendor name. */
1217
+ function deriveDefaults$1(input) {
1218
+ const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
1219
+ const capability = input.capability;
1220
+ return {
1221
+ vendorUpper,
1222
+ capabilityClass: `${input.vendorName}${capability.charAt(0).toUpperCase() + capability.slice(1)}`,
1223
+ tokenEnv: `HOLOCRON_${vendorUpper}_TOKEN`,
1224
+ transport: "rest"
1225
+ };
1226
+ }
1213
1227
  //#endregion
1214
- //#region src/templates/workflows/audit.yml
1215
- 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 run-knip:\n description: Run Knip to detect unused files, exports, and dependencies\n type: boolean\n required: false\n default: false\n knip-script:\n description: Script that invokes Knip (must exit non-zero on findings)\n type: string\n required: false\n default: pnpm run audit\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\n knip:\n name: Knip\n if: ${{ inputs.run-knip }}\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 with:\n persist-credentials: false\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: eval \"$KNIP_SCRIPT\"\n name: Run Knip\n env:\n KNIP_SCRIPT: ${{ inputs.knip-script }}\n";
1228
+ //#region src/commands/plugin-create/templates/auth.ts
1229
+ function render$17(inputs) {
1230
+ return `import { AuthError, createResolveToken, type ResolveTokenInput } from "@theholocron/cli";
1231
+
1232
+ export { AuthError };
1233
+ export type { ResolveTokenInput };
1234
+
1235
+ export const resolveToken = createResolveToken({
1236
+ \tenvName: "${inputs.tokenEnv}",
1237
+ \tvendorEnvName: "${inputs.vendorEnv}",
1238
+ \tkeyringService: "${inputs.slug}",
1239
+ \terrorMessage:
1240
+ \t\t"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
1241
+ \t\t"or run: holocron auth set ${inputs.slug} <TOKEN>",
1242
+ });
1243
+ `;
1244
+ }
1216
1245
  //#endregion
1217
- //#region src/templates/workflows/bookkeeping.yml
1218
- 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";
1219
- //#endregion
1220
- //#region src/templates/workflows/codeql.yml
1221
- 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";
1222
- //#endregion
1223
- //#region src/templates/workflows/dependencies.yml
1224
- 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";
1225
- //#endregion
1226
- //#region src/templates/workflows/greetings.yml
1227
- 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";
1228
- //#endregion
1229
- //#region src/templates/workflows/lint.yml
1230
- 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";
1231
- //#endregion
1232
- //#region src/templates/workflows/release.yml
1233
- 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 HOLOCRON_RELEASE_TOKEN:\n description: >\n Fine-grained PAT (Contents + Issues + Pull requests: write) owned by\n an admin. Required when the default branch is protected by a ruleset —\n github.token cannot push through rulesets, but an admin PAT can.\n Takes priority over HOLOCRON_SYNC_TOKEN. Falls back to github.token.\n required: false\n HOLOCRON_SYNC_TOKEN:\n description: >\n Legacy alias for HOLOCRON_RELEASE_TOKEN — kept for backward compatibility.\n Prefer HOLOCRON_RELEASE_TOKEN for new repos.\n required: false\n HOLOCRON_READ_TOKEN:\n description: >\n Fine-grained PAT for read-only GitHub API calls (e.g. resolving git\n committer identity via `gh api user`). Falls back to github.token.\n required: false\n GH_TOKEN:\n description: >\n Generic GitHub token fallback for `gh` CLI calls. Used when\n HOLOCRON_READ_TOKEN is not set.\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 HOLOCRON_RELEASE_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.HOLOCRON_RELEASE_TOKEN || secrets.HOLOCRON_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.HOLOCRON_READ_TOKEN || secrets.GH_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 HOLOCRON_RELEASE_TOKEN (fine-grained PAT, Contents+Issues+PRs write,\n # owned by an admin with ruleset bypass) so @semantic-release/git can\n # push the version-bump commit through branch protection. Falls back to\n # HOLOCRON_SYNC_TOKEN (legacy) then github.token for unprotected repos.\n GITHUB_TOKEN: ${{ secrets.HOLOCRON_RELEASE_TOKEN || secrets.HOLOCRON_SYNC_TOKEN || github.token }}\n NPM_CONFIG_PROVENANCE: true\n";
1234
- //#endregion
1235
- //#region src/templates/workflows/review.yml
1236
- 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 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-annotations\n tool_name: \"Review / gitleaks\"\n gitleaks_flags: --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}\n\n - name: YamlLint\n if: ${{ hashFiles('yamllint.config.yml') != '' }}\n uses: reviewdog/action-yamllint@b5f7217d8c815ae374d1d55840d5e569d82f01f0 # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / yamllint\"\n yamllint_flags: -c ${{ github.workspace }}/yamllint.config.yml ${{ github.workspace }}\n\n - name: ActionLint (GitHub Actions)\n if: ${{ hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}\n uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / actionlint\"\n\n #\n # TypeScript / JavaScript\n #\n\n - name: ESLint\n if: steps.detect.outputs.eslint == 'true'\n uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / eslint\"\n eslint_flags: .\n\n - name: TypeScript\n if: steps.detect.outputs.tsconfig == 'true'\n uses: EPMatt/reviewdog-action-tsc@63d923a3c5b4497671940b8874f58a404e2351b5 # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / tsc\"\n\n #\n # Shell\n #\n\n - name: ShellCheck\n if: steps.detect.outputs.shell == 'true'\n uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / shellcheck\"\n fail_level: none\n\n #\n # Docker\n #\n\n - name: Hadolint\n if: steps.detect.outputs.docker == 'true'\n uses: reviewdog/action-hadolint@1b2cfa6ba72072ad35158d7ff3aa49bbdc03506d # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / hadolint\"\n fail_level: none\n\n #\n # Environment files\n #\n\n - name: dotenv-linter\n if: steps.detect.outputs.dotenv == 'true'\n uses: dotenv-linter/action-dotenv-linter@afde61cfda2ecffe7bea35837b6f20b956c88689 # v3.0.0\n with:\n reporter: github-code-suggestions\n\n #\n # Documentation\n #\n\n - name: Alex (inclusive language)\n if: steps.detect.outputs.markdown == 'true'\n uses: reviewdog/action-alex@347481655add010a2ae302df34b57c9bcfa0d6e4 # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / alex\"\n";
1237
- //#endregion
1238
- //#region src/templates/workflows/stale.yml
1239
- 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";
1240
- //#endregion
1241
- //#region src/templates/workflows/sync-github.yml
1242
- 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# HOLOCRON_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 HOLOCRON_SYNC_TOKEN:\n required: true\n HOLOCRON_READ_TOKEN:\n description: >\n Fine-grained PAT for read-only GitHub API calls (e.g. resolving git\n committer identity via `gh api user`). Falls back to HOLOCRON_SYNC_TOKEN.\n required: false\n GH_TOKEN:\n description: >\n Generic GitHub token fallback for `gh` CLI calls. Used when neither\n HOLOCRON_READ_TOKEN nor HOLOCRON_SYNC_TOKEN is set.\n required: false\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 GH_TOKEN: ${{ secrets.HOLOCRON_READ_TOKEN || secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN }}\n HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_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 GH_TOKEN: ${{ secrets.HOLOCRON_READ_TOKEN || secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN }}\n HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n SECONDARY_REPOS: ${{ inputs.secondary-repos }}\n SYNC_BRANCH: ${{ inputs.sync-branch }}\n";
1243
- //#endregion
1244
- //#region src/templates/workflows/test.yml
1245
- 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";
1246
- //#endregion
1247
- //#region src/templates/workflows/typecheck.yml
1248
- 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";
1249
- //#endregion
1250
- //#region src/templates/index.ts
1251
- /**
1252
- * All reusable workflow and composite action content bundled as string
1253
- * constants so the CLI can push them to theholocron/.github without
1254
- * needing filesystem access at runtime.
1255
- *
1256
- * Content lives in standalone .yml files; the rawYml rollup plugin
1257
- * inlines them as string exports at build time.
1258
- */
1259
- const ACTIONS = {
1260
- "setup/action": setup_default,
1261
- "install/action": install_default,
1262
- "setup-node/action": setup_node_default
1263
- };
1264
- const REUSABLE_WORKFLOWS = {
1265
- audit: audit_default$1,
1266
- bookkeeping: bookkeeping_default$1,
1267
- codeql: codeql_default$1,
1268
- dependencies: dependencies_default$1,
1269
- greetings: greetings_default$1,
1270
- lint: lint_default$1,
1271
- release: release_default$1,
1272
- review: review_default$1,
1273
- stale: stale_default$1,
1274
- "sync-github": sync_github_default$1,
1275
- test: test_default$1,
1276
- typecheck: typecheck_default$1
1277
- };
1278
- const WORKFLOW_TEMPLATE_PROPERTIES = {
1279
- bookkeeping: JSON.stringify({
1280
- name: "Bookkeeping",
1281
- description: "Label and track issues and pull requests.",
1282
- iconName: "octicon tag"
1283
- }, null, 2),
1284
- "sync-github": JSON.stringify({
1285
- name: "Sync GitHub Templates",
1286
- description: "Sync workflow templates and composite actions from the holocron CLI.",
1287
- iconName: "octicon sync"
1288
- }, null, 2)
1289
- };
1290
- //#endregion
1291
- //#region src/commands/dependabot.yml
1292
- 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";
1293
- //#endregion
1294
- //#region src/commands/workflows/audit.yml
1295
- 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";
1296
- //#endregion
1297
- //#region src/commands/workflows/bookkeeping.yml
1298
- 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";
1299
- //#endregion
1300
- //#region src/commands/workflows/codeql.yml
1301
- 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";
1302
- //#endregion
1303
- //#region src/commands/workflows/dependencies.yml
1304
- 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";
1305
- //#endregion
1306
- //#region src/commands/workflows/greetings.yml
1307
- 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";
1308
- //#endregion
1309
- //#region src/commands/workflows/lint.yml
1310
- 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";
1311
- //#endregion
1312
- //#region src/commands/workflows/release.yml
1313
- 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";
1314
- //#endregion
1315
- //#region src/commands/workflows/review.yml
1316
- 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";
1317
- //#endregion
1318
- //#region src/commands/workflows/stale.yml
1319
- 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";
1320
- //#endregion
1321
- //#region src/commands/workflows/sync-github.yml
1322
- 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 HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n";
1323
- //#endregion
1324
- //#region src/commands/workflows/test.yml
1325
- 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";
1326
- //#endregion
1327
- //#region src/commands/workflows/typecheck.yml
1328
- 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";
1329
- //#endregion
1330
- //#region src/commands/setup-workflows.ts
1331
- /** Header prepended when holocron setup writes a generated file to a repo. */
1332
- function workflowHeader(source = "packages/cli/src/commands/setup-workflows.ts") {
1333
- return [
1334
- `# AUTO-GENERATED — do not edit directly.`,
1335
- `# Source: theholocron/holocron · ${source}`,
1336
- `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1337
- `# Tool: holocron setup`,
1338
- `# Changes: run \`holocron setup\` to regenerate.`,
1339
- ``
1340
- ].join("\n");
1341
- }
1342
- const WORKFLOW_TEMPLATES = {
1343
- lint: lint_default,
1344
- test: test_default,
1345
- typecheck: typecheck_default,
1346
- codeql: codeql_default,
1347
- review: review_default,
1348
- release: release_default,
1349
- stale: stale_default,
1350
- greetings: greetings_default,
1351
- dependencies: dependencies_default,
1352
- bookkeeping: bookkeeping_default,
1353
- audit: audit_default,
1354
- "sync-github": sync_github_default
1355
- };
1356
- const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
1357
- /**
1358
- * GitHub check context name each CI workflow produces on a PR.
1359
- *
1360
- * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
1361
- * job's own `name:` field does NOT appear in the external check name — only
1362
- * the calling workflow's top-level `name:` and the inner reusable-workflow
1363
- * job name matter. Only workflows that gate merges are listed here.
1364
- */
1365
- const WORKFLOW_CHECK_CONTEXTS = {
1366
- lint: "Lint / Lint entire codebase",
1367
- test: "Test / Run tests and collect coverage",
1368
- typecheck: "Typecheck / tsc --noEmit"
1369
- };
1370
- /**
1371
- * Generate the thin caller content for a workflow, optionally injecting or
1372
- * merging `with:` overrides into the jobs block.
1373
- *
1374
- * Two strategies are used depending on the template:
1375
- * - Templates that already have a `with:` block (e.g. lint, sync-github):
1376
- * the override entries are merged in, replacing existing keys and appending
1377
- * new ones.
1378
- * - Templates that end with ` secrets: inherit`: a new `with:` block is
1379
- * injected immediately before `secrets: inherit`.
1380
- * If neither pattern matches the template, a warning is emitted and the
1381
- * base template is returned unchanged.
1382
- */
1383
- function generateThinCallerContent(name, withOverrides) {
1384
- const base = WORKFLOW_TEMPLATES[name];
1385
- if (!base) return "";
1386
- if (!withOverrides || Object.keys(withOverrides).length === 0) return base;
1387
- const fmt = (k, v) => ` ${k}: ${v === true ? "true" : v === false ? "false" : String(v)}`;
1388
- const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
1389
- const existingMatch = base.match(withBlockRe);
1390
- if (existingMatch) {
1391
- const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).map((line) => {
1392
- const m = line.match(/^ {6}([^:]+):\s*(.*)/);
1393
- return m ? [m[1].trim(), m[2].trim()] : null;
1394
- }).filter((e) => e !== null));
1395
- for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, v === true ? "true" : v === false ? "false" : String(v));
1396
- const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
1397
- return base.replace(withBlockRe, ` with:\n${merged}\n`);
1398
- }
1399
- const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
1400
- const result = base.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
1401
- if (result === base) console.warn(`[generateThinCallerContent] could not inject with: overrides into "${name}" template`);
1402
- return result;
1403
- }
1404
- //#endregion
1405
- //#region src/commands/sync-github.ts
1406
- const DEFAULT_REPO = "theholocron/.github";
1407
- /**
1408
- * Extracts the `workflows` array from a `holocron.config.ts` source string.
1409
- * Handles both plain string entries and `{ name, with }` object entries.
1410
- * Falls back to an empty array if the array cannot be found or parsed.
1411
- */
1412
- function parseWorkflowsFromTs(source) {
1413
- const keyMatch = source.match(/\bworkflows\s*:\s*\[/);
1414
- if (!keyMatch) return [];
1415
- const start = keyMatch.index + keyMatch[0].length;
1416
- let depth = 1;
1417
- let i = start;
1418
- while (i < source.length && depth > 0) {
1419
- if (source[i] === "[") depth++;
1420
- else if (source[i] === "]") depth--;
1421
- i++;
1422
- }
1423
- const body = source.slice(start, i - 1);
1424
- const entries = [];
1425
- const objSpans = [];
1426
- const objRe = /\{\s*name\s*:\s*"([^"]+)"(?:\s*,\s*with\s*:\s*(\{[^}]*\}))?\s*\}/g;
1427
- let m;
1428
- while ((m = objRe.exec(body)) !== null) {
1429
- objSpans.push([m.index, m.index + m[0].length]);
1430
- let withObj;
1431
- if (m[2]) try {
1432
- withObj = JSON.parse(m[2]);
1433
- } catch {}
1434
- entries.push({
1435
- pos: m.index,
1436
- entry: {
1437
- name: m[1],
1438
- ...withObj && { with: withObj }
1439
- }
1440
- });
1441
- }
1442
- const strRe = /"([^"]+)"/g;
1443
- while ((m = strRe.exec(body)) !== null) if (!objSpans.some(([s, e]) => m.index >= s && m.index < e)) entries.push({
1444
- pos: m.index,
1445
- entry: { name: m[1] }
1446
- });
1447
- entries.sort((a, b) => a.pos - b.pos);
1448
- return entries.map(({ entry }) => entry);
1449
- }
1450
- function reusableHeader(source) {
1451
- return [
1452
- `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
1453
- `# Source: theholocron/holocron · ${source}`,
1454
- `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1455
- `# Tool: holocron sync-github`,
1456
- `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1457
- ``
1458
- ].join("\n");
1459
- }
1460
- function thinCallerHeader(forPrimary = false) {
1461
- return [
1462
- forPrimary ? `# AUTO-GENERATED — do not edit in theholocron/.github directly.` : `# AUTO-GENERATED — do not edit directly.`,
1463
- `# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
1464
- `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
1465
- `# Tool: holocron sync-github`,
1466
- `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
1467
- ``
1468
- ].join("\n");
1469
- }
1470
- function buildBatch(repo, allowedWorkflows, withOverrides) {
1471
- const files = [];
1472
- const isPrimaryGithubRepo = repo === DEFAULT_REPO;
1473
- if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(ACTIONS)) files.push({
1474
- path: `.github/actions/${name}.yml`,
1475
- content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1476
- });
1477
- if (isPrimaryGithubRepo) {
1478
- for (const [name, content] of Object.entries(REUSABLE_WORKFLOWS)) files.push({
1479
- path: `.github/workflows/${name}.yml`,
1480
- content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
1481
- });
1482
- for (const [name, content] of Object.entries(WORKFLOW_TEMPLATES)) {
1483
- files.push({
1484
- path: `workflow-templates/${name}.yml`,
1485
- content: thinCallerHeader(true) + content
1486
- });
1487
- const props = WORKFLOW_TEMPLATE_PROPERTIES[name];
1488
- if (props) files.push({
1489
- path: `workflow-templates/${name}.properties.json`,
1490
- content: props
1491
- });
1492
- }
1493
- } else for (const name of Object.keys(REUSABLE_WORKFLOWS)) {
1494
- if (allowedWorkflows && !allowedWorkflows.has(name)) continue;
1495
- const content = generateThinCallerContent(name, withOverrides?.get(name));
1496
- if (!content) continue;
1497
- files.push({
1498
- path: `.github/workflows/${name}.yml`,
1499
- content: thinCallerHeader() + content
1500
- });
1501
- }
1502
- return files;
1503
- }
1504
- /** Git blob SHA: sha1("blob {len}\0{content}") — used to detect unchanged files. */
1505
- function gitBlobSha(content) {
1506
- const buf = Buffer.from(content, "utf8");
1507
- return createHash("sha1").update(`blob ${buf.length}\0`).update(buf).digest("hex");
1508
- }
1509
- async function runSyncGithub(input) {
1510
- const print = input.print ?? ((line) => console.log(line));
1511
- const repo = input.repo ?? DEFAULT_REPO;
1512
- const { token, dryRun = false, branch, createPr = false } = input;
1513
- const message = input.message ?? `chore: sync from theholocron/holocron`;
1514
- const client = createGitHubClient({
1515
- token,
1516
- fetch: input.fetch
1517
- });
1518
- print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
1519
- print(` repo: ${repo}`);
1520
- if (branch) print(` branch: ${branch}`);
1521
- print("");
1522
- if (input.outputDir) {
1523
- const batch = buildBatch(repo);
1524
- for (const file of batch) {
1525
- const dest = join(input.outputDir, file.path);
1526
- mkdirSync(dirname(dest), { recursive: true });
1527
- writeFileSync(dest, file.content, "utf8");
1528
- }
1529
- print(` ${batch.length} files written to ${input.outputDir}`);
1530
- return {
1531
- status: "ok",
1532
- created: batch.length,
1533
- updated: 0,
1534
- unchanged: 0
1535
- };
1536
- }
1537
- let targetBranch = branch;
1538
- let defaultBranch;
1539
- if (!targetBranch || createPr) try {
1540
- defaultBranch = (await client.repos.getRepo(repo)).default_branch;
1541
- if (!targetBranch) targetBranch = defaultBranch;
1542
- } catch {
1543
- const msg = "failed to fetch repo metadata";
1544
- print(` ✗ ${msg}`);
1545
- return {
1546
- status: "fail",
1547
- created: 0,
1548
- updated: 0,
1549
- unchanged: 0,
1550
- message: msg
1551
- };
1552
- }
1553
- const baseBranch = createPr && defaultBranch ? defaultBranch : targetBranch;
1554
- let headSha;
1555
- let baseTreeSha;
1556
- let existingBlobs;
1557
- try {
1558
- headSha = (await client.git.getRef(repo, baseBranch)).object.sha;
1559
- baseTreeSha = (await client.git.getCommit(repo, headSha)).tree.sha;
1560
- const treeData = await client.git.getTree(repo, baseTreeSha, true);
1561
- existingBlobs = new Map(treeData.tree.filter((i) => i.type === "blob").map((i) => [i.path, i.sha]));
1562
- } catch (err) {
1563
- const msg = err instanceof Error ? err.message : `Branch ${baseBranch} not found`;
1564
- print(` ✗ ${msg}`);
1565
- return {
1566
- status: "fail",
1567
- created: 0,
1568
- updated: 0,
1569
- unchanged: 0,
1570
- message: msg
1571
- };
1572
- }
1573
- let allowedWorkflows;
1574
- let withOverrides;
1575
- if (repo !== DEFAULT_REPO) try {
1576
- let entries = [];
1577
- try {
1578
- const data = await client.git.getContents(repo, "holocron.config.json");
1579
- entries = (JSON.parse(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"))?.workflows ?? []).map((w) => typeof w === "string" ? { name: w } : w);
1580
- } catch (err) {
1581
- if (!(err instanceof ProviderApiError) || err.status !== 404) throw err;
1582
- try {
1583
- const data = await client.git.getContents(repo, "holocron.config.ts");
1584
- entries = parseWorkflowsFromTs(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"));
1585
- } catch {}
1586
- }
1587
- if (entries.length > 0) {
1588
- allowedWorkflows = new Set(entries.map((e) => e.name));
1589
- const overrideEntries = entries.filter((e) => e.with != null).map((e) => [e.name, e.with]);
1590
- if (overrideEntries.length > 0) withOverrides = new Map(overrideEntries);
1591
- }
1592
- } catch {}
1593
- const batch = buildBatch(repo, allowedWorkflows, withOverrides);
1594
- let created = 0;
1595
- let updated = 0;
1596
- let unchanged = 0;
1597
- const changedFiles = [];
1598
- for (const file of batch) {
1599
- const localSha = gitBlobSha(file.content);
1600
- const existingSha = existingBlobs.get(file.path);
1601
- if (existingSha === localSha) {
1602
- print(` · unchanged ${file.path}`);
1603
- unchanged++;
1604
- } else if (existingSha) {
1605
- print(` ${dryRun ? "~" : "✓"} updated ${file.path}`);
1606
- updated++;
1607
- if (!dryRun) changedFiles.push(file);
1608
- } else {
1609
- print(` ${dryRun ? "~" : "✓"} created ${file.path}`);
1610
- created++;
1611
- if (!dryRun) changedFiles.push(file);
1612
- }
1613
- }
1614
- print("");
1615
- print(` ${created} created, ${updated} updated, ${unchanged} unchanged`);
1616
- if (dryRun || changedFiles.length === 0) return {
1617
- status: dryRun ? "dry-run" : "ok",
1618
- created,
1619
- updated,
1620
- unchanged
1621
- };
1622
- const treeEntries = [];
1623
- for (const file of changedFiles) try {
1624
- const blob = await client.git.createBlob(repo, file.content);
1625
- treeEntries.push({
1626
- path: file.path,
1627
- mode: "100644",
1628
- type: "blob",
1629
- sha: blob.sha
1630
- });
1631
- } catch (err) {
1632
- const msg = `failed to create blob for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
1633
- print(` ✗ ${msg}`);
1634
- return {
1635
- status: "fail",
1636
- created,
1637
- updated,
1638
- unchanged,
1639
- message: msg
1640
- };
1641
- }
1642
- let newTreeSha;
1643
- try {
1644
- newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
1645
- } catch (err) {
1646
- const msg = `failed to create tree: ${err instanceof Error ? err.message : String(err)}`;
1647
- print(` ✗ ${msg}`);
1648
- return {
1649
- status: "fail",
1650
- created,
1651
- updated,
1652
- unchanged,
1653
- message: msg
1654
- };
1655
- }
1656
- let newCommitSha;
1657
- try {
1658
- newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
1659
- } catch (err) {
1660
- const msg = `failed to create commit: ${err instanceof Error ? err.message : String(err)}`;
1661
- print(` ✗ ${msg}`);
1662
- return {
1663
- status: "fail",
1664
- created,
1665
- updated,
1666
- unchanged,
1667
- message: msg
1668
- };
1669
- }
1670
- try {
1671
- if (createPr && branch) try {
1672
- await client.git.createRef(repo, `refs/heads/${branch}`, newCommitSha);
1673
- } catch (err) {
1674
- if (!(err instanceof ProviderApiError) || err.status !== 422) throw err;
1675
- await client.git.updateRef(repo, `heads/${branch}`, newCommitSha, true);
1676
- }
1677
- else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
1678
- } catch (err) {
1679
- const msg = `failed to update ref: ${err instanceof Error ? err.message : String(err)}`;
1680
- print(` ✗ ${msg}`);
1681
- return {
1682
- status: "fail",
1683
- created,
1684
- updated,
1685
- unchanged,
1686
- message: msg
1687
- };
1688
- }
1689
- let prUrl;
1690
- if (branch && createPr && !dryRun) try {
1691
- prUrl = (await client.git.createPull(repo, {
1692
- title: message.split("\n")[0],
1693
- head: branch,
1694
- base: "main",
1695
- body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
1696
- })).html_url;
1697
- print(` → PR opened: ${prUrl}`);
1698
- } catch (err) {
1699
- if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("already exists")) print(` → PR already open for ${branch} — branch updated, ready to merge`);
1700
- else print(` ⚠ PR creation failed: ${err instanceof Error ? err.message : String(err)}`);
1701
- }
1702
- return {
1703
- status: "ok",
1704
- created,
1705
- updated,
1706
- unchanged,
1707
- prUrl
1708
- };
1709
- }
1710
- //#endregion
1711
- //#region src/commands/npm-publish-initial.ts
1712
- /**
1713
- * `holocron npm publish-initial` — bottles up the chicken-and-egg
1714
- * bootstrap that every new npm-published holocron monorepo hits.
1715
- *
1716
- * npm requires a package to exist before Trusted Publishing can be
1717
- * configured on it. So the first publish has to happen outside the
1718
- * OIDC flow — using either a browser-auth session (`npm login
1719
- * --auth-type=web`) or an ephemeral automation token. This command
1720
- * runs the publish step + tells you exactly what to do next.
1721
- *
1722
- * Workflow:
1723
- *
1724
- * $ npm login --auth-type=web # one-time, browser-based
1725
- * $ pnpm install --frozen-lockfile
1726
- * $ pnpm build
1727
- * $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
1728
- *
1729
- * The command itself only handles the publish step + the post-publish
1730
- * Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
1731
- * stay outside the command (no pnpm-inside-pnpm).
1732
- *
1733
- * If `NPM_TOKEN` is detected in env, the command prints a final
1734
- * "revoke this token at <url>" reminder — same pattern as `rando vc
1735
- * setup` for the ephemeral GH admin PAT.
1736
- */
1737
- const PUBLISHABLE_PACKAGES = [
1738
- "@theholocron/cli",
1739
- "@theholocron/holocron-plugin-github",
1740
- "@theholocron/holocron-plugin-vercel",
1741
- "@theholocron/holocron-plugin-neon",
1742
- "@theholocron/holocron-plugin-clerk",
1743
- "@theholocron/holocron-plugin-1password",
1744
- "@theholocron/holocron-plugin-postman"
1745
- ];
1746
- async function runNpmPublishInitial(input = {}) {
1747
- const print = input.print ?? ((line) => console.log(line));
1748
- const cwd = input.cwd ?? process.cwd();
1749
- const tag = input.tag ?? "alpha";
1750
- const dryRun = input.dryRun ?? false;
1751
- const otp = input.otp;
1752
- const env = input.env ?? process.env;
1753
- const exec = input.exec ?? defaultExec$2;
1754
- const publishArgs = [
1755
- "-r",
1756
- "--filter=./packages/*",
1757
- "publish",
1758
- "--access",
1759
- "public",
1760
- "--no-git-checks",
1761
- "--tag",
1762
- tag,
1763
- ...otp ? ["--otp", otp] : []
1764
- ];
1765
- print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
1766
- print(` cwd: ${cwd}`);
1767
- print(` tag: ${tag}`);
1768
- if (otp) print(` otp: <${otp.length} chars>`);
1769
- print("");
1770
- print(" → verifying npm auth (`npm whoami`)…");
1771
- const whoami = await exec("npm", ["whoami"], { cwd });
1772
- if (whoami.exitCode !== 0) {
1773
- const message = "npm is not authenticated. Run `npm login --auth-type=web` (browser flow, no token stored) or `npm login`, then re-run this command.";
1774
- print(` ✗ ${message}`);
1775
- return {
1776
- status: "fail",
1777
- message,
1778
- packageNames: PUBLISHABLE_PACKAGES
1779
- };
1780
- }
1781
- print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
1782
- if (dryRun) {
1783
- print("");
1784
- print(" … (dry-run) skipping actual publish");
1785
- print(` would run: pnpm ${publishArgs.join(" ")}`);
1786
- printNextSteps$1(print, env);
1787
- return {
1788
- status: "dry-run",
1789
- message: "dry-run — no publish executed",
1790
- packageNames: PUBLISHABLE_PACKAGES
1791
- };
1792
- }
1793
- print("");
1794
- print(" → publishing all public @theholocron/* packages…");
1795
- const publish = await exec("pnpm", publishArgs, { cwd });
1796
- if (publish.exitCode !== 0) {
1797
- const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
1798
- print(` ✗ ${message}`);
1799
- if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
1800
- print("");
1801
- print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
1802
- print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
1803
- }
1804
- return {
1805
- status: "fail",
1806
- message,
1807
- packageNames: PUBLISHABLE_PACKAGES
1808
- };
1809
- }
1810
- print(" ✓ publish complete");
1811
- printNextSteps$1(print, env);
1812
- return {
1813
- status: "ok",
1814
- packageNames: PUBLISHABLE_PACKAGES
1815
- };
1816
- }
1817
- function printNextSteps$1(print, env) {
1818
- print("");
1819
- print(" → next: configure Trusted Publisher for each package on npm:");
1820
- for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
1821
- print(" Publisher: GitHub Actions Org: theholocron Repo: holocron Workflow: release.yml");
1822
- if (env.NPM_TOKEN) {
1823
- print("");
1824
- print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
1825
- print(" https://www.npmjs.com/settings/~/tokens");
1826
- }
1827
- }
1828
- const defaultExec$2 = async (cmd, args, opts) => {
1829
- const result = spawnSync(cmd, args, {
1830
- cwd: opts.cwd,
1831
- encoding: "utf8",
1832
- stdio: [
1833
- "inherit",
1834
- "pipe",
1835
- "pipe"
1836
- ]
1837
- });
1838
- return {
1839
- exitCode: result.status ?? -1,
1840
- stdout: result.stdout ?? "",
1841
- stderr: result.stderr ?? ""
1842
- };
1843
- };
1844
- //#endregion
1845
- //#region src/commands/plugin-create/template-inputs.ts
1846
- /** Derive the standard defaults from a slug + vendor name. */
1847
- function deriveDefaults$1(input) {
1848
- const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
1849
- const capability = input.capability;
1850
- return {
1851
- vendorUpper,
1852
- capabilityClass: `${input.vendorName}${capability.charAt(0).toUpperCase() + capability.slice(1)}`,
1853
- tokenEnv: `HOLOCRON_${vendorUpper}_TOKEN`,
1854
- transport: "rest"
1855
- };
1856
- }
1857
- //#endregion
1858
- //#region src/commands/plugin-create/templates/auth.ts
1859
- function render$17(inputs) {
1860
- return `import { AuthError, createResolveToken, type ResolveTokenInput } from "@theholocron/cli";
1861
-
1862
- export { AuthError };
1863
- export type { ResolveTokenInput };
1864
-
1865
- export const resolveToken = createResolveToken({
1866
- \tenvName: "${inputs.tokenEnv}",
1867
- \tvendorEnvName: "${inputs.vendorEnv}",
1868
- \tkeyringService: "${inputs.slug}",
1869
- \terrorMessage:
1870
- \t\t"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
1871
- \t\t"or run: holocron auth set ${inputs.slug} <TOKEN>",
1872
- });
1873
- `;
1874
- }
1875
- //#endregion
1876
- //#region src/commands/plugin-create/templates/auth-test.ts
1877
- function render$16(inputs) {
1878
- return `import { describe, expect, it } from "vitest";
1879
-
1880
- import { AuthError, resolveToken } from "../auth.js";
1881
-
1882
- const noKeyring = () => null;
1883
-
1884
- describe("resolveToken", () => {
1885
- it("prefers --token over env vars + keyring", () => {
1886
- expect(
1887
- resolveToken({
1888
- cliToken: "flag",
1889
- env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
1890
- keyring: () => "kr",
1891
- })
1892
- ).toBe("flag");
1893
- });
1894
-
1895
- it("prefers ${inputs.tokenEnv} over ${inputs.vendorEnv}", () => {
1896
- expect(
1897
- resolveToken({
1898
- env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
1899
- keyring: noKeyring,
1900
- })
1901
- ).toBe("hlc");
1902
- });
1903
-
1904
- it("falls back to ${inputs.vendorEnv} when ${inputs.tokenEnv} is unset", () => {
1905
- expect(resolveToken({ env: { ${inputs.vendorEnv}: "vendor" }, keyring: noKeyring })).toBe("vendor");
1906
- });
1907
-
1908
- it("falls back to keyring when env vars are unset", () => {
1909
- expect(resolveToken({ env: {}, keyring: (p) => (p === "${inputs.slug}" ? "kr" : null) })).toBe("kr");
1910
- });
1911
-
1912
- it("throws AuthError with a helpful message when nothing is set", () => {
1913
- try {
1914
- resolveToken({ env: {}, keyring: noKeyring });
1915
- throw new Error("should have thrown");
1916
- } catch (err) {
1917
- expect(err).toBeInstanceOf(AuthError);
1918
- expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
1919
- expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
1920
- }
1921
- });
1922
- });
1923
- `;
1924
- }
1246
+ //#region src/commands/plugin-create/templates/auth-test.ts
1247
+ function render$16(inputs) {
1248
+ return `import { describe, expect, it } from "vitest";
1249
+
1250
+ import { AuthError, resolveToken } from "../auth.js";
1251
+
1252
+ const noKeyring = () => null;
1253
+
1254
+ describe("resolveToken", () => {
1255
+ it("prefers --token over env vars + keyring", () => {
1256
+ expect(
1257
+ resolveToken({
1258
+ cliToken: "flag",
1259
+ env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
1260
+ keyring: () => "kr",
1261
+ })
1262
+ ).toBe("flag");
1263
+ });
1264
+
1265
+ it("prefers ${inputs.tokenEnv} over ${inputs.vendorEnv}", () => {
1266
+ expect(
1267
+ resolveToken({
1268
+ env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
1269
+ keyring: noKeyring,
1270
+ })
1271
+ ).toBe("hlc");
1272
+ });
1273
+
1274
+ it("falls back to ${inputs.vendorEnv} when ${inputs.tokenEnv} is unset", () => {
1275
+ expect(resolveToken({ env: { ${inputs.vendorEnv}: "vendor" }, keyring: noKeyring })).toBe("vendor");
1276
+ });
1277
+
1278
+ it("falls back to keyring when env vars are unset", () => {
1279
+ expect(resolveToken({ env: {}, keyring: (p) => (p === "${inputs.slug}" ? "kr" : null) })).toBe("kr");
1280
+ });
1281
+
1282
+ it("throws AuthError with a helpful message when nothing is set", () => {
1283
+ try {
1284
+ resolveToken({ env: {}, keyring: noKeyring });
1285
+ throw new Error("should have thrown");
1286
+ } catch (err) {
1287
+ expect(err).toBeInstanceOf(AuthError);
1288
+ expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
1289
+ expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
1290
+ }
1291
+ });
1292
+ });
1293
+ `;
1294
+ }
1925
1295
  //#endregion
1926
1296
  //#region src/commands/plugin-create/templates/capability.ts
1927
1297
  function render$15(inputs) {
@@ -3067,18 +2437,132 @@ async function runRow(destination, scope, key, dryRun, body) {
3067
2437
  message: err instanceof Error ? err.message : String(err)
3068
2438
  };
3069
2439
  }
3070
- }
3071
- function formatRow(row) {
3072
- const detail = row.message ? style.dim(` (${row.message})`) : "";
3073
- const label = `${row.key}${detail}`;
3074
- if (row.status === "ok") return ` ${style.success(label)}`;
3075
- if (row.status === "fail") return ` ${style.fail(label)}`;
3076
- if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3077
- return ` ${style.dim(`· ${label}`)}`;
3078
- }
3079
- function vaultProviderName(loader) {
3080
- if (!loader.has("vault")) return "<missing>";
3081
- return loader.get("vault").providerName;
2440
+ }
2441
+ function formatRow(row) {
2442
+ const detail = row.message ? style.dim(` (${row.message})`) : "";
2443
+ const label = `${row.key}${detail}`;
2444
+ if (row.status === "ok") return ` ${style.success(label)}`;
2445
+ if (row.status === "fail") return ` ${style.fail(label)}`;
2446
+ if (row.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
2447
+ return ` ${style.dim(`· ${label}`)}`;
2448
+ }
2449
+ function vaultProviderName(loader) {
2450
+ if (!loader.has("vault")) return "<missing>";
2451
+ return loader.get("vault").providerName;
2452
+ }
2453
+ //#endregion
2454
+ //#region src/commands/dependabot.yml
2455
+ 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";
2456
+ //#endregion
2457
+ //#region src/commands/workflows/audit.yml
2458
+ var audit_default$1 = "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";
2459
+ //#endregion
2460
+ //#region src/commands/workflows/bookkeeping.yml
2461
+ var bookkeeping_default$1 = "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";
2462
+ //#endregion
2463
+ //#region src/commands/workflows/codeql.yml
2464
+ var codeql_default$1 = "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";
2465
+ //#endregion
2466
+ //#region src/commands/workflows/dependencies.yml
2467
+ var dependencies_default$1 = "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";
2468
+ //#endregion
2469
+ //#region src/commands/workflows/greetings.yml
2470
+ var greetings_default$1 = "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";
2471
+ //#endregion
2472
+ //#region src/commands/workflows/lint.yml
2473
+ var lint_default$1 = "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";
2474
+ //#endregion
2475
+ //#region src/commands/workflows/release.yml
2476
+ var release_default$1 = "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";
2477
+ //#endregion
2478
+ //#region src/commands/workflows/review.yml
2479
+ var review_default$1 = "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";
2480
+ //#endregion
2481
+ //#region src/commands/workflows/stale.yml
2482
+ var stale_default$1 = "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";
2483
+ //#endregion
2484
+ //#region src/commands/workflows/sync-github.yml
2485
+ var sync_github_default$1 = "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 HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n";
2486
+ //#endregion
2487
+ //#region src/commands/workflows/test.yml
2488
+ var test_default$1 = "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";
2489
+ //#endregion
2490
+ //#region src/commands/workflows/typecheck.yml
2491
+ var typecheck_default$1 = "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";
2492
+ //#endregion
2493
+ //#region src/commands/setup-workflows.ts
2494
+ /** Header prepended when holocron setup writes a generated file to a repo. */
2495
+ function workflowHeader(source = "packages/cli/src/commands/setup-workflows.ts") {
2496
+ return [
2497
+ `# AUTO-GENERATED — do not edit directly.`,
2498
+ `# Source: theholocron/holocron · ${source}`,
2499
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
2500
+ `# Tool: holocron setup`,
2501
+ `# Changes: run \`holocron setup\` to regenerate.`,
2502
+ ``
2503
+ ].join("\n");
2504
+ }
2505
+ const WORKFLOW_TEMPLATES = {
2506
+ lint: lint_default$1,
2507
+ test: test_default$1,
2508
+ typecheck: typecheck_default$1,
2509
+ codeql: codeql_default$1,
2510
+ review: review_default$1,
2511
+ release: release_default$1,
2512
+ stale: stale_default$1,
2513
+ greetings: greetings_default$1,
2514
+ dependencies: dependencies_default$1,
2515
+ bookkeeping: bookkeeping_default$1,
2516
+ audit: audit_default$1,
2517
+ "sync-github": sync_github_default$1
2518
+ };
2519
+ const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
2520
+ /**
2521
+ * GitHub check context name each CI workflow produces on a PR.
2522
+ *
2523
+ * The format is "{caller-workflow-name} / {reusable-job-name}". The caller
2524
+ * job's own `name:` field does NOT appear in the external check name — only
2525
+ * the calling workflow's top-level `name:` and the inner reusable-workflow
2526
+ * job name matter. Only workflows that gate merges are listed here.
2527
+ */
2528
+ const WORKFLOW_CHECK_CONTEXTS = {
2529
+ lint: "Lint / Lint entire codebase",
2530
+ test: "Test / Run tests and collect coverage",
2531
+ typecheck: "Typecheck / tsc --noEmit"
2532
+ };
2533
+ /**
2534
+ * Generate the thin caller content for a workflow, optionally injecting or
2535
+ * merging `with:` overrides into the jobs block.
2536
+ *
2537
+ * Two strategies are used depending on the template:
2538
+ * - Templates that already have a `with:` block (e.g. lint, sync-github):
2539
+ * the override entries are merged in, replacing existing keys and appending
2540
+ * new ones.
2541
+ * - Templates that end with ` secrets: inherit`: a new `with:` block is
2542
+ * injected immediately before `secrets: inherit`.
2543
+ * If neither pattern matches the template, a warning is emitted and the
2544
+ * base template is returned unchanged.
2545
+ */
2546
+ function generateThinCallerContent(name, withOverrides) {
2547
+ const base = WORKFLOW_TEMPLATES[name];
2548
+ if (!base) return "";
2549
+ if (!withOverrides || Object.keys(withOverrides).length === 0) return base;
2550
+ const fmt = (k, v) => ` ${k}: ${v === true ? "true" : v === false ? "false" : String(v)}`;
2551
+ const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
2552
+ const existingMatch = base.match(withBlockRe);
2553
+ if (existingMatch) {
2554
+ const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).map((line) => {
2555
+ const m = line.match(/^ {6}([^:]+):\s*(.*)/);
2556
+ return m ? [m[1].trim(), m[2].trim()] : null;
2557
+ }).filter((e) => e !== null));
2558
+ for (const [k, v] of Object.entries(withOverrides)) existingEntries.set(k, v === true ? "true" : v === false ? "false" : String(v));
2559
+ const merged = [...existingEntries.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n");
2560
+ return base.replace(withBlockRe, ` with:\n${merged}\n`);
2561
+ }
2562
+ const withBlock = Object.entries(withOverrides).map(([k, v]) => fmt(k, v)).join("\n");
2563
+ const result = base.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
2564
+ if (result === base) console.warn(`[generateThinCallerContent] could not inject with: overrides into "${name}" template`);
2565
+ return result;
3082
2566
  }
3083
2567
  //#endregion
3084
2568
  //#region src/commands/setup.ts
@@ -3784,152 +3268,481 @@ async function runSetup(input) {
3784
3268
  print(style.hint(" Then re-run: holocron setup --token <your-temp-pat>"));
3785
3269
  print(style.hint(" You can revoke it immediately after setup completes."));
3786
3270
  }
3787
- return {
3788
- steps,
3789
- summary
3790
- };
3271
+ return {
3272
+ steps,
3273
+ summary
3274
+ };
3275
+ }
3276
+ /**
3277
+ * Fetch a single SKILL.md from its upstream GitHub source.
3278
+ * Verifies the SHA-256 hash when `computedHash` is present in the lock entry.
3279
+ */
3280
+ async function fetchExternalSkill(entry) {
3281
+ if (entry.sourceType !== "github") throw new Error(`unsupported sourceType: ${entry.sourceType}`);
3282
+ const url = `https://raw.githubusercontent.com/${entry.source}/HEAD/${entry.skillPath}`;
3283
+ const res = await fetch(url);
3284
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
3285
+ const content = await res.text();
3286
+ return {
3287
+ content,
3288
+ stale: !!entry.computedHash && createHash("sha256").update(content).digest("hex") !== entry.computedHash
3289
+ };
3290
+ }
3291
+ const AGENTS_SKILLS_ROOT = ".agents/skills";
3292
+ /** Relative path of the agent-specific symlink. undefined = unsupported agent. */
3293
+ const AGENT_SYMLINK_PATHS = { claude: (name) => `.claude/skills/${name}` };
3294
+ const GITIGNORE_BLOCK_START = "# managed by holocron setup — skills";
3295
+ const GITIGNORE_BLOCK_END = "# end managed by holocron setup — skills";
3296
+ async function installSkills({ agent, skills, repoRoot }) {
3297
+ const symlinkFn = AGENT_SYMLINK_PATHS[agent];
3298
+ if (!symlinkFn) return `agent "${agent}" has no known skill install path — skipping`;
3299
+ const require = createRequire(pathToFileURL(join(repoRoot, "package.json")));
3300
+ let skillsRoot;
3301
+ try {
3302
+ skillsRoot = dirname(require.resolve("@theholocron/skills/package.json"));
3303
+ } catch {
3304
+ throw new Error("@theholocron/skills not found — run: pnpm add -D @theholocron/skills");
3305
+ }
3306
+ const gitignorePath = join(repoRoot, ".gitignore");
3307
+ const existingContent = await readFile(gitignorePath, "utf8").catch(() => "");
3308
+ const previouslyInstalled = parsePreviousSkills(existingContent, symlinkFn);
3309
+ const currentSet = new Set(skills);
3310
+ const stale = previouslyInstalled.filter((n) => !currentSet.has(n));
3311
+ for (const name of stale) {
3312
+ await rm(join(repoRoot, symlinkFn(name)), { force: true }).catch(() => void 0);
3313
+ await rm(join(repoRoot, AGENTS_SKILLS_ROOT, name), {
3314
+ recursive: true,
3315
+ force: true
3316
+ }).catch(() => void 0);
3317
+ }
3318
+ const installed = [];
3319
+ const missing = [];
3320
+ for (const name of skills) {
3321
+ const srcDir = join(skillsRoot, "skills", name);
3322
+ try {
3323
+ await stat(srcDir);
3324
+ } catch {
3325
+ missing.push(name);
3326
+ continue;
3327
+ }
3328
+ const agentsDir = join(repoRoot, AGENTS_SKILLS_ROOT, name);
3329
+ await copyDirRecursive(srcDir, agentsDir);
3330
+ const symlinkPath = join(repoRoot, symlinkFn(name));
3331
+ await mkdir(dirname(symlinkPath), { recursive: true });
3332
+ try {
3333
+ await unlink(symlinkPath);
3334
+ } catch {}
3335
+ await symlink(relative(dirname(symlinkPath), agentsDir).replace(/\\/g, "/"), symlinkPath);
3336
+ installed.push(name);
3337
+ }
3338
+ const externalFailed = [];
3339
+ const externalStale = [];
3340
+ if (missing.length > 0) {
3341
+ let lock = null;
3342
+ try {
3343
+ lock = JSON.parse(await readFile(join(skillsRoot, "skills-lock.json"), "utf8"));
3344
+ } catch {}
3345
+ if (lock?.skills) for (const name of [...missing]) {
3346
+ const entry = lock.skills[name];
3347
+ if (!entry) continue;
3348
+ try {
3349
+ const { content, stale: isStale } = await fetchExternalSkill(entry);
3350
+ const agentsDir = join(repoRoot, AGENTS_SKILLS_ROOT, name);
3351
+ await mkdir(agentsDir, { recursive: true });
3352
+ await writeFile(join(agentsDir, "SKILL.md"), content);
3353
+ const symlinkPath = join(repoRoot, symlinkFn(name));
3354
+ await mkdir(dirname(symlinkPath), { recursive: true });
3355
+ try {
3356
+ await unlink(symlinkPath);
3357
+ } catch {}
3358
+ await symlink(relative(dirname(symlinkPath), agentsDir).replace(/\\/g, "/"), symlinkPath);
3359
+ missing.splice(missing.indexOf(name), 1);
3360
+ installed.push(name);
3361
+ if (isStale) externalStale.push(name);
3362
+ } catch {
3363
+ missing.splice(missing.indexOf(name), 1);
3364
+ externalFailed.push(name);
3365
+ }
3366
+ }
3367
+ }
3368
+ if (installed.length > 0 || stale.length > 0 || missing.length > 0 || externalFailed.length > 0) await updateSkillsGitignore(gitignorePath, existingContent, [
3369
+ ...installed,
3370
+ ...missing,
3371
+ ...externalFailed
3372
+ ], symlinkFn);
3373
+ const parts = [`installed ${installed.length}`];
3374
+ if (stale.length > 0) parts.push(`pruned: ${stale.join(", ")}`);
3375
+ if (externalStale.length > 0) parts.push(`stale: ${externalStale.join(", ")} (run \`holocron skills update\` to refresh)`);
3376
+ if (externalFailed.length > 0) parts.push(`fetch failed: ${externalFailed.join(", ")}`);
3377
+ if (missing.length > 0) parts.push(`unknown: ${missing.join(", ")}`);
3378
+ return parts.join("; ");
3379
+ }
3380
+ /** Extract skill names from the previous gitignore block so stale dirs can be pruned. */
3381
+ function parsePreviousSkills(gitignoreContent, symlinkFn) {
3382
+ if (!gitignoreContent.includes(GITIGNORE_BLOCK_START)) return [];
3383
+ const startIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_START);
3384
+ const endIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_END, startIdx);
3385
+ const block = endIdx !== -1 ? gitignoreContent.slice(startIdx, endIdx) : gitignoreContent.slice(startIdx);
3386
+ const placeholder = "__placeholder__";
3387
+ const symlinkPrefix = `/${symlinkFn(placeholder)}`.replace(placeholder, "");
3388
+ return block.split("\n").filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
3389
+ }
3390
+ async function copyDirRecursive(src, dest) {
3391
+ await mkdir(dest, { recursive: true });
3392
+ const entries = await readdir(src, { withFileTypes: true });
3393
+ for (const entry of entries) {
3394
+ const srcPath = join(src, entry.name);
3395
+ const destPath = join(dest, entry.name);
3396
+ if (entry.isDirectory()) await copyDirRecursive(srcPath, destPath);
3397
+ else await copyFile(srcPath, destPath);
3398
+ }
3399
+ }
3400
+ async function updateSkillsGitignore(gitignorePath, existingContent, skills, symlinkFn) {
3401
+ const entries = [`/${AGENTS_SKILLS_ROOT}/`, ...skills.map((n) => `/${symlinkFn(n)}`)];
3402
+ const block = [
3403
+ GITIGNORE_BLOCK_START,
3404
+ ...entries,
3405
+ GITIGNORE_BLOCK_END
3406
+ ].join("\n");
3407
+ let content;
3408
+ if (existingContent.includes(GITIGNORE_BLOCK_START)) {
3409
+ const start = existingContent.indexOf(GITIGNORE_BLOCK_START);
3410
+ const end = existingContent.indexOf(GITIGNORE_BLOCK_END, start);
3411
+ const afterBlock = end !== -1 ? existingContent.slice(end + 40) : "\n";
3412
+ content = existingContent.slice(0, start) + block + afterBlock;
3413
+ } else content = (existingContent.trimEnd() ? existingContent.trimEnd() + "\n\n" : "") + block + "\n";
3414
+ await writeFile(gitignorePath, content, "utf8");
3415
+ }
3416
+ async function runStep(capability, step, dryRun, body) {
3417
+ if (dryRun) return {
3418
+ capability,
3419
+ step,
3420
+ status: "dry-run"
3421
+ };
3422
+ try {
3423
+ const note = await body();
3424
+ const result = {
3425
+ capability,
3426
+ step,
3427
+ status: "ok"
3428
+ };
3429
+ if (typeof note === "string") result.message = note;
3430
+ return result;
3431
+ } catch (err) {
3432
+ if (err instanceof ProviderApiError$1 && err.status === 403) {
3433
+ const reason = classify403(err);
3434
+ return {
3435
+ capability,
3436
+ step,
3437
+ status: "fail",
3438
+ message: err.message,
3439
+ reason
3440
+ };
3441
+ }
3442
+ return {
3443
+ capability,
3444
+ step,
3445
+ status: "fail",
3446
+ message: err instanceof Error ? err.message : String(err)
3447
+ };
3448
+ }
3449
+ }
3450
+ function classify403(err) {
3451
+ const detailText = typeof err.details === "string" ? err.details : typeof err.details === "object" && err.details !== null && "message" in err.details ? String(err.details.message) : "";
3452
+ const text = `${err.message} ${detailText}`.toLowerCase();
3453
+ if (text.includes("advanced security") || text.includes("not enabled for this repository") || text.includes("upgrade") || text.includes("not available on")) return "plan";
3454
+ return "permissions";
3455
+ }
3456
+ function formatStep(step) {
3457
+ const tag = step.reason === "permissions" ? " [permissions]" : step.reason === "plan" ? " [plan restriction]" : "";
3458
+ const detail = step.message ? style.dim(` (${step.message})`) : "";
3459
+ const label = `${step.step}${tag}${detail}`;
3460
+ if (step.status === "ok") return ` ${style.success(label)}`;
3461
+ if (step.status === "fail") return ` ${style.fail(label)}`;
3462
+ if (step.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3463
+ return ` ${style.dim(`· ${label}`)}`;
3464
+ }
3465
+ //#endregion
3466
+ //#region src/commands/skills.ts
3467
+ /**
3468
+ * `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
3469
+ *
3470
+ * Unlike `holocron setup`, this command is purely local (no GitHub token
3471
+ * required). It reads `agent` and `skills` from the config and installs
3472
+ * the listed skills into `.agents/skills/<name>/` with a symlink at the
3473
+ * agent-specific path (e.g. `.claude/skills/<name>` for Claude Code).
3474
+ *
3475
+ * `holocron skills remove [name]` and `holocron skills update [name]` delegate
3476
+ * to the corresponding `npx skills` subcommands from the upstream skills CLI.
3477
+ */
3478
+ const defaultExec = (cmd, args, opts) => {
3479
+ return { exitCode: spawnSync(cmd, args, {
3480
+ cwd: opts.cwd,
3481
+ stdio: "inherit"
3482
+ }).status ?? -1 };
3483
+ };
3484
+ async function runSkillsInstall(input) {
3485
+ const print = input.print ?? ((line) => console.log(line));
3486
+ const config = input.loaded.resolved;
3487
+ if (!config.agent || !config.skills?.length) {
3488
+ print("Nothing to install — set `agent` and `skills` in holocron.config.ts");
3489
+ return;
3490
+ }
3491
+ if (input.context.dryRun) {
3492
+ print(`Would install ${config.skills.length} skill(s) for agent: ${config.agent}`);
3493
+ for (const name of config.skills) print(` → would install: ${name}`);
3494
+ return;
3495
+ }
3496
+ print(`Installing ${config.skills.length} skill(s) for agent: ${config.agent}`);
3497
+ try {
3498
+ print(` → ${await installSkills({
3499
+ agent: config.agent,
3500
+ skills: config.skills,
3501
+ repoRoot: input.context.repoRoot
3502
+ })}`);
3503
+ } catch (err) {
3504
+ print(` ✗ ${err instanceof Error ? err.message : String(err)}`);
3505
+ }
3791
3506
  }
3792
- /**
3793
- * Fetch a single SKILL.md from its upstream GitHub source.
3794
- * Verifies the SHA-256 hash when `computedHash` is present in the lock entry.
3795
- */
3796
- async function fetchExternalSkill(entry) {
3797
- if (entry.sourceType !== "github") throw new Error(`unsupported sourceType: ${entry.sourceType}`);
3798
- const url = `https://raw.githubusercontent.com/${entry.source}/HEAD/${entry.skillPath}`;
3799
- const res = await fetch(url);
3800
- if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
3801
- const content = await res.text();
3802
- return {
3803
- content,
3804
- stale: !!entry.computedHash && createHash("sha256").update(content).digest("hex") !== entry.computedHash
3805
- };
3507
+ function runSkillsRemove(input) {
3508
+ const { dryRun, repoRoot } = input.context;
3509
+ const exec = input.exec ?? defaultExec;
3510
+ const args = [
3511
+ "skills",
3512
+ "remove",
3513
+ ...input.names ?? []
3514
+ ];
3515
+ if (dryRun) {
3516
+ console.log(`Would run: npx ${args.join(" ")}`);
3517
+ return { status: "dry-run" };
3518
+ }
3519
+ const { exitCode } = exec("npx", args, { cwd: repoRoot });
3520
+ return { status: exitCode === 0 ? "ok" : "fail" };
3806
3521
  }
3807
- const AGENTS_SKILLS_ROOT = ".agents/skills";
3808
- /** Relative path of the agent-specific symlink. undefined = unsupported agent. */
3809
- const AGENT_SYMLINK_PATHS = { claude: (name) => `.claude/skills/${name}` };
3810
- const GITIGNORE_BLOCK_START = "# managed by holocron setup — skills";
3811
- const GITIGNORE_BLOCK_END = "# end managed by holocron setup — skills";
3812
- async function installSkills({ agent, skills, repoRoot }) {
3813
- const symlinkFn = AGENT_SYMLINK_PATHS[agent];
3814
- if (!symlinkFn) return `agent "${agent}" has no known skill install path — skipping`;
3815
- const require = createRequire(pathToFileURL(join(repoRoot, "package.json")));
3816
- let skillsRoot;
3817
- try {
3818
- skillsRoot = dirname(require.resolve("@theholocron/skills/package.json"));
3819
- } catch {
3820
- throw new Error("@theholocron/skills not found — run: pnpm add -D @theholocron/skills");
3522
+ function runSkillsUpdate(input) {
3523
+ const { dryRun, repoRoot } = input.context;
3524
+ const exec = input.exec ?? defaultExec;
3525
+ const args = [
3526
+ "skills",
3527
+ "update",
3528
+ ...input.name ? [input.name] : []
3529
+ ];
3530
+ if (dryRun) {
3531
+ console.log(`Would run: npx ${args.join(" ")}`);
3532
+ return { status: "dry-run" };
3821
3533
  }
3822
- const gitignorePath = join(repoRoot, ".gitignore");
3823
- const existingContent = await readFile(gitignorePath, "utf8").catch(() => "");
3824
- const previouslyInstalled = parsePreviousSkills(existingContent, symlinkFn);
3825
- const currentSet = new Set(skills);
3826
- const stale = previouslyInstalled.filter((n) => !currentSet.has(n));
3827
- for (const name of stale) {
3828
- await rm(join(repoRoot, symlinkFn(name)), { force: true }).catch(() => void 0);
3829
- await rm(join(repoRoot, AGENTS_SKILLS_ROOT, name), {
3830
- recursive: true,
3831
- force: true
3832
- }).catch(() => void 0);
3534
+ const { exitCode } = exec("npx", args, { cwd: repoRoot });
3535
+ return { status: exitCode === 0 ? "ok" : "fail" };
3536
+ }
3537
+ //#endregion
3538
+ //#region src/commands/sync.ts
3539
+ const SYNC_STEPS = [
3540
+ "labels",
3541
+ "properties",
3542
+ "teams",
3543
+ "topics",
3544
+ "keywords",
3545
+ "description"
3546
+ ];
3547
+ const LOCAL_STEPS = /* @__PURE__ */ new Set(["keywords", "description"]);
3548
+ async function runSync(input) {
3549
+ const print = input.print ?? ((line) => console.log(line));
3550
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3551
+ const config = input.loaded.resolved;
3552
+ const dryRun = input.context.dryRun ?? false;
3553
+ const requestedSteps = input.steps;
3554
+ const steps = [];
3555
+ if (!requestedSteps || requestedSteps.some((s) => !LOCAL_STEPS.has(s))) await loader.load();
3556
+ else try {
3557
+ await loader.load();
3558
+ } catch (err) {
3559
+ if (!(err instanceof AuthError)) throw err;
3833
3560
  }
3834
- const installed = [];
3835
- const missing = [];
3836
- for (const name of skills) {
3837
- const srcDir = join(skillsRoot, "skills", name);
3838
- try {
3839
- await stat(srcDir);
3840
- } catch {
3841
- missing.push(name);
3842
- continue;
3561
+ print(`Holocron sync ${config.name}${dryRun ? " (dry-run)" : ""}`);
3562
+ print(` config: ${input.loaded.filepath}`);
3563
+ print("");
3564
+ if (loader.has("source")) {
3565
+ const source = loader.get("source");
3566
+ print(" → source");
3567
+ for (const stepName of SYNC_STEPS) {
3568
+ if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
3569
+ if (LOCAL_STEPS.has(stepName)) continue;
3570
+ if (stepName === "labels") if (source.syncLabels) {
3571
+ steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
3572
+ print(formatSyncStep(steps[steps.length - 1]));
3573
+ } else {
3574
+ steps.push({
3575
+ capability: "source",
3576
+ step: "sync labels",
3577
+ status: "skip",
3578
+ message: "provider does not implement syncLabels"
3579
+ });
3580
+ print(formatSyncStep(steps[steps.length - 1]));
3581
+ }
3582
+ if (stepName === "properties") if (source.syncProperties) {
3583
+ const repo = config.repo;
3584
+ const properties = {};
3585
+ const effectivePreset = repo?.protection;
3586
+ if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
3587
+ const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
3588
+ properties["monorepo"] = String(isMonorepo);
3589
+ const manual = repo?.properties ?? {};
3590
+ if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
3591
+ if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
3592
+ if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
3593
+ if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
3594
+ steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
3595
+ print(formatSyncStep(steps[steps.length - 1]));
3596
+ } else {
3597
+ steps.push({
3598
+ capability: "source",
3599
+ step: "sync properties",
3600
+ status: "skip",
3601
+ message: "provider does not implement syncProperties"
3602
+ });
3603
+ print(formatSyncStep(steps[steps.length - 1]));
3604
+ }
3605
+ if (stepName === "teams") {
3606
+ const teams = config.repo?.teams ?? [];
3607
+ if (teams.length === 0) {
3608
+ steps.push({
3609
+ capability: "source",
3610
+ step: "sync teams",
3611
+ status: "skip",
3612
+ message: "no teams configured"
3613
+ });
3614
+ print(formatSyncStep(steps[steps.length - 1]));
3615
+ } else if (source.syncTeams) {
3616
+ steps.push(await runSyncStep("source", "sync teams", dryRun, () => source.syncTeams(teams)));
3617
+ print(formatSyncStep(steps[steps.length - 1]));
3618
+ const repoCoord = input.context.repo ?? config.repo?.name ?? "";
3619
+ const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
3620
+ const writeableTeams = teams.map((t) => typeof t === "string" ? {
3621
+ slug: t,
3622
+ permission: "push"
3623
+ } : t).filter((t) => [
3624
+ "push",
3625
+ "maintain",
3626
+ "admin"
3627
+ ].includes(t.permission));
3628
+ if (org && writeableTeams.length > 0) {
3629
+ steps.push(await runSyncStep("source", "write .github/CODEOWNERS", dryRun, async () => {
3630
+ const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
3631
+ await source.writeRepoFile(".github/CODEOWNERS", content);
3632
+ }));
3633
+ print(formatSyncStep(steps[steps.length - 1]));
3634
+ }
3635
+ } else {
3636
+ steps.push({
3637
+ capability: "source",
3638
+ step: "sync teams",
3639
+ status: "skip",
3640
+ message: "provider does not implement syncTeams"
3641
+ });
3642
+ print(formatSyncStep(steps[steps.length - 1]));
3643
+ }
3644
+ }
3645
+ if (stepName === "topics") {
3646
+ const topics = config.repo?.topics ?? [];
3647
+ if (topics.length === 0) {
3648
+ steps.push({
3649
+ capability: "source",
3650
+ step: "sync topics",
3651
+ status: "skip",
3652
+ message: "no topics configured"
3653
+ });
3654
+ print(formatSyncStep(steps[steps.length - 1]));
3655
+ } else if (source.syncTopics) {
3656
+ steps.push(await runSyncStep("source", "sync topics", dryRun, () => source.syncTopics(topics)));
3657
+ print(formatSyncStep(steps[steps.length - 1]));
3658
+ } else {
3659
+ steps.push({
3660
+ capability: "source",
3661
+ step: "sync topics",
3662
+ status: "skip",
3663
+ message: "provider does not implement syncTopics"
3664
+ });
3665
+ print(formatSyncStep(steps[steps.length - 1]));
3666
+ }
3667
+ }
3843
3668
  }
3844
- const agentsDir = join(repoRoot, AGENTS_SKILLS_ROOT, name);
3845
- await copyDirRecursive(srcDir, agentsDir);
3846
- const symlinkPath = join(repoRoot, symlinkFn(name));
3847
- await mkdir(dirname(symlinkPath), { recursive: true });
3848
- try {
3849
- await unlink(symlinkPath);
3850
- } catch {}
3851
- await symlink(relative(dirname(symlinkPath), agentsDir).replace(/\\/g, "/"), symlinkPath);
3852
- installed.push(name);
3853
- }
3854
- const externalFailed = [];
3855
- const externalStale = [];
3856
- if (missing.length > 0) {
3857
- let lock = null;
3858
- try {
3859
- lock = JSON.parse(await readFile(join(skillsRoot, "skills-lock.json"), "utf8"));
3860
- } catch {}
3861
- if (lock?.skills) for (const name of [...missing]) {
3862
- const entry = lock.skills[name];
3863
- if (!entry) continue;
3864
- try {
3865
- const { content, stale: isStale } = await fetchExternalSkill(entry);
3866
- const agentsDir = join(repoRoot, AGENTS_SKILLS_ROOT, name);
3867
- await mkdir(agentsDir, { recursive: true });
3868
- await writeFile(join(agentsDir, "SKILL.md"), content);
3869
- const symlinkPath = join(repoRoot, symlinkFn(name));
3870
- await mkdir(dirname(symlinkPath), { recursive: true });
3871
- try {
3872
- await unlink(symlinkPath);
3873
- } catch {}
3874
- await symlink(relative(dirname(symlinkPath), agentsDir).replace(/\\/g, "/"), symlinkPath);
3875
- missing.splice(missing.indexOf(name), 1);
3876
- installed.push(name);
3877
- if (isStale) externalStale.push(name);
3878
- } catch {
3879
- missing.splice(missing.indexOf(name), 1);
3880
- externalFailed.push(name);
3669
+ if (requestedSteps) {
3670
+ for (const name of requestedSteps) if (!SYNC_STEPS.includes(name)) {
3671
+ steps.push({
3672
+ capability: "source",
3673
+ step: `sync ${name}`,
3674
+ status: "skip",
3675
+ message: `unknown step "${name}"`
3676
+ });
3677
+ print(formatSyncStep(steps[steps.length - 1]));
3881
3678
  }
3882
3679
  }
3883
3680
  }
3884
- if (installed.length > 0 || stale.length > 0 || missing.length > 0 || externalFailed.length > 0) await updateSkillsGitignore(gitignorePath, existingContent, [
3885
- ...installed,
3886
- ...missing,
3887
- ...externalFailed
3888
- ], symlinkFn);
3889
- const parts = [`installed ${installed.length}`];
3890
- if (stale.length > 0) parts.push(`pruned: ${stale.join(", ")}`);
3891
- if (externalStale.length > 0) parts.push(`stale: ${externalStale.join(", ")} (run \`holocron skills update\` to refresh)`);
3892
- if (externalFailed.length > 0) parts.push(`fetch failed: ${externalFailed.join(", ")}`);
3893
- if (missing.length > 0) parts.push(`unknown: ${missing.join(", ")}`);
3894
- return parts.join("; ");
3895
- }
3896
- /** Extract skill names from the previous gitignore block so stale dirs can be pruned. */
3897
- function parsePreviousSkills(gitignoreContent, symlinkFn) {
3898
- if (!gitignoreContent.includes(GITIGNORE_BLOCK_START)) return [];
3899
- const startIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_START);
3900
- const endIdx = gitignoreContent.indexOf(GITIGNORE_BLOCK_END, startIdx);
3901
- const block = endIdx !== -1 ? gitignoreContent.slice(startIdx, endIdx) : gitignoreContent.slice(startIdx);
3902
- const placeholder = "__placeholder__";
3903
- const symlinkPrefix = `/${symlinkFn(placeholder)}`.replace(placeholder, "");
3904
- return block.split("\n").filter((line) => line.startsWith(symlinkPrefix)).map((line) => line.slice(symlinkPrefix.length));
3905
- }
3906
- async function copyDirRecursive(src, dest) {
3907
- await mkdir(dest, { recursive: true });
3908
- const entries = await readdir(src, { withFileTypes: true });
3909
- for (const entry of entries) {
3910
- const srcPath = join(src, entry.name);
3911
- const destPath = join(dest, entry.name);
3912
- if (entry.isDirectory()) await copyDirRecursive(srcPath, destPath);
3913
- else await copyFile(srcPath, destPath);
3681
+ for (const stepName of ["keywords", "description"]) {
3682
+ if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
3683
+ if (stepName === "keywords") {
3684
+ const topics = config.repo?.topics ?? [];
3685
+ if (topics.length === 0) {
3686
+ steps.push({
3687
+ capability: "local",
3688
+ step: "sync keywords",
3689
+ status: "skip",
3690
+ message: "no topics configured"
3691
+ });
3692
+ print(formatSyncStep(steps[steps.length - 1]));
3693
+ } else {
3694
+ steps.push(await runSyncStep("local", "sync keywords", dryRun, async () => {
3695
+ return await writePackageJsonField(input.context.repoRoot, "keywords", topics) ? `${topics.length} keywords written` : `${topics.length} topics (no package.json)`;
3696
+ }));
3697
+ print(formatSyncStep(steps[steps.length - 1]));
3698
+ }
3699
+ }
3700
+ if (stepName === "description") {
3701
+ const description = config.description;
3702
+ if (!description) {
3703
+ steps.push({
3704
+ capability: "local",
3705
+ step: "sync description",
3706
+ status: "skip",
3707
+ message: "no description configured"
3708
+ });
3709
+ print(formatSyncStep(steps[steps.length - 1]));
3710
+ } else {
3711
+ const source = loader.has("source") ? loader.get("source") : null;
3712
+ steps.push(await runSyncStep("local", "sync description", dryRun, async () => {
3713
+ const pkgWrote = await writePackageJsonField(input.context.repoRoot, "description", description);
3714
+ const readmeWrote = await updateReadmeDescription(input.context.repoRoot, description);
3715
+ if (source?.syncDescription) await source.syncDescription(description);
3716
+ const parts = [];
3717
+ if (pkgWrote) parts.push("package.json");
3718
+ if (readmeWrote) parts.push("README.md");
3719
+ if (source?.syncDescription) parts.push("GitHub");
3720
+ return parts.length > 0 ? parts.join(", ") + " updated" : "description synced";
3721
+ }));
3722
+ print(formatSyncStep(steps[steps.length - 1]));
3723
+ }
3724
+ }
3914
3725
  }
3726
+ const summary = steps.reduce((acc, s) => {
3727
+ if (s.status === "ok") acc.ok += 1;
3728
+ else if (s.status === "fail") acc.fail += 1;
3729
+ else if (s.status === "skip") acc.skip += 1;
3730
+ else if (s.status === "dry-run") acc.dryRun += 1;
3731
+ return acc;
3732
+ }, {
3733
+ ok: 0,
3734
+ fail: 0,
3735
+ skip: 0,
3736
+ dryRun: 0
3737
+ });
3738
+ print("");
3739
+ print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
3740
+ return {
3741
+ steps,
3742
+ summary
3743
+ };
3915
3744
  }
3916
- async function updateSkillsGitignore(gitignorePath, existingContent, skills, symlinkFn) {
3917
- const entries = [`/${AGENTS_SKILLS_ROOT}/`, ...skills.map((n) => `/${symlinkFn(n)}`)];
3918
- const block = [
3919
- GITIGNORE_BLOCK_START,
3920
- ...entries,
3921
- GITIGNORE_BLOCK_END
3922
- ].join("\n");
3923
- let content;
3924
- if (existingContent.includes(GITIGNORE_BLOCK_START)) {
3925
- const start = existingContent.indexOf(GITIGNORE_BLOCK_START);
3926
- const end = existingContent.indexOf(GITIGNORE_BLOCK_END, start);
3927
- const afterBlock = end !== -1 ? existingContent.slice(end + 40) : "\n";
3928
- content = existingContent.slice(0, start) + block + afterBlock;
3929
- } else content = (existingContent.trimEnd() ? existingContent.trimEnd() + "\n\n" : "") + block + "\n";
3930
- await writeFile(gitignorePath, content, "utf8");
3931
- }
3932
- async function runStep(capability, step, dryRun, body) {
3745
+ async function runSyncStep(capability, step, dryRun, body) {
3933
3746
  if (dryRun) return {
3934
3747
  capability,
3935
3748
  step,
@@ -3945,16 +3758,6 @@ async function runStep(capability, step, dryRun, body) {
3945
3758
  if (typeof note === "string") result.message = note;
3946
3759
  return result;
3947
3760
  } catch (err) {
3948
- if (err instanceof ProviderApiError$1 && err.status === 403) {
3949
- const reason = classify403(err);
3950
- return {
3951
- capability,
3952
- step,
3953
- status: "fail",
3954
- message: err.message,
3955
- reason
3956
- };
3957
- }
3958
3761
  return {
3959
3762
  capability,
3960
3763
  step,
@@ -3963,400 +3766,597 @@ async function runStep(capability, step, dryRun, body) {
3963
3766
  };
3964
3767
  }
3965
3768
  }
3966
- function classify403(err) {
3967
- const detailText = typeof err.details === "string" ? err.details : typeof err.details === "object" && err.details !== null && "message" in err.details ? String(err.details.message) : "";
3968
- const text = `${err.message} ${detailText}`.toLowerCase();
3969
- if (text.includes("advanced security") || text.includes("not enabled for this repository") || text.includes("upgrade") || text.includes("not available on")) return "plan";
3970
- return "permissions";
3971
- }
3972
- function formatStep(step) {
3973
- const tag = step.reason === "permissions" ? " [permissions]" : step.reason === "plan" ? " [plan restriction]" : "";
3974
- const detail = step.message ? style.dim(` (${step.message})`) : "";
3975
- const label = `${step.step}${tag}${detail}`;
3976
- if (step.status === "ok") return ` ${style.success(label)}`;
3977
- if (step.status === "fail") return ` ${style.fail(label)}`;
3978
- if (step.status === "dry-run") return ` ${style.dim(`… ${label}`)}`;
3979
- return ` ${style.dim(`· ${label}`)}`;
3769
+ function formatSyncStep(step) {
3770
+ const icon = step.status === "ok" ? "✓" : step.status === "fail" ? "✗" : step.status === "dry-run" ? "…" : "·";
3771
+ const detail = step.message ? ` (${step.message})` : "";
3772
+ return ` ${icon} ${step.step}${detail}`;
3980
3773
  }
3981
- //#endregion
3982
- //#region src/commands/skills.ts
3983
- /**
3984
- * `holocron skills` — install, remove, and update agent skills from @theholocron/skills.
3985
- *
3986
- * Unlike `holocron setup`, this command is purely local (no GitHub token
3987
- * required). It reads `agent` and `skills` from the config and installs
3988
- * the listed skills into `.agents/skills/<name>/` with a symlink at the
3989
- * agent-specific path (e.g. `.claude/skills/<name>` for Claude Code).
3990
- *
3991
- * `holocron skills remove [name]` and `holocron skills update [name]` delegate
3992
- * to the corresponding `npx skills` subcommands from the upstream skills CLI.
3993
- */
3994
- const defaultExec = (cmd, args, opts) => {
3995
- return { exitCode: spawnSync(cmd, args, {
3996
- cwd: opts.cwd,
3997
- stdio: "inherit"
3998
- }).status ?? -1 };
3999
- };
4000
- async function runSkillsInstall(input) {
4001
- const print = input.print ?? ((line) => console.log(line));
4002
- const config = input.loaded.resolved;
4003
- if (!config.agent || !config.skills?.length) {
4004
- print("Nothing to install — set `agent` and `skills` in holocron.config.ts");
4005
- return;
4006
- }
4007
- if (input.context.dryRun) {
4008
- print(`Would install ${config.skills.length} skill(s) for agent: ${config.agent}`);
4009
- for (const name of config.skills) print(` → would install: ${name}`);
4010
- return;
4011
- }
4012
- print(`Installing ${config.skills.length} skill(s) for agent: ${config.agent}`);
3774
+ async function writePackageJsonField(repoRoot, field, value) {
3775
+ const pkgPath = join(repoRoot, "package.json");
3776
+ let content;
4013
3777
  try {
4014
- print(` → ${await installSkills({
4015
- agent: config.agent,
4016
- skills: config.skills,
4017
- repoRoot: input.context.repoRoot
4018
- })}`);
4019
- } catch (err) {
4020
- print(` ✗ ${err instanceof Error ? err.message : String(err)}`);
3778
+ content = await readFile(pkgPath, "utf8");
3779
+ } catch {
3780
+ return false;
4021
3781
  }
3782
+ const pkg = JSON.parse(content);
3783
+ pkg[field] = value;
3784
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
3785
+ return true;
4022
3786
  }
4023
- function runSkillsRemove(input) {
4024
- const { dryRun, repoRoot } = input.context;
4025
- const exec = input.exec ?? defaultExec;
4026
- const args = [
4027
- "skills",
4028
- "remove",
4029
- ...input.names ?? []
4030
- ];
4031
- if (dryRun) {
4032
- console.log(`Would run: npx ${args.join(" ")}`);
4033
- return { status: "dry-run" };
3787
+ const README_DESC_START = "<!-- holocron:description -->";
3788
+ const README_DESC_END = "<!-- /holocron:description -->";
3789
+ async function updateReadmeDescription(repoRoot, description) {
3790
+ const readmePath = join(repoRoot, "README.md");
3791
+ let content;
3792
+ try {
3793
+ content = await readFile(readmePath, "utf8");
3794
+ } catch {
3795
+ return false;
4034
3796
  }
4035
- const { exitCode } = exec("npx", args, { cwd: repoRoot });
4036
- return { status: exitCode === 0 ? "ok" : "fail" };
4037
- }
4038
- function runSkillsUpdate(input) {
4039
- const { dryRun, repoRoot } = input.context;
4040
- const exec = input.exec ?? defaultExec;
4041
- const args = [
4042
- "skills",
4043
- "update",
4044
- ...input.name ? [input.name] : []
4045
- ];
4046
- if (dryRun) {
4047
- console.log(`Would run: npx ${args.join(" ")}`);
4048
- return { status: "dry-run" };
3797
+ const lines = content.split("\n");
3798
+ const startIdx = lines.findIndex((l) => l.trim() === README_DESC_START);
3799
+ const endIdx = lines.findIndex((l) => l.trim() === README_DESC_END);
3800
+ if (startIdx !== -1) {
3801
+ if (endIdx === -1 || endIdx <= startIdx) return false;
3802
+ lines.splice(startIdx + 1, endIdx - startIdx - 1, description);
3803
+ await writeFile(readmePath, lines.join("\n"), "utf8");
3804
+ return true;
4049
3805
  }
4050
- const { exitCode } = exec("npx", args, { cwd: repoRoot });
4051
- return { status: exitCode === 0 ? "ok" : "fail" };
4052
- }
4053
- //#endregion
4054
- //#region src/env.ts
4055
- function createEnvLookup(source = process.env) {
4056
- return {
4057
- get(key) {
4058
- return source[key] || void 0;
4059
- },
4060
- first(...keys) {
4061
- for (const key of keys) {
4062
- const val = source[key];
4063
- if (val) return val;
4064
- }
4065
- }
4066
- };
3806
+ const h1Index = lines.findIndex((l) => /^# /.test(l));
3807
+ if (h1Index === -1) return false;
3808
+ lines.splice(h1Index + 1, 0, "", README_DESC_START, description, README_DESC_END);
3809
+ await writeFile(readmePath, lines.join("\n"), "utf8");
3810
+ return true;
4067
3811
  }
4068
3812
  //#endregion
4069
- //#region src/auth-resolver.ts
3813
+ //#region src/templates/actions/install.yml
3814
+ 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";
3815
+ //#endregion
3816
+ //#region src/templates/actions/setup.yml
3817
+ 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";
3818
+ //#endregion
3819
+ //#region src/templates/actions/setup-node.yml
3820
+ 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";
3821
+ //#endregion
3822
+ //#region src/templates/workflows/audit.yml
3823
+ var audit_default = "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 run-knip:\n description: Run Knip to detect unused files, exports, and dependencies\n type: boolean\n required: false\n default: false\n knip-script:\n description: Script that invokes Knip (must exit non-zero on findings)\n type: string\n required: false\n default: pnpm run audit\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\n knip:\n name: Knip\n if: ${{ inputs.run-knip }}\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 with:\n persist-credentials: false\n\n - uses: theholocron/.github/.github/actions/setup@main\n name: Setup\n\n - run: eval \"$KNIP_SCRIPT\"\n name: Run Knip\n env:\n KNIP_SCRIPT: ${{ inputs.knip-script }}\n";
3824
+ //#endregion
3825
+ //#region src/templates/workflows/bookkeeping.yml
3826
+ var bookkeeping_default = "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";
3827
+ //#endregion
3828
+ //#region src/templates/workflows/codeql.yml
3829
+ var codeql_default = "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";
3830
+ //#endregion
3831
+ //#region src/templates/workflows/dependencies.yml
3832
+ var dependencies_default = "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";
3833
+ //#endregion
3834
+ //#region src/templates/workflows/greetings.yml
3835
+ var greetings_default = "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";
3836
+ //#endregion
3837
+ //#region src/templates/workflows/lint.yml
3838
+ var lint_default = "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";
3839
+ //#endregion
3840
+ //#region src/templates/workflows/release.yml
3841
+ var release_default = "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 HOLOCRON_RELEASE_TOKEN:\n description: >\n Fine-grained PAT (Contents + Issues + Pull requests: write) owned by\n an admin. Required when the default branch is protected by a ruleset —\n github.token cannot push through rulesets, but an admin PAT can.\n Takes priority over HOLOCRON_SYNC_TOKEN. Falls back to github.token.\n required: false\n HOLOCRON_SYNC_TOKEN:\n description: >\n Legacy alias for HOLOCRON_RELEASE_TOKEN — kept for backward compatibility.\n Prefer HOLOCRON_RELEASE_TOKEN for new repos.\n required: false\n HOLOCRON_READ_TOKEN:\n description: >\n Fine-grained PAT for read-only GitHub API calls (e.g. resolving git\n committer identity via `gh api user`). Falls back to github.token.\n required: false\n GH_TOKEN:\n description: >\n Generic GitHub token fallback for `gh` CLI calls. Used when\n HOLOCRON_READ_TOKEN is not set.\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 HOLOCRON_RELEASE_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.HOLOCRON_RELEASE_TOKEN || secrets.HOLOCRON_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.HOLOCRON_READ_TOKEN || secrets.GH_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 HOLOCRON_RELEASE_TOKEN (fine-grained PAT, Contents+Issues+PRs write,\n # owned by an admin with ruleset bypass) so @semantic-release/git can\n # push the version-bump commit through branch protection. Falls back to\n # HOLOCRON_SYNC_TOKEN (legacy) then github.token for unprotected repos.\n GITHUB_TOKEN: ${{ secrets.HOLOCRON_RELEASE_TOKEN || secrets.HOLOCRON_SYNC_TOKEN || github.token }}\n NPM_CONFIG_PROVENANCE: true\n";
3842
+ //#endregion
3843
+ //#region src/templates/workflows/review.yml
3844
+ var review_default = "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 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-annotations\n tool_name: \"Review / gitleaks\"\n gitleaks_flags: --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}\n\n - name: YamlLint\n if: ${{ hashFiles('yamllint.config.yml') != '' }}\n uses: reviewdog/action-yamllint@b5f7217d8c815ae374d1d55840d5e569d82f01f0 # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / yamllint\"\n yamllint_flags: -c ${{ github.workspace }}/yamllint.config.yml ${{ github.workspace }}\n\n - name: ActionLint (GitHub Actions)\n if: ${{ hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}\n uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / actionlint\"\n\n #\n # TypeScript / JavaScript\n #\n\n - name: ESLint\n if: steps.detect.outputs.eslint == 'true'\n uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / eslint\"\n eslint_flags: .\n\n - name: TypeScript\n if: steps.detect.outputs.tsconfig == 'true'\n uses: EPMatt/reviewdog-action-tsc@63d923a3c5b4497671940b8874f58a404e2351b5 # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / tsc\"\n\n #\n # Shell\n #\n\n - name: ShellCheck\n if: steps.detect.outputs.shell == 'true'\n uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / shellcheck\"\n fail_level: none\n\n #\n # Docker\n #\n\n - name: Hadolint\n if: steps.detect.outputs.docker == 'true'\n uses: reviewdog/action-hadolint@1b2cfa6ba72072ad35158d7ff3aa49bbdc03506d # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / hadolint\"\n fail_level: none\n\n #\n # Environment files\n #\n\n - name: dotenv-linter\n if: steps.detect.outputs.dotenv == 'true'\n uses: dotenv-linter/action-dotenv-linter@afde61cfda2ecffe7bea35837b6f20b956c88689 # v3.0.0\n with:\n reporter: github-code-suggestions\n\n #\n # Documentation\n #\n\n - name: Alex (inclusive language)\n if: steps.detect.outputs.markdown == 'true'\n uses: reviewdog/action-alex@347481655add010a2ae302df34b57c9bcfa0d6e4 # v1\n with:\n reporter: github-pr-annotations\n tool_name: \"Review / alex\"\n";
3845
+ //#endregion
3846
+ //#region src/templates/workflows/stale.yml
3847
+ var stale_default = "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";
3848
+ //#endregion
3849
+ //#region src/templates/workflows/sync-github.yml
3850
+ var sync_github_default = "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# HOLOCRON_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 HOLOCRON_SYNC_TOKEN:\n required: true\n HOLOCRON_READ_TOKEN:\n description: >\n Fine-grained PAT for read-only GitHub API calls (e.g. resolving git\n committer identity via `gh api user`). Falls back to HOLOCRON_SYNC_TOKEN.\n required: false\n GH_TOKEN:\n description: >\n Generic GitHub token fallback for `gh` CLI calls. Used when neither\n HOLOCRON_READ_TOKEN nor HOLOCRON_SYNC_TOKEN is set.\n required: false\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 GH_TOKEN: ${{ secrets.HOLOCRON_READ_TOKEN || secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN }}\n HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_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 GH_TOKEN: ${{ secrets.HOLOCRON_READ_TOKEN || secrets.HOLOCRON_SYNC_TOKEN || secrets.GH_TOKEN }}\n HOLOCRON_SYNC_TOKEN: ${{ secrets.HOLOCRON_SYNC_TOKEN }}\n SECONDARY_REPOS: ${{ inputs.secondary-repos }}\n SYNC_BRANCH: ${{ inputs.sync-branch }}\n";
3851
+ //#endregion
3852
+ //#region src/templates/workflows/test.yml
3853
+ var test_default = "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";
3854
+ //#endregion
3855
+ //#region src/templates/workflows/typecheck.yml
3856
+ var typecheck_default = "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";
3857
+ //#endregion
3858
+ //#region src/templates/index.ts
4070
3859
  /**
4071
- * Build a strict, single-feature token resolver.
3860
+ * All reusable workflow and composite action content bundled as string
3861
+ * constants so the CLI can push them to theholocron/.github without
3862
+ * needing filesystem access at runtime.
4072
3863
  *
4073
- * Resolution chain: `--token flag envName env var keyring(keyringKey)`.
4074
- * No broad-token fallback if the feature-specific token is absent the
4075
- * operation fails with a message naming the exact env var to set.
3864
+ * Content lives in standalone .yml files; the rawYml rollup plugin
3865
+ * inlines them as string exports at build time.
4076
3866
  */
4077
- function createFeatureResolver(config) {
4078
- return function resolveFeatureToken(input = {}) {
4079
- const env = createEnvLookup(input.env);
4080
- const keyring = input.keyring ?? getToken;
4081
- const token = input.cliToken || env.get(config.envName) || keyring(config.keyringKey);
4082
- if (!token) throw new AuthError(`no GitHub token found for this operation. Pass --token <PAT>, set ${config.envName}, or run: holocron auth set ${config.keyringKey} <PAT>`);
4083
- return token;
4084
- };
4085
- }
3867
+ const ACTIONS = {
3868
+ "setup/action": setup_default,
3869
+ "install/action": install_default,
3870
+ "setup-node/action": setup_node_default
3871
+ };
3872
+ const REUSABLE_WORKFLOWS = {
3873
+ audit: audit_default,
3874
+ bookkeeping: bookkeeping_default,
3875
+ codeql: codeql_default,
3876
+ dependencies: dependencies_default,
3877
+ greetings: greetings_default,
3878
+ lint: lint_default,
3879
+ release: release_default,
3880
+ review: review_default,
3881
+ stale: stale_default,
3882
+ "sync-github": sync_github_default,
3883
+ test: test_default,
3884
+ typecheck: typecheck_default
3885
+ };
3886
+ const WORKFLOW_TEMPLATE_PROPERTIES = {
3887
+ bookkeeping: JSON.stringify({
3888
+ name: "Bookkeeping",
3889
+ description: "Label and track issues and pull requests.",
3890
+ iconName: "octicon tag"
3891
+ }, null, 2),
3892
+ "sync-github": JSON.stringify({
3893
+ name: "Sync GitHub Templates",
3894
+ description: "Sync workflow templates and composite actions from the holocron CLI.",
3895
+ iconName: "octicon sync"
3896
+ }, null, 2)
3897
+ };
4086
3898
  //#endregion
4087
- //#region src/commands/sync.ts
4088
- const SYNC_STEPS = [
4089
- "labels",
4090
- "properties",
4091
- "teams",
4092
- "topics",
4093
- "keywords",
4094
- "description"
4095
- ];
4096
- const LOCAL_STEPS = /* @__PURE__ */ new Set(["keywords", "description"]);
4097
- async function runSync(input) {
4098
- const print = input.print ?? ((line) => console.log(line));
4099
- const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
4100
- const config = input.loaded.resolved;
4101
- const dryRun = input.context.dryRun ?? false;
4102
- const requestedSteps = input.steps;
4103
- const steps = [];
4104
- if (!requestedSteps || requestedSteps.some((s) => !LOCAL_STEPS.has(s))) await loader.load();
4105
- else try {
4106
- await loader.load();
4107
- } catch (err) {
4108
- if (!(err instanceof AuthError)) throw err;
3899
+ //#region src/commands/sync-github.ts
3900
+ const DEFAULT_REPO = "theholocron/.github";
3901
+ /**
3902
+ * Extracts the `workflows` array from a `holocron.config.ts` source string.
3903
+ * Handles both plain string entries and `{ name, with }` object entries.
3904
+ * Falls back to an empty array if the array cannot be found or parsed.
3905
+ */
3906
+ function parseWorkflowsFromTs(source) {
3907
+ const keyMatch = source.match(/\bworkflows\s*:\s*\[/);
3908
+ if (!keyMatch) return [];
3909
+ const start = keyMatch.index + keyMatch[0].length;
3910
+ let depth = 1;
3911
+ let i = start;
3912
+ while (i < source.length && depth > 0) {
3913
+ if (source[i] === "[") depth++;
3914
+ else if (source[i] === "]") depth--;
3915
+ i++;
4109
3916
  }
4110
- print(`Holocron sync ${config.name}${dryRun ? " (dry-run)" : ""}`);
4111
- print(` config: ${input.loaded.filepath}`);
4112
- print("");
4113
- if (loader.has("source")) {
4114
- const source = loader.get("source");
4115
- print(" → source");
4116
- for (const stepName of SYNC_STEPS) {
4117
- if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
4118
- if (LOCAL_STEPS.has(stepName)) continue;
4119
- if (stepName === "labels") if (source.syncLabels) {
4120
- steps.push(await runSyncStep("source", "sync labels", dryRun, () => source.syncLabels(CANONICAL_LABELS, STALE_LABELS)));
4121
- print(formatSyncStep(steps[steps.length - 1]));
4122
- } else {
4123
- steps.push({
4124
- capability: "source",
4125
- step: "sync labels",
4126
- status: "skip",
4127
- message: "provider does not implement syncLabels"
4128
- });
4129
- print(formatSyncStep(steps[steps.length - 1]));
4130
- }
4131
- if (stepName === "properties") if (source.syncProperties) {
4132
- const repo = config.repo;
4133
- const properties = {};
4134
- const effectivePreset = repo?.protection;
4135
- if (effectivePreset && effectivePreset !== "none") properties["branch_protection_level"] = effectivePreset;
4136
- const isMonorepo = await access(join(input.context.repoRoot, "pnpm-workspace.yaml")).then(() => true).catch(() => false);
4137
- properties["monorepo"] = String(isMonorepo);
4138
- const manual = repo?.properties ?? {};
4139
- if (manual.lifecycle) properties["lifecycle"] = manual.lifecycle;
4140
- if (manual.open_source !== void 0) properties["open_source"] = String(manual.open_source);
4141
- if (manual.runtime_environment) properties["runtime_environment"] = manual.runtime_environment;
4142
- if (manual.uses_external_packages !== void 0) properties["uses_external_packages"] = String(manual.uses_external_packages);
4143
- steps.push(await runSyncStep("source", "sync properties", dryRun, () => source.syncProperties(properties)));
4144
- print(formatSyncStep(steps[steps.length - 1]));
4145
- } else {
4146
- steps.push({
4147
- capability: "source",
4148
- step: "sync properties",
4149
- status: "skip",
4150
- message: "provider does not implement syncProperties"
4151
- });
4152
- print(formatSyncStep(steps[steps.length - 1]));
4153
- }
4154
- if (stepName === "teams") {
4155
- const teams = config.repo?.teams ?? [];
4156
- if (teams.length === 0) {
4157
- steps.push({
4158
- capability: "source",
4159
- step: "sync teams",
4160
- status: "skip",
4161
- message: "no teams configured"
4162
- });
4163
- print(formatSyncStep(steps[steps.length - 1]));
4164
- } else if (source.syncTeams) {
4165
- steps.push(await runSyncStep("source", "sync teams", dryRun, () => source.syncTeams(teams)));
4166
- print(formatSyncStep(steps[steps.length - 1]));
4167
- const repoCoord = input.context.repo ?? config.repo?.name ?? "";
4168
- const org = repoCoord.includes("/") ? repoCoord.split("/")[0] : "";
4169
- const writeableTeams = teams.map((t) => typeof t === "string" ? {
4170
- slug: t,
4171
- permission: "push"
4172
- } : t).filter((t) => [
4173
- "push",
4174
- "maintain",
4175
- "admin"
4176
- ].includes(t.permission));
4177
- if (org && writeableTeams.length > 0) {
4178
- steps.push(await runSyncStep("source", "write .github/CODEOWNERS", dryRun, async () => {
4179
- const content = writeableTeams.map((t) => `* @${org}/${t.slug}`).join("\n") + "\n";
4180
- await source.writeRepoFile(".github/CODEOWNERS", content);
4181
- }));
4182
- print(formatSyncStep(steps[steps.length - 1]));
4183
- }
4184
- } else {
4185
- steps.push({
4186
- capability: "source",
4187
- step: "sync teams",
4188
- status: "skip",
4189
- message: "provider does not implement syncTeams"
4190
- });
4191
- print(formatSyncStep(steps[steps.length - 1]));
4192
- }
4193
- }
4194
- if (stepName === "topics") {
4195
- const topics = config.repo?.topics ?? [];
4196
- if (topics.length === 0) {
4197
- steps.push({
4198
- capability: "source",
4199
- step: "sync topics",
4200
- status: "skip",
4201
- message: "no topics configured"
4202
- });
4203
- print(formatSyncStep(steps[steps.length - 1]));
4204
- } else if (source.syncTopics) {
4205
- steps.push(await runSyncStep("source", "sync topics", dryRun, () => source.syncTopics(topics)));
4206
- print(formatSyncStep(steps[steps.length - 1]));
4207
- } else {
4208
- steps.push({
4209
- capability: "source",
4210
- step: "sync topics",
4211
- status: "skip",
4212
- message: "provider does not implement syncTopics"
4213
- });
4214
- print(formatSyncStep(steps[steps.length - 1]));
4215
- }
3917
+ const body = source.slice(start, i - 1);
3918
+ const entries = [];
3919
+ const objSpans = [];
3920
+ const objRe = /\{\s*name\s*:\s*"([^"]+)"(?:\s*,\s*with\s*:\s*(\{[^}]*\}))?\s*\}/g;
3921
+ let m;
3922
+ while ((m = objRe.exec(body)) !== null) {
3923
+ objSpans.push([m.index, m.index + m[0].length]);
3924
+ let withObj;
3925
+ if (m[2]) try {
3926
+ withObj = JSON.parse(m[2]);
3927
+ } catch {}
3928
+ entries.push({
3929
+ pos: m.index,
3930
+ entry: {
3931
+ name: m[1],
3932
+ ...withObj && { with: withObj }
4216
3933
  }
3934
+ });
3935
+ }
3936
+ const strRe = /"([^"]+)"/g;
3937
+ while ((m = strRe.exec(body)) !== null) if (!objSpans.some(([s, e]) => m.index >= s && m.index < e)) entries.push({
3938
+ pos: m.index,
3939
+ entry: { name: m[1] }
3940
+ });
3941
+ entries.sort((a, b) => a.pos - b.pos);
3942
+ return entries.map(({ entry }) => entry);
3943
+ }
3944
+ function reusableHeader(source) {
3945
+ return [
3946
+ `# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
3947
+ `# Source: theholocron/holocron · ${source}`,
3948
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
3949
+ `# Tool: holocron sync-github`,
3950
+ `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
3951
+ ``
3952
+ ].join("\n");
3953
+ }
3954
+ function thinCallerHeader(forPrimary = false) {
3955
+ return [
3956
+ forPrimary ? `# AUTO-GENERATED — do not edit in theholocron/.github directly.` : `# AUTO-GENERATED — do not edit directly.`,
3957
+ `# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
3958
+ `# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
3959
+ `# Tool: holocron sync-github`,
3960
+ `# Changes: edit source in theholocron/holocron and push to alpha or main.`,
3961
+ ``
3962
+ ].join("\n");
3963
+ }
3964
+ function buildBatch(repo, allowedWorkflows, withOverrides) {
3965
+ const files = [];
3966
+ const isPrimaryGithubRepo = repo === DEFAULT_REPO;
3967
+ if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(ACTIONS)) files.push({
3968
+ path: `.github/actions/${name}.yml`,
3969
+ content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
3970
+ });
3971
+ if (isPrimaryGithubRepo) {
3972
+ for (const [name, content] of Object.entries(REUSABLE_WORKFLOWS)) files.push({
3973
+ path: `.github/workflows/${name}.yml`,
3974
+ content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
3975
+ });
3976
+ for (const [name, content] of Object.entries(WORKFLOW_TEMPLATES)) {
3977
+ files.push({
3978
+ path: `workflow-templates/${name}.yml`,
3979
+ content: thinCallerHeader(true) + content
3980
+ });
3981
+ const props = WORKFLOW_TEMPLATE_PROPERTIES[name];
3982
+ if (props) files.push({
3983
+ path: `workflow-templates/${name}.properties.json`,
3984
+ content: props
3985
+ });
4217
3986
  }
4218
- if (requestedSteps) {
4219
- for (const name of requestedSteps) if (!SYNC_STEPS.includes(name)) {
4220
- steps.push({
4221
- capability: "source",
4222
- step: `sync ${name}`,
4223
- status: "skip",
4224
- message: `unknown step "${name}"`
4225
- });
4226
- print(formatSyncStep(steps[steps.length - 1]));
4227
- }
3987
+ } else for (const name of Object.keys(REUSABLE_WORKFLOWS)) {
3988
+ if (allowedWorkflows && !allowedWorkflows.has(name)) continue;
3989
+ const content = generateThinCallerContent(name, withOverrides?.get(name));
3990
+ if (!content) continue;
3991
+ files.push({
3992
+ path: `.github/workflows/${name}.yml`,
3993
+ content: thinCallerHeader() + content
3994
+ });
3995
+ }
3996
+ return files;
3997
+ }
3998
+ /** Git blob SHA: sha1("blob {len}\0{content}") — used to detect unchanged files. */
3999
+ function gitBlobSha(content) {
4000
+ const buf = Buffer.from(content, "utf8");
4001
+ return createHash("sha1").update(`blob ${buf.length}\0`).update(buf).digest("hex");
4002
+ }
4003
+ async function runSyncGithub(input) {
4004
+ const print = input.print ?? ((line) => console.log(line));
4005
+ const repo = input.repo ?? DEFAULT_REPO;
4006
+ const { token, dryRun = false, branch, createPr = false } = input;
4007
+ const message = input.message ?? `chore: sync from theholocron/holocron`;
4008
+ const client = createGitHubClient({
4009
+ token,
4010
+ fetch: input.fetch
4011
+ });
4012
+ print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
4013
+ print(` repo: ${repo}`);
4014
+ if (branch) print(` branch: ${branch}`);
4015
+ print("");
4016
+ if (input.outputDir) {
4017
+ const batch = buildBatch(repo);
4018
+ for (const file of batch) {
4019
+ const dest = join(input.outputDir, file.path);
4020
+ mkdirSync(dirname(dest), { recursive: true });
4021
+ writeFileSync(dest, file.content, "utf8");
4228
4022
  }
4023
+ print(` ${batch.length} files written to ${input.outputDir}`);
4024
+ return {
4025
+ status: "ok",
4026
+ created: batch.length,
4027
+ updated: 0,
4028
+ unchanged: 0
4029
+ };
4229
4030
  }
4230
- for (const stepName of ["keywords", "description"]) {
4231
- if (requestedSteps !== void 0 && !requestedSteps.includes(stepName)) continue;
4232
- if (stepName === "keywords") {
4233
- const topics = config.repo?.topics ?? [];
4234
- if (topics.length === 0) {
4235
- steps.push({
4236
- capability: "local",
4237
- step: "sync keywords",
4238
- status: "skip",
4239
- message: "no topics configured"
4240
- });
4241
- print(formatSyncStep(steps[steps.length - 1]));
4242
- } else {
4243
- steps.push(await runSyncStep("local", "sync keywords", dryRun, async () => {
4244
- return await writePackageJsonField(input.context.repoRoot, "keywords", topics) ? `${topics.length} keywords written` : `${topics.length} topics (no package.json)`;
4245
- }));
4246
- print(formatSyncStep(steps[steps.length - 1]));
4247
- }
4031
+ let targetBranch = branch;
4032
+ let defaultBranch;
4033
+ if (!targetBranch || createPr) try {
4034
+ defaultBranch = (await client.repos.getRepo(repo)).default_branch;
4035
+ if (!targetBranch) targetBranch = defaultBranch;
4036
+ } catch {
4037
+ const msg = "failed to fetch repo metadata";
4038
+ print(` ✗ ${msg}`);
4039
+ return {
4040
+ status: "fail",
4041
+ created: 0,
4042
+ updated: 0,
4043
+ unchanged: 0,
4044
+ message: msg
4045
+ };
4046
+ }
4047
+ const baseBranch = createPr && defaultBranch ? defaultBranch : targetBranch;
4048
+ let headSha;
4049
+ let baseTreeSha;
4050
+ let existingBlobs;
4051
+ try {
4052
+ headSha = (await client.git.getRef(repo, baseBranch)).object.sha;
4053
+ baseTreeSha = (await client.git.getCommit(repo, headSha)).tree.sha;
4054
+ const treeData = await client.git.getTree(repo, baseTreeSha, true);
4055
+ existingBlobs = new Map(treeData.tree.filter((i) => i.type === "blob").map((i) => [i.path, i.sha]));
4056
+ } catch (err) {
4057
+ const msg = err instanceof Error ? err.message : `Branch ${baseBranch} not found`;
4058
+ print(` ✗ ${msg}`);
4059
+ return {
4060
+ status: "fail",
4061
+ created: 0,
4062
+ updated: 0,
4063
+ unchanged: 0,
4064
+ message: msg
4065
+ };
4066
+ }
4067
+ let allowedWorkflows;
4068
+ let withOverrides;
4069
+ if (repo !== DEFAULT_REPO) try {
4070
+ let entries = [];
4071
+ try {
4072
+ const data = await client.git.getContents(repo, "holocron.config.json");
4073
+ entries = (JSON.parse(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"))?.workflows ?? []).map((w) => typeof w === "string" ? { name: w } : w);
4074
+ } catch (err) {
4075
+ if (!(err instanceof ProviderApiError) || err.status !== 404) throw err;
4076
+ try {
4077
+ const data = await client.git.getContents(repo, "holocron.config.ts");
4078
+ entries = parseWorkflowsFromTs(Buffer.from(data.content.replace(/\n/g, ""), "base64").toString("utf8"));
4079
+ } catch {}
4248
4080
  }
4249
- if (stepName === "description") {
4250
- const description = config.description;
4251
- if (!description) {
4252
- steps.push({
4253
- capability: "local",
4254
- step: "sync description",
4255
- status: "skip",
4256
- message: "no description configured"
4257
- });
4258
- print(formatSyncStep(steps[steps.length - 1]));
4259
- } else {
4260
- const source = loader.has("source") ? loader.get("source") : null;
4261
- steps.push(await runSyncStep("local", "sync description", dryRun, async () => {
4262
- const pkgWrote = await writePackageJsonField(input.context.repoRoot, "description", description);
4263
- const readmeWrote = await updateReadmeDescription(input.context.repoRoot, description);
4264
- if (source?.syncDescription) await source.syncDescription(description);
4265
- const parts = [];
4266
- if (pkgWrote) parts.push("package.json");
4267
- if (readmeWrote) parts.push("README.md");
4268
- if (source?.syncDescription) parts.push("GitHub");
4269
- return parts.length > 0 ? parts.join(", ") + " updated" : "description synced";
4270
- }));
4271
- print(formatSyncStep(steps[steps.length - 1]));
4272
- }
4081
+ if (entries.length > 0) {
4082
+ allowedWorkflows = new Set(entries.map((e) => e.name));
4083
+ const overrideEntries = entries.filter((e) => e.with != null).map((e) => [e.name, e.with]);
4084
+ if (overrideEntries.length > 0) withOverrides = new Map(overrideEntries);
4085
+ }
4086
+ } catch {}
4087
+ const batch = buildBatch(repo, allowedWorkflows, withOverrides);
4088
+ let created = 0;
4089
+ let updated = 0;
4090
+ let unchanged = 0;
4091
+ const changedFiles = [];
4092
+ for (const file of batch) {
4093
+ const localSha = gitBlobSha(file.content);
4094
+ const existingSha = existingBlobs.get(file.path);
4095
+ if (existingSha === localSha) {
4096
+ print(` · unchanged ${file.path}`);
4097
+ unchanged++;
4098
+ } else if (existingSha) {
4099
+ print(` ${dryRun ? "~" : "✓"} updated ${file.path}`);
4100
+ updated++;
4101
+ if (!dryRun) changedFiles.push(file);
4102
+ } else {
4103
+ print(` ${dryRun ? "~" : "✓"} created ${file.path}`);
4104
+ created++;
4105
+ if (!dryRun) changedFiles.push(file);
4273
4106
  }
4274
4107
  }
4275
- const summary = steps.reduce((acc, s) => {
4276
- if (s.status === "ok") acc.ok += 1;
4277
- else if (s.status === "fail") acc.fail += 1;
4278
- else if (s.status === "skip") acc.skip += 1;
4279
- else if (s.status === "dry-run") acc.dryRun += 1;
4280
- return acc;
4281
- }, {
4282
- ok: 0,
4283
- fail: 0,
4284
- skip: 0,
4285
- dryRun: 0
4286
- });
4287
4108
  print("");
4288
- print(` ${summary.ok} ok, ${summary.fail} fail, ${summary.skip} skipped${dryRun ? `, ${summary.dryRun} would-do` : ""}`);
4289
- return {
4290
- steps,
4291
- summary
4292
- };
4293
- }
4294
- async function runSyncStep(capability, step, dryRun, body) {
4295
- if (dryRun) return {
4296
- capability,
4297
- step,
4298
- status: "dry-run"
4109
+ print(` ${created} created, ${updated} updated, ${unchanged} unchanged`);
4110
+ if (dryRun || changedFiles.length === 0) return {
4111
+ status: dryRun ? "dry-run" : "ok",
4112
+ created,
4113
+ updated,
4114
+ unchanged
4299
4115
  };
4116
+ const treeEntries = [];
4117
+ for (const file of changedFiles) try {
4118
+ const blob = await client.git.createBlob(repo, file.content);
4119
+ treeEntries.push({
4120
+ path: file.path,
4121
+ mode: "100644",
4122
+ type: "blob",
4123
+ sha: blob.sha
4124
+ });
4125
+ } catch (err) {
4126
+ const msg = `failed to create blob for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
4127
+ print(` ✗ ${msg}`);
4128
+ return {
4129
+ status: "fail",
4130
+ created,
4131
+ updated,
4132
+ unchanged,
4133
+ message: msg
4134
+ };
4135
+ }
4136
+ let newTreeSha;
4300
4137
  try {
4301
- const note = await body();
4302
- const result = {
4303
- capability,
4304
- step,
4305
- status: "ok"
4138
+ newTreeSha = (await client.git.createTree(repo, treeEntries, baseTreeSha)).sha;
4139
+ } catch (err) {
4140
+ const msg = `failed to create tree: ${err instanceof Error ? err.message : String(err)}`;
4141
+ print(` ✗ ${msg}`);
4142
+ return {
4143
+ status: "fail",
4144
+ created,
4145
+ updated,
4146
+ unchanged,
4147
+ message: msg
4306
4148
  };
4307
- if (typeof note === "string") result.message = note;
4308
- return result;
4149
+ }
4150
+ let newCommitSha;
4151
+ try {
4152
+ newCommitSha = (await client.git.createCommit(repo, message, newTreeSha, [headSha])).sha;
4309
4153
  } catch (err) {
4154
+ const msg = `failed to create commit: ${err instanceof Error ? err.message : String(err)}`;
4155
+ print(` ✗ ${msg}`);
4310
4156
  return {
4311
- capability,
4312
- step,
4313
4157
  status: "fail",
4314
- message: err instanceof Error ? err.message : String(err)
4158
+ created,
4159
+ updated,
4160
+ unchanged,
4161
+ message: msg
4315
4162
  };
4316
4163
  }
4164
+ try {
4165
+ if (createPr && branch) try {
4166
+ await client.git.createRef(repo, `refs/heads/${branch}`, newCommitSha);
4167
+ } catch (err) {
4168
+ if (!(err instanceof ProviderApiError) || err.status !== 422) throw err;
4169
+ await client.git.updateRef(repo, `heads/${branch}`, newCommitSha, true);
4170
+ }
4171
+ else await client.git.updateRef(repo, `heads/${targetBranch}`, newCommitSha);
4172
+ } catch (err) {
4173
+ const msg = `failed to update ref: ${err instanceof Error ? err.message : String(err)}`;
4174
+ print(` ✗ ${msg}`);
4175
+ return {
4176
+ status: "fail",
4177
+ created,
4178
+ updated,
4179
+ unchanged,
4180
+ message: msg
4181
+ };
4182
+ }
4183
+ let prUrl;
4184
+ if (branch && createPr && !dryRun) try {
4185
+ prUrl = (await client.git.createPull(repo, {
4186
+ title: message.split("\n")[0],
4187
+ head: branch,
4188
+ base: "main",
4189
+ body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
4190
+ })).html_url;
4191
+ print(` → PR opened: ${prUrl}`);
4192
+ } catch (err) {
4193
+ if (err instanceof ProviderApiError && err.status === 422 && String(err.details).includes("already exists")) print(` → PR already open for ${branch} — branch updated, ready to merge`);
4194
+ else print(` ⚠ PR creation failed: ${err instanceof Error ? err.message : String(err)}`);
4195
+ }
4196
+ return {
4197
+ status: "ok",
4198
+ created,
4199
+ updated,
4200
+ unchanged,
4201
+ prUrl
4202
+ };
4317
4203
  }
4318
- function formatSyncStep(step) {
4319
- const icon = step.status === "ok" ? "✓" : step.status === "fail" ? "✗" : step.status === "dry-run" ? "…" : "·";
4320
- const detail = step.message ? ` (${step.message})` : "";
4321
- return ` ${icon} ${step.step}${detail}`;
4322
- }
4323
- async function writePackageJsonField(repoRoot, field, value) {
4324
- const pkgPath = join(repoRoot, "package.json");
4325
- let content;
4204
+ //#endregion
4205
+ //#region src/commands/upgrade-node.ts
4206
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
4207
+ "node_modules",
4208
+ ".git",
4209
+ "dist",
4210
+ "coverage",
4211
+ "build",
4212
+ ".turbo",
4213
+ ".next",
4214
+ "out"
4215
+ ]);
4216
+ function patchPackageJson(content, from, to) {
4217
+ let pkg;
4326
4218
  try {
4327
- content = await readFile(pkgPath, "utf8");
4219
+ pkg = JSON.parse(content);
4328
4220
  } catch {
4329
- return false;
4221
+ return null;
4330
4222
  }
4331
- const pkg = JSON.parse(content);
4332
- pkg[field] = value;
4333
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
4334
- return true;
4223
+ let changed = false;
4224
+ const engines = pkg.engines;
4225
+ if (engines?.node && /^>=\d+(?:\.0\.0)?$/.test(engines.node)) {
4226
+ if (parseInt(engines.node.match(/\d+/)[0], 10) === from) {
4227
+ engines.node = `>=${to}.0.0`;
4228
+ changed = true;
4229
+ }
4230
+ }
4231
+ for (const field of ["devDependencies", "dependencies"]) {
4232
+ const deps = pkg[field];
4233
+ if (!deps?.["@types/node"]) continue;
4234
+ if (deps["@types/node"] === `^${from}.0.0`) {
4235
+ deps["@types/node"] = `^${to}.0.0`;
4236
+ changed = true;
4237
+ }
4238
+ }
4239
+ return changed ? JSON.stringify(pkg, null, 2) + "\n" : null;
4335
4240
  }
4336
- const README_DESC_START = "<!-- holocron:description -->";
4337
- const README_DESC_END = "<!-- /holocron:description -->";
4338
- async function updateReadmeDescription(repoRoot, description) {
4339
- const readmePath = join(repoRoot, "README.md");
4340
- let content;
4241
+ function patchYaml(content, from, to) {
4242
+ const updated = content.replace(/node-version:\s+['"]?(\d+)['"]?/g, (match, ver) => ver === String(from) ? match.replace(String(from), String(to)) : match);
4243
+ return updated !== content ? updated : null;
4244
+ }
4245
+ function patchPinFile(content, from, to) {
4246
+ const trimmed = content.trim();
4247
+ if (trimmed === String(from) || trimmed.startsWith(`${from}.`)) return `${to}\n`;
4248
+ return null;
4249
+ }
4250
+ function patchDockerfile(content, from, to) {
4251
+ const updated = content.replace(/^(FROM\s+node:)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
4252
+ return updated !== content ? updated : null;
4253
+ }
4254
+ function patchToolVersions(content, from, to) {
4255
+ const updated = content.replace(/^(nodejs\s+)(\d+)/gm, (match, prefix, ver) => ver === String(from) ? `${prefix}${to}` : match);
4256
+ return updated !== content ? updated : null;
4257
+ }
4258
+ const PATTERNS = [
4259
+ {
4260
+ matches: (n) => n === "package.json",
4261
+ patch: patchPackageJson
4262
+ },
4263
+ {
4264
+ matches: (n) => n.endsWith(".yml") || n.endsWith(".yaml"),
4265
+ patch: patchYaml
4266
+ },
4267
+ {
4268
+ matches: (n) => n === ".nvmrc" || n === ".node-version",
4269
+ patch: patchPinFile
4270
+ },
4271
+ {
4272
+ matches: (n) => n === "Dockerfile" || n.startsWith("Dockerfile."),
4273
+ patch: patchDockerfile
4274
+ },
4275
+ {
4276
+ matches: (n) => n === ".tool-versions",
4277
+ patch: patchToolVersions
4278
+ }
4279
+ ];
4280
+ function detectFrom(cwd, _readFile) {
4281
+ for (const name of [".nvmrc", ".node-version"]) try {
4282
+ const major = parseInt(_readFile(join(cwd, name)).trim(), 10);
4283
+ if (!isNaN(major)) return major;
4284
+ } catch {}
4341
4285
  try {
4342
- content = await readFile(readmePath, "utf8");
4343
- } catch {
4344
- return false;
4286
+ const node = JSON.parse(_readFile(join(cwd, "package.json"))).engines?.node;
4287
+ if (node) {
4288
+ const m = node.match(/(\d+)/);
4289
+ if (m) return parseInt(m[1], 10);
4290
+ }
4291
+ } catch {}
4292
+ return null;
4293
+ }
4294
+ function defaultWalkFiles(dir) {
4295
+ const results = [];
4296
+ function walk(current) {
4297
+ let entries;
4298
+ try {
4299
+ entries = readdirSync(current);
4300
+ } catch {
4301
+ return;
4302
+ }
4303
+ for (const entry of entries) {
4304
+ if (SKIP_DIRS.has(entry)) continue;
4305
+ const abs = join(current, entry);
4306
+ try {
4307
+ if (statSync(abs).isDirectory()) walk(abs);
4308
+ else results.push(abs);
4309
+ } catch {}
4310
+ }
4345
4311
  }
4346
- const lines = content.split("\n");
4347
- const startIdx = lines.findIndex((l) => l.trim() === README_DESC_START);
4348
- const endIdx = lines.findIndex((l) => l.trim() === README_DESC_END);
4349
- if (startIdx !== -1) {
4350
- if (endIdx === -1 || endIdx <= startIdx) return false;
4351
- lines.splice(startIdx + 1, endIdx - startIdx - 1, description);
4352
- await writeFile(readmePath, lines.join("\n"), "utf8");
4353
- return true;
4312
+ walk(dir);
4313
+ return results;
4314
+ }
4315
+ async function runUpgradeNode(input) {
4316
+ const print = input.print ?? ((line) => console.log(line));
4317
+ const cwd = input.cwd ?? process.cwd();
4318
+ const { to, dryRun = false, extra = [] } = input;
4319
+ const _readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
4320
+ const _writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
4321
+ const _walkFiles = input.walkFiles ?? defaultWalkFiles;
4322
+ const from = input.from ?? detectFrom(cwd, _readFile);
4323
+ if (from === null) return {
4324
+ status: "fail",
4325
+ updated: [],
4326
+ message: "could not detect current Node version — pass --from <major>"
4327
+ };
4328
+ if (from === to) {
4329
+ print(`Already at Node.js ${to} — nothing to do.`);
4330
+ return {
4331
+ status: "ok",
4332
+ updated: []
4333
+ };
4354
4334
  }
4355
- const h1Index = lines.findIndex((l) => /^# /.test(l));
4356
- if (h1Index === -1) return false;
4357
- lines.splice(h1Index + 1, 0, "", README_DESC_START, description, README_DESC_END);
4358
- await writeFile(readmePath, lines.join("\n"), "utf8");
4359
- return true;
4335
+ print(`Upgrading Node.js ${from} ${to}${dryRun ? " (dry-run)" : ""}…`);
4336
+ const updated = [];
4337
+ const scanned = [..._walkFiles(cwd), ...extra.map((p) => join(cwd, p))];
4338
+ for (const abs of scanned) {
4339
+ const name = basename(abs);
4340
+ const pattern = PATTERNS.find((p) => p.matches(name));
4341
+ if (!pattern) continue;
4342
+ let content;
4343
+ try {
4344
+ content = _readFile(abs);
4345
+ } catch {
4346
+ continue;
4347
+ }
4348
+ const patched = pattern.patch(content, from, to);
4349
+ if (patched === null) continue;
4350
+ const rel = abs.startsWith(cwd + "/") ? abs.slice(cwd.length + 1) : abs;
4351
+ if (!dryRun) _writeFile(abs, patched);
4352
+ print(` ${dryRun ? "~" : "✓"} ${rel}`);
4353
+ updated.push(rel);
4354
+ }
4355
+ if (updated.length === 0) print(` · no files contained Node.js ${from} pins`);
4356
+ return {
4357
+ status: dryRun ? "dry-run" : "ok",
4358
+ updated
4359
+ };
4360
4360
  }
4361
4361
  //#endregion
4362
4362
  //#region src/load-config.ts