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