@theholocron/cli 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +1696 -1696
- 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/
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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/
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
883
|
+
content = readFn(filepath);
|
|
994
884
|
} catch {
|
|
995
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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/
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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: "
|
|
1175
|
-
|
|
1158
|
+
status: "dry-run",
|
|
1159
|
+
message: "dry-run — no publish executed",
|
|
1160
|
+
packageNames: PUBLISHABLE_PACKAGES
|
|
1176
1161
|
};
|
|
1177
1162
|
}
|
|
1178
|
-
print(
|
|
1179
|
-
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
if (
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
updated.push(rel);
|
|
1174
|
+
return {
|
|
1175
|
+
status: "fail",
|
|
1176
|
+
message,
|
|
1177
|
+
packageNames: PUBLISHABLE_PACKAGES
|
|
1178
|
+
};
|
|
1197
1179
|
}
|
|
1198
|
-
|
|
1180
|
+
print(" ✓ publish complete");
|
|
1181
|
+
printNextSteps$1(print, env);
|
|
1199
1182
|
return {
|
|
1200
|
-
status:
|
|
1201
|
-
|
|
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/
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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/
|
|
1215
|
-
|
|
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/
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
};
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
if (
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
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
|
-
|
|
3808
|
-
|
|
3809
|
-
const
|
|
3810
|
-
const
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
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
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
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
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
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
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
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
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
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
|
|
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
|
|
3967
|
-
const
|
|
3968
|
-
const
|
|
3969
|
-
|
|
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
|
-
|
|
3982
|
-
|
|
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
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
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
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
const
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
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
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
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
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
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/
|
|
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\njobs:\n unit:\n name: Run tests and collect coverage\n permissions:\n contents: read\n id-token: write\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 use_oidc: true\n\n - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1\n name: Upload test results to Codecov\n if: ${{ !cancelled() }}\n with:\n use_oidc: true\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
|
-
*
|
|
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
|
-
*
|
|
4074
|
-
*
|
|
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
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
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
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
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
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
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
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
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
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
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 (
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
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(` ${
|
|
4289
|
-
return {
|
|
4290
|
-
|
|
4291
|
-
|
|
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
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
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
|
-
|
|
4308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
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
|
-
|
|
4219
|
+
pkg = JSON.parse(content);
|
|
4328
4220
|
} catch {
|
|
4329
|
-
return
|
|
4221
|
+
return null;
|
|
4330
4222
|
}
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
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
|
-
|
|
4337
|
-
const
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
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
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
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
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
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
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
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
|