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