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