@theholocron/cli 2.0.0-alpha.0 → 2.0.0-alpha.10
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 +15 -11
- package/dist/capabilities/index.d.mts +535 -2
- package/dist/capabilities/index.mjs +46 -1
- package/dist/cli.mjs +3414 -133
- package/dist/index.d.mts +97 -5
- package/dist/index.mjs +184 -3
- package/package.json +5 -4
- package/dist/capabilities-QjjhVlDd.mjs +0 -43
- package/dist/cli.d.mts +0 -1
- package/dist/config-DWlIfFZm.mjs +0 -113
- package/dist/index-DDxKXqtR.d.mts +0 -485
package/dist/cli.mjs
CHANGED
|
@@ -1,11 +1,404 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
import {
|
|
3
|
-
import { n as resolveConfig, t as ConfigError } from "./config-DWlIfFZm.mjs";
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
3
|
import yargs from "yargs";
|
|
5
4
|
import { hideBin } from "yargs/helpers";
|
|
5
|
+
import { Entry, findCredentials } from "@napi-rs/keyring";
|
|
6
|
+
import path, { dirname, join } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
6
8
|
import { spawnSync } from "node:child_process";
|
|
7
9
|
import { readFile, stat } from "node:fs/promises";
|
|
8
|
-
|
|
10
|
+
//#region src/capabilities/index.ts
|
|
11
|
+
const CARDINALITY = {
|
|
12
|
+
source: "single",
|
|
13
|
+
ci: "single",
|
|
14
|
+
secrets: "single",
|
|
15
|
+
environments: "single",
|
|
16
|
+
issues: "single",
|
|
17
|
+
deployment: "single",
|
|
18
|
+
storage: "single",
|
|
19
|
+
auth: "single",
|
|
20
|
+
vault: "single",
|
|
21
|
+
dns: "single",
|
|
22
|
+
tooling: "many",
|
|
23
|
+
notifications: "many",
|
|
24
|
+
analytics: "many",
|
|
25
|
+
observability: "many"
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* No capabilities are strictly required — repos without secrets (e.g. org
|
|
29
|
+
* community health repos) legitimately omit vault. Plugins validate their
|
|
30
|
+
* own requirements at call time.
|
|
31
|
+
*/
|
|
32
|
+
const REQUIRED_CAPABILITIES = [];
|
|
33
|
+
/**
|
|
34
|
+
* Surfaced from every capability call that hits a vendor API. Wraps
|
|
35
|
+
* the underlying error with `status` (HTTP) and `details` so
|
|
36
|
+
* orchestrators (`holocron setup`, `doctor`) can soft-skip rather
|
|
37
|
+
* than abort.
|
|
38
|
+
*/
|
|
39
|
+
var ProviderApiError = class extends Error {
|
|
40
|
+
status;
|
|
41
|
+
details;
|
|
42
|
+
name = "ProviderApiError";
|
|
43
|
+
constructor(message, status, details) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.status = status;
|
|
46
|
+
this.details = details;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/config.ts
|
|
51
|
+
/**
|
|
52
|
+
* `holocron.config.json` schema, parser, and provider resolution.
|
|
53
|
+
*
|
|
54
|
+
* ESLint-style entry forms:
|
|
55
|
+
*
|
|
56
|
+
* "source": "github" ← single, short
|
|
57
|
+
* "deployment": ["vercel", { team: "rando" }] ← single, with options
|
|
58
|
+
* "notifications": ["slack", "discord"] ← multi, short
|
|
59
|
+
* "notifications": [
|
|
60
|
+
* ["slack", { channel: "#ops" }],
|
|
61
|
+
* ["discord", { webhook: "env:HOOK" }]
|
|
62
|
+
* ] ← multi, with options
|
|
63
|
+
*
|
|
64
|
+
* Discriminator: an array entry is a `[provider, options]` tuple when
|
|
65
|
+
* the length is 2 AND element[1] is a non-array, non-null object.
|
|
66
|
+
* Otherwise it's a multi-provider list (string[] or tuple[]).
|
|
67
|
+
*
|
|
68
|
+
* Validation rules:
|
|
69
|
+
* - `vault` is REQUIRED (every project has secrets somewhere)
|
|
70
|
+
* - Entries for `'many'` capabilities are normalized to an array of
|
|
71
|
+
* normalized tuples; entries for `'single'` capabilities are
|
|
72
|
+
* normalized to one tuple
|
|
73
|
+
* - Tokens / secret values never appear in config — providers read
|
|
74
|
+
* them from env (or pull from `vault` at runtime)
|
|
75
|
+
*/
|
|
76
|
+
var ConfigError = class extends Error {
|
|
77
|
+
name = "ConfigError";
|
|
78
|
+
};
|
|
79
|
+
const PLUGIN_PREFIX = "@theholocron/holocron-plugin-";
|
|
80
|
+
const COMMUNITY_PREFIX = "holocron-plugin-";
|
|
81
|
+
/**
|
|
82
|
+
* Resolve `"github"` → `"@theholocron/holocron-plugin-github"`.
|
|
83
|
+
* Fully-qualified names (scoped or not) are honored verbatim, which
|
|
84
|
+
* is how third-party plugins published outside the org work.
|
|
85
|
+
*/
|
|
86
|
+
function resolvePluginPackage(provider) {
|
|
87
|
+
if (!provider) throw new ConfigError("provider name is empty");
|
|
88
|
+
if (provider.startsWith("@")) return provider;
|
|
89
|
+
if (provider.startsWith(COMMUNITY_PREFIX)) return provider;
|
|
90
|
+
if (provider.includes("/")) return provider;
|
|
91
|
+
return PLUGIN_PREFIX + provider;
|
|
92
|
+
}
|
|
93
|
+
/** A bare `[provider, options]` tuple, with both elements present? */
|
|
94
|
+
function isOptionsTuple(value) {
|
|
95
|
+
if (!Array.isArray(value)) return false;
|
|
96
|
+
if (value.length !== 2) return false;
|
|
97
|
+
if (typeof value[0] !== "string") return false;
|
|
98
|
+
const opt = value[1];
|
|
99
|
+
return typeof opt === "object" && opt !== null && !Array.isArray(opt);
|
|
100
|
+
}
|
|
101
|
+
function normalizeEntry(entry) {
|
|
102
|
+
if (typeof entry === "string") return {
|
|
103
|
+
provider: entry,
|
|
104
|
+
packageName: resolvePluginPackage(entry),
|
|
105
|
+
options: {}
|
|
106
|
+
};
|
|
107
|
+
const [provider, options] = entry;
|
|
108
|
+
return {
|
|
109
|
+
provider,
|
|
110
|
+
packageName: resolvePluginPackage(provider),
|
|
111
|
+
options
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function resolveEntry(key, raw) {
|
|
115
|
+
const cardinality = CARDINALITY[key];
|
|
116
|
+
if (typeof raw === "string") {
|
|
117
|
+
if (cardinality === "many") throw new ConfigError(`\`${key}\` accepts multiple providers; wrap a single one in an array: ["${raw}"]`);
|
|
118
|
+
return {
|
|
119
|
+
cardinality: "single",
|
|
120
|
+
tuple: normalizeEntry(raw)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (!Array.isArray(raw)) throw new ConfigError(`\`${key}\` entry must be a string or array, got ${typeof raw}`);
|
|
124
|
+
if (isOptionsTuple(raw)) {
|
|
125
|
+
if (cardinality === "many") return {
|
|
126
|
+
cardinality: "many",
|
|
127
|
+
tuples: [normalizeEntry(raw)]
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
cardinality: "single",
|
|
131
|
+
tuple: normalizeEntry(raw)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (cardinality === "single") throw new ConfigError(`\`${key}\` accepts exactly one provider; got a multi-provider list with ${raw.length} entries`);
|
|
135
|
+
return {
|
|
136
|
+
cardinality: "many",
|
|
137
|
+
tuples: raw.map((entry, idx) => {
|
|
138
|
+
if (typeof entry === "string") return normalizeEntry(entry);
|
|
139
|
+
if (isOptionsTuple(entry)) return normalizeEntry(entry);
|
|
140
|
+
throw new ConfigError(`\`${key}[${idx}]\` must be a provider string or [provider, options] tuple`);
|
|
141
|
+
})
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function resolveConfig(raw) {
|
|
145
|
+
if (!raw.project?.name) throw new ConfigError("`project.name` is required");
|
|
146
|
+
if (!raw.providers || typeof raw.providers !== "object") throw new ConfigError("`providers` block is required");
|
|
147
|
+
const providers = {};
|
|
148
|
+
for (const [key, entry] of Object.entries(raw.providers)) {
|
|
149
|
+
if (entry === void 0) continue;
|
|
150
|
+
providers[key] = resolveEntry(key, entry);
|
|
151
|
+
}
|
|
152
|
+
for (const required of REQUIRED_CAPABILITIES) if (!providers[required]) throw new ConfigError(`required capability \`${required}\` is missing from providers`);
|
|
153
|
+
return {
|
|
154
|
+
project: raw.project,
|
|
155
|
+
providers,
|
|
156
|
+
apps: raw.apps ?? [],
|
|
157
|
+
doctor: raw.doctor ?? {}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/keyring.ts
|
|
162
|
+
/**
|
|
163
|
+
* Keyring-backed bootstrap credential store.
|
|
164
|
+
*
|
|
165
|
+
* Every holocron plugin's bootstrap token (the one it needs before it
|
|
166
|
+
* can talk to its vendor's API) can be stored in the OS keyring under
|
|
167
|
+
* a single reverse-DNS service scope. Managed via `holocron auth`
|
|
168
|
+
* subcommands; consulted at position 4 in every plugin's auth
|
|
169
|
+
* precedence chain (after --token / HOLOCRON_<X>_TOKEN / <native>_TOKEN).
|
|
170
|
+
*
|
|
171
|
+
* See `.notes/tech-auth-bootstrap.spec.md` for the design rationale.
|
|
172
|
+
*
|
|
173
|
+
* Failure model: keyring access is best-effort. Platforms without a
|
|
174
|
+
* supported credential store (some Linux CI images, sandboxed
|
|
175
|
+
* environments) will throw from the underlying library. Every export
|
|
176
|
+
* here catches and returns a null/empty result rather than propagating
|
|
177
|
+
* — the plugin's precedence chain then falls through to
|
|
178
|
+
* env-var-only paths, which is exactly how CI is meant to work.
|
|
179
|
+
*/
|
|
180
|
+
const SERVICE = "com.theholocron.cli";
|
|
181
|
+
/**
|
|
182
|
+
* Store or overwrite a bootstrap token for a provider. Returns true on
|
|
183
|
+
* success, false when the underlying keyring is unsupported or errored.
|
|
184
|
+
*/
|
|
185
|
+
function setToken(provider, token) {
|
|
186
|
+
try {
|
|
187
|
+
new Entry(SERVICE, provider).setPassword(token);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Read the bootstrap token for a provider. Returns `null` for both
|
|
195
|
+
* "not stored" and "keyring unavailable" — callers can treat them the
|
|
196
|
+
* same way (fall through to env-var precedence).
|
|
197
|
+
*/
|
|
198
|
+
function getToken(provider) {
|
|
199
|
+
try {
|
|
200
|
+
return new Entry(SERVICE, provider).getPassword();
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Delete a stored token. Returns true when a token was removed, false
|
|
207
|
+
* when there was nothing to delete or the keyring is unavailable.
|
|
208
|
+
* Distinguishing the two cases isn't worth the surface area — the
|
|
209
|
+
* command output makes the situation clear either way.
|
|
210
|
+
*/
|
|
211
|
+
function deleteToken(provider) {
|
|
212
|
+
try {
|
|
213
|
+
return new Entry(SERVICE, provider).deletePassword();
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* List provider slugs with a stored token in this service scope.
|
|
220
|
+
* Uses the library's `findCredentials(service)` — supported on all
|
|
221
|
+
* platforms the underlying credential store supports.
|
|
222
|
+
*/
|
|
223
|
+
function listStoredProviders() {
|
|
224
|
+
try {
|
|
225
|
+
return findCredentials(SERVICE).map((c) => c.account);
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/commands/auth.ts
|
|
232
|
+
/**
|
|
233
|
+
* `holocron auth <subcommand>` — manage bootstrap credentials in the
|
|
234
|
+
* OS keyring.
|
|
235
|
+
*
|
|
236
|
+
* Subcommands:
|
|
237
|
+
* auth set <provider> [token] verify + store
|
|
238
|
+
* auth unset <provider> remove
|
|
239
|
+
* auth check <provider> re-verify a stored token
|
|
240
|
+
* auth list every provider with a stored entry
|
|
241
|
+
*
|
|
242
|
+
* See `.notes/tech-auth-bootstrap.spec.md`.
|
|
243
|
+
*
|
|
244
|
+
* Verification lives in each plugin as a top-level `verifyToken(token)`
|
|
245
|
+
* export. The auth command dynamically imports
|
|
246
|
+
* `@theholocron/holocron-plugin-<provider>` and calls it. Plugins that
|
|
247
|
+
* don't export `verifyToken` can still store — with a warning — because
|
|
248
|
+
* "no verify path" shouldn't block credential storage.
|
|
249
|
+
*/
|
|
250
|
+
const defaultImporter$1 = async (pkg) => await import(pkg);
|
|
251
|
+
/**
|
|
252
|
+
* Resolve a token from (positional → HOLOCRON_<X> → vendor-native env).
|
|
253
|
+
* Keyring is NOT consulted here — `auth set` writes TO the keyring, so
|
|
254
|
+
* pulling FROM it would just re-store the same value.
|
|
255
|
+
*/
|
|
256
|
+
function resolveAuthSetToken(input) {
|
|
257
|
+
const env = input.env ?? process.env;
|
|
258
|
+
const upper = input.provider.toUpperCase();
|
|
259
|
+
const holocronKey = `HOLOCRON_${upper}_TOKEN`;
|
|
260
|
+
return input.positional || env[holocronKey] || env[upper + "_TOKEN"] || null;
|
|
261
|
+
}
|
|
262
|
+
async function runAuthSet(input) {
|
|
263
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
264
|
+
const importer = input.importer ?? defaultImporter$1;
|
|
265
|
+
const { provider } = input;
|
|
266
|
+
const token = resolveAuthSetToken({
|
|
267
|
+
provider,
|
|
268
|
+
positional: input.positional,
|
|
269
|
+
env: input.env
|
|
270
|
+
});
|
|
271
|
+
const packageName = resolvePluginPackage(provider);
|
|
272
|
+
if (!token) {
|
|
273
|
+
print(`no token supplied for \`${provider}\`.`);
|
|
274
|
+
print(` pass as positional arg: holocron auth set ${provider} <token>`);
|
|
275
|
+
print(` or via env: HOLOCRON_${provider.toUpperCase()}_TOKEN / ${provider.toUpperCase()}_TOKEN`);
|
|
276
|
+
const hint = await tryLoadHint(importer, packageName);
|
|
277
|
+
if (hint) print(` hint: ${hint}`);
|
|
278
|
+
return {
|
|
279
|
+
status: "fail",
|
|
280
|
+
message: "no token supplied"
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
let subject;
|
|
284
|
+
try {
|
|
285
|
+
const module = await importer(packageName);
|
|
286
|
+
if (typeof module.verifyToken === "function") {
|
|
287
|
+
const verified = await module.verifyToken(token);
|
|
288
|
+
if (!verified.ok) {
|
|
289
|
+
print(`token rejected by ${provider}: ${verified.message}`);
|
|
290
|
+
if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
|
|
291
|
+
return {
|
|
292
|
+
status: "fail",
|
|
293
|
+
message: verified.message
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
subject = verified.subject;
|
|
297
|
+
} else print(`(${provider} plugin has no verifyToken; storing without verification)`);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
print(`cannot verify token — failed to load ${packageName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
300
|
+
print(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`);
|
|
301
|
+
}
|
|
302
|
+
if (!setToken(provider, token)) {
|
|
303
|
+
print(`keyring unavailable — token not stored. Use env vars instead.`);
|
|
304
|
+
return {
|
|
305
|
+
status: "fail",
|
|
306
|
+
message: "keyring unavailable"
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
print(`stored ${provider} token${subject ? ` (${subject})` : ""}`);
|
|
310
|
+
return {
|
|
311
|
+
status: "ok",
|
|
312
|
+
...subject ? { message: subject } : {}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
function runAuthUnset(input) {
|
|
316
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
317
|
+
if (deleteToken(input.provider)) {
|
|
318
|
+
print(`removed ${input.provider} token`);
|
|
319
|
+
return { status: "ok" };
|
|
320
|
+
}
|
|
321
|
+
print(`no stored token for ${input.provider}`);
|
|
322
|
+
return {
|
|
323
|
+
status: "skip",
|
|
324
|
+
message: "nothing to remove"
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
async function runAuthCheck(input) {
|
|
328
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
329
|
+
const importer = input.importer ?? defaultImporter$1;
|
|
330
|
+
const { provider } = input;
|
|
331
|
+
const token = getToken(provider);
|
|
332
|
+
if (!token) {
|
|
333
|
+
print(`no stored token for ${provider}`);
|
|
334
|
+
return {
|
|
335
|
+
status: "skip",
|
|
336
|
+
message: "no stored token"
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const packageName = resolvePluginPackage(provider);
|
|
340
|
+
try {
|
|
341
|
+
const module = await importer(packageName);
|
|
342
|
+
if (typeof module.verifyToken !== "function") {
|
|
343
|
+
print(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`);
|
|
344
|
+
return {
|
|
345
|
+
status: "ok",
|
|
346
|
+
message: "stored, unverified"
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const verified = await module.verifyToken(token);
|
|
350
|
+
if (verified.ok) {
|
|
351
|
+
print(`${provider}: ok — ${verified.subject}`);
|
|
352
|
+
return {
|
|
353
|
+
status: "ok",
|
|
354
|
+
message: verified.subject
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
print(`${provider}: rejected — ${verified.message}`);
|
|
358
|
+
if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
|
|
359
|
+
return {
|
|
360
|
+
status: "fail",
|
|
361
|
+
message: verified.message
|
|
362
|
+
};
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
365
|
+
print(`${provider}: cannot verify — ${msg}`);
|
|
366
|
+
return {
|
|
367
|
+
status: "fail",
|
|
368
|
+
message: msg
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function runAuthList(input = {}) {
|
|
373
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
374
|
+
const importer = input.importer ?? defaultImporter$1;
|
|
375
|
+
const providers = listStoredProviders();
|
|
376
|
+
if (providers.length === 0) {
|
|
377
|
+
print("no stored tokens.");
|
|
378
|
+
print("run: holocron auth set <provider> <token>");
|
|
379
|
+
return {
|
|
380
|
+
status: "ok",
|
|
381
|
+
message: "none"
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
for (const provider of providers.sort()) {
|
|
385
|
+
const check = await runAuthCheck({
|
|
386
|
+
provider,
|
|
387
|
+
importer,
|
|
388
|
+
print: () => {}
|
|
389
|
+
});
|
|
390
|
+
print(` ${check.status === "ok" ? "✓" : check.status === "fail" ? "✗" : "·"} ${provider}${check.message ? ` — ${check.message}` : ""}`);
|
|
391
|
+
}
|
|
392
|
+
return { status: "ok" };
|
|
393
|
+
}
|
|
394
|
+
async function tryLoadHint(importer, packageName) {
|
|
395
|
+
try {
|
|
396
|
+
return (await importer(packageName)).AUTH_HINT ?? null;
|
|
397
|
+
} catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
9
402
|
//#region src/loader.ts
|
|
10
403
|
var LoaderError = class extends Error {
|
|
11
404
|
name = "LoaderError";
|
|
@@ -58,12 +451,23 @@ var PluginLoader = class {
|
|
|
58
451
|
});
|
|
59
452
|
if (typeof module.createPlugin !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\``);
|
|
60
453
|
const factory = module.createPlugin({
|
|
454
|
+
...this.projectDefaults(),
|
|
61
455
|
...this.context,
|
|
62
456
|
...tuple.options
|
|
63
457
|
}).capabilities[key];
|
|
64
458
|
if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
|
|
65
459
|
return factory();
|
|
66
460
|
}
|
|
461
|
+
/**
|
|
462
|
+
* Project-level defaults that get merged into every plugin's options
|
|
463
|
+
* unless overridden by the CLI context or per-plugin tuple options.
|
|
464
|
+
* See `.notes/tech-setup-and-config.spec.md` §Design.
|
|
465
|
+
*/
|
|
466
|
+
projectDefaults() {
|
|
467
|
+
const defaults = {};
|
|
468
|
+
if (this.config.project.repo) defaults.repo = this.config.project.repo;
|
|
469
|
+
return defaults;
|
|
470
|
+
}
|
|
67
471
|
};
|
|
68
472
|
/** Default importer — native dynamic import. */
|
|
69
473
|
const defaultImporter = async (pkg) => {
|
|
@@ -175,149 +579,2702 @@ async function smokeCheck(key, provider, impl) {
|
|
|
175
579
|
default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
176
580
|
}
|
|
177
581
|
} catch (err) {
|
|
178
|
-
return mk(key, provider, "fail", err instanceof Error ? err.message : String(err));
|
|
582
|
+
return mk(key, provider, "fail", err instanceof Error ? err.message : String(err));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function countResolvedLifecycle(report) {
|
|
586
|
+
return `${report.lifecycle.filter((l) => l.resolved).length}/${report.lifecycle.length} lifecycle slots ready`;
|
|
587
|
+
}
|
|
588
|
+
function mk(capability, provider, status, message) {
|
|
589
|
+
return {
|
|
590
|
+
capability,
|
|
591
|
+
provider,
|
|
592
|
+
status,
|
|
593
|
+
message
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function pad(s, width) {
|
|
597
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
598
|
+
}
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/commands/npm-bump-versions.ts
|
|
601
|
+
async function runNpmBumpVersions(input) {
|
|
602
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
603
|
+
const cwd = input.cwd ?? process.cwd();
|
|
604
|
+
const { version, dryRun = false } = input;
|
|
605
|
+
const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
606
|
+
const writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
|
|
607
|
+
const listDir = input.listDir ?? ((p) => readdirSync(p));
|
|
608
|
+
const isDir = input.isDir ?? ((p) => statSync(p).isDirectory());
|
|
609
|
+
const bumped = [];
|
|
610
|
+
const skipped = [];
|
|
611
|
+
print(`Bumping monorepo to ${version}${dryRun ? " (dry-run)" : ""}…`);
|
|
612
|
+
function bumpFile(absPath, label) {
|
|
613
|
+
let pkg;
|
|
614
|
+
try {
|
|
615
|
+
pkg = JSON.parse(readFile(absPath));
|
|
616
|
+
} catch {
|
|
617
|
+
print(` ✗ could not parse ${label}`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const old = pkg.version;
|
|
621
|
+
if (!dryRun) {
|
|
622
|
+
pkg.version = version;
|
|
623
|
+
writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
624
|
+
}
|
|
625
|
+
print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
|
|
626
|
+
bumped.push(label);
|
|
627
|
+
}
|
|
628
|
+
bumpFile(join(cwd, "package.json"), "root");
|
|
629
|
+
const packagesDir = join(cwd, "packages");
|
|
630
|
+
let entries;
|
|
631
|
+
try {
|
|
632
|
+
entries = listDir(packagesDir);
|
|
633
|
+
} catch {
|
|
634
|
+
return {
|
|
635
|
+
status: "fail",
|
|
636
|
+
bumped,
|
|
637
|
+
skipped,
|
|
638
|
+
message: `packages/ directory not found in ${cwd}`
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
for (const entry of entries) {
|
|
642
|
+
const pkgDir = join(packagesDir, entry);
|
|
643
|
+
try {
|
|
644
|
+
if (!isDir(pkgDir)) continue;
|
|
645
|
+
} catch {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const pkgFile = join(pkgDir, "package.json");
|
|
649
|
+
let pkg;
|
|
650
|
+
try {
|
|
651
|
+
pkg = JSON.parse(readFile(pkgFile));
|
|
652
|
+
} catch {
|
|
653
|
+
print(` ! skipping packages/${entry}: no package.json or malformed JSON`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (pkg.private) {
|
|
657
|
+
print(` · skipping private package packages/${entry}`);
|
|
658
|
+
skipped.push(`packages/${entry}`);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
bumpFile(pkgFile, `packages/${entry}`);
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
status: dryRun ? "dry-run" : "ok",
|
|
665
|
+
bumped,
|
|
666
|
+
skipped
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/templates/index.ts
|
|
671
|
+
/**
|
|
672
|
+
* All reusable workflow and composite action content bundled as string
|
|
673
|
+
* constants so the CLI can push them to theholocron/.github without
|
|
674
|
+
* needing filesystem access at runtime.
|
|
675
|
+
*
|
|
676
|
+
* These are the sources of truth — the YAML files in this directory
|
|
677
|
+
* have been removed in favour of these constants.
|
|
678
|
+
*/
|
|
679
|
+
const ACTIONS = {
|
|
680
|
+
"setup/action": `\
|
|
681
|
+
name: Setup
|
|
682
|
+
description: Prepare the environment and install project dependencies.
|
|
683
|
+
|
|
684
|
+
inputs:
|
|
685
|
+
node-version:
|
|
686
|
+
description: Node.js version
|
|
687
|
+
required: false
|
|
688
|
+
default: "22.x"
|
|
689
|
+
|
|
690
|
+
runs:
|
|
691
|
+
using: composite
|
|
692
|
+
|
|
693
|
+
steps:
|
|
694
|
+
- uses: theholocron/.github/.github/actions/setup-node@main
|
|
695
|
+
with:
|
|
696
|
+
node-version: \${{ inputs.node-version }}
|
|
697
|
+
|
|
698
|
+
- uses: theholocron/.github/.github/actions/install@main
|
|
699
|
+
`,
|
|
700
|
+
"install/action": `\
|
|
701
|
+
name: Install dependencies
|
|
702
|
+
description: Install project dependencies with pnpm frozen lockfile.
|
|
703
|
+
|
|
704
|
+
runs:
|
|
705
|
+
using: composite
|
|
706
|
+
|
|
707
|
+
steps:
|
|
708
|
+
- name: Install dependencies
|
|
709
|
+
shell: bash
|
|
710
|
+
run: pnpm install --frozen-lockfile
|
|
711
|
+
`,
|
|
712
|
+
"setup-node/action": `\
|
|
713
|
+
name: Setup Node
|
|
714
|
+
description: Install pnpm and Node.js with pnpm dependency caching.
|
|
715
|
+
|
|
716
|
+
inputs:
|
|
717
|
+
node-version:
|
|
718
|
+
description: Node.js version
|
|
719
|
+
required: false
|
|
720
|
+
default: "22.x"
|
|
721
|
+
|
|
722
|
+
runs:
|
|
723
|
+
using: composite
|
|
724
|
+
|
|
725
|
+
steps:
|
|
726
|
+
- name: Setup pnpm
|
|
727
|
+
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
|
728
|
+
|
|
729
|
+
- name: Setup Node.js
|
|
730
|
+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
731
|
+
with:
|
|
732
|
+
node-version: \${{ inputs.node-version }}
|
|
733
|
+
cache: pnpm
|
|
734
|
+
|
|
735
|
+
- name: Add node_modules/.bin to PATH
|
|
736
|
+
shell: bash
|
|
737
|
+
run: echo "$GITHUB_WORKSPACE/node_modules/.bin" >> $GITHUB_PATH
|
|
738
|
+
`
|
|
739
|
+
};
|
|
740
|
+
const REUSABLE_WORKFLOWS = {
|
|
741
|
+
audit: `\
|
|
742
|
+
name: Audit
|
|
743
|
+
|
|
744
|
+
on: # yamllint disable-line rule:truthy
|
|
745
|
+
workflow_call:
|
|
746
|
+
inputs:
|
|
747
|
+
build-script:
|
|
748
|
+
description: Script to build before analyzing bundle size
|
|
749
|
+
type: string
|
|
750
|
+
required: false
|
|
751
|
+
default: pnpm build
|
|
752
|
+
secrets:
|
|
753
|
+
BUNDLEWATCH_GITHUB_TOKEN:
|
|
754
|
+
required: true
|
|
755
|
+
|
|
756
|
+
jobs:
|
|
757
|
+
bundle-size:
|
|
758
|
+
name: Audit the bundle size
|
|
759
|
+
permissions:
|
|
760
|
+
contents: read
|
|
761
|
+
runs-on: ubuntu-latest
|
|
762
|
+
timeout-minutes: 15
|
|
763
|
+
concurrency:
|
|
764
|
+
group: audit-\${{ github.ref }}
|
|
765
|
+
cancel-in-progress: true
|
|
766
|
+
steps:
|
|
767
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
768
|
+
name: Checkout repository
|
|
769
|
+
with:
|
|
770
|
+
fetch-depth: 0
|
|
771
|
+
|
|
772
|
+
- uses: theholocron/.github/.github/actions/setup@main
|
|
773
|
+
name: Setup
|
|
774
|
+
|
|
775
|
+
- uses: jackyef/bundlewatch-gh-action@01f51133d3580a6daa046ca83eb233d79735e1c1 # 0.3.0
|
|
776
|
+
name: Analyze using BundleWatch
|
|
777
|
+
with:
|
|
778
|
+
build-script: \${{ inputs.build-script }}
|
|
779
|
+
bundlewatch-github-token: \${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
|
|
780
|
+
`,
|
|
781
|
+
"bookkeeping-pr": `\
|
|
782
|
+
name: PR Bookkeeping
|
|
783
|
+
|
|
784
|
+
on: # yamllint disable-line rule:truthy
|
|
785
|
+
workflow_call:
|
|
786
|
+
inputs:
|
|
787
|
+
configuration-path:
|
|
788
|
+
description: Path to the labeler configuration file in the calling repo
|
|
789
|
+
type: string
|
|
790
|
+
required: false
|
|
791
|
+
default: .github/labeler.yml
|
|
792
|
+
|
|
793
|
+
jobs:
|
|
794
|
+
label:
|
|
795
|
+
name: Add Labels to PRs
|
|
796
|
+
permissions:
|
|
797
|
+
contents: read
|
|
798
|
+
pull-requests: write
|
|
799
|
+
runs-on: ubuntu-latest
|
|
800
|
+
timeout-minutes: 5
|
|
801
|
+
concurrency:
|
|
802
|
+
group: bookkeeping-pr-\${{ github.event.pull_request.number }}
|
|
803
|
+
cancel-in-progress: true
|
|
804
|
+
steps:
|
|
805
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
806
|
+
with:
|
|
807
|
+
sparse-checkout: \${{ inputs.configuration-path || '.github/labeler.yml' }}
|
|
808
|
+
sparse-checkout-cone-mode: false
|
|
809
|
+
|
|
810
|
+
- uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
|
|
811
|
+
if: \${{ hashFiles(inputs.configuration-path || '.github/labeler.yml') != '' }}
|
|
812
|
+
with:
|
|
813
|
+
# Fall back to default path when triggered directly (not via workflow_call)
|
|
814
|
+
# because inputs.* defaults only apply on workflow_call events.
|
|
815
|
+
configuration-path: \${{ inputs.configuration-path || '.github/labeler.yml' }}
|
|
816
|
+
include-title: 1
|
|
817
|
+
include-body: 0
|
|
818
|
+
sync-labels: 1
|
|
819
|
+
enable-versioned-regex: 0
|
|
820
|
+
repo-token: \${{ github.token }}
|
|
821
|
+
`,
|
|
822
|
+
codeql: `\
|
|
823
|
+
name: CodeQL
|
|
824
|
+
|
|
825
|
+
on: # yamllint disable-line rule:truthy
|
|
826
|
+
workflow_call:
|
|
827
|
+
inputs:
|
|
828
|
+
language:
|
|
829
|
+
description: CodeQL language to analyze
|
|
830
|
+
type: string
|
|
831
|
+
required: false
|
|
832
|
+
default: javascript-typescript
|
|
833
|
+
|
|
834
|
+
jobs:
|
|
835
|
+
analyze:
|
|
836
|
+
name: Analyze (\${{ inputs.language }})
|
|
837
|
+
permissions:
|
|
838
|
+
actions: read
|
|
839
|
+
contents: read
|
|
840
|
+
security-events: write
|
|
841
|
+
runs-on: ubuntu-latest
|
|
842
|
+
timeout-minutes: 45
|
|
843
|
+
# Do not cancel in-progress security scans.
|
|
844
|
+
concurrency:
|
|
845
|
+
group: codeql-\${{ github.ref }}
|
|
846
|
+
cancel-in-progress: false
|
|
847
|
+
steps:
|
|
848
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
849
|
+
name: Checkout repository
|
|
850
|
+
|
|
851
|
+
- uses: github/codeql-action/init@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
|
|
852
|
+
name: Initialize CodeQL
|
|
853
|
+
with:
|
|
854
|
+
languages: \${{ inputs.language }}
|
|
855
|
+
|
|
856
|
+
- uses: github/codeql-action/autobuild@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
|
|
857
|
+
name: Autobuild
|
|
858
|
+
|
|
859
|
+
- uses: github/codeql-action/analyze@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3
|
|
860
|
+
name: Analyze
|
|
861
|
+
with:
|
|
862
|
+
category: /language:\${{ inputs.language }}
|
|
863
|
+
`,
|
|
864
|
+
dependencies: `\
|
|
865
|
+
name: Dependencies
|
|
866
|
+
|
|
867
|
+
on: # yamllint disable-line rule:truthy
|
|
868
|
+
workflow_call:
|
|
869
|
+
secrets:
|
|
870
|
+
merge-token:
|
|
871
|
+
description: >
|
|
872
|
+
Optional privileged token for auto-merge. Falls back to GITHUB_TOKEN.
|
|
873
|
+
Required when branch protection enforces required reviews — GITHUB_TOKEN
|
|
874
|
+
cannot approve its own PRs.
|
|
875
|
+
required: false
|
|
876
|
+
|
|
877
|
+
jobs:
|
|
878
|
+
dependabot:
|
|
879
|
+
name: Update the dependencies
|
|
880
|
+
permissions:
|
|
881
|
+
contents: write
|
|
882
|
+
pull-requests: write
|
|
883
|
+
runs-on: ubuntu-latest
|
|
884
|
+
timeout-minutes: 5
|
|
885
|
+
concurrency:
|
|
886
|
+
group: dependencies-\${{ github.event.pull_request.number }}
|
|
887
|
+
cancel-in-progress: true
|
|
888
|
+
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
|
889
|
+
steps:
|
|
890
|
+
- uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2
|
|
891
|
+
name: Fetch Dependabot metadata
|
|
892
|
+
id: metadata
|
|
893
|
+
|
|
894
|
+
- run: gh pr merge --auto --squash "$PR_URL"
|
|
895
|
+
# --squash is intentional: repoPolicy sets allow_merge_commit: false,
|
|
896
|
+
# so --merge would fail on any repo using the standard preset.
|
|
897
|
+
name: Enable auto-merge for Dependabot PRs
|
|
898
|
+
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
|
|
899
|
+
env:
|
|
900
|
+
PR_URL: \${{ github.event.pull_request.html_url }}
|
|
901
|
+
GH_TOKEN: \${{ secrets.merge-token || github.token }}
|
|
902
|
+
`,
|
|
903
|
+
greetings: `\
|
|
904
|
+
name: Greetings
|
|
905
|
+
|
|
906
|
+
on: # yamllint disable-line rule:truthy
|
|
907
|
+
workflow_call:
|
|
908
|
+
|
|
909
|
+
jobs:
|
|
910
|
+
greeting:
|
|
911
|
+
name: Greet first-time contributors
|
|
912
|
+
permissions:
|
|
913
|
+
issues: write
|
|
914
|
+
pull-requests: write
|
|
915
|
+
runs-on: ubuntu-latest
|
|
916
|
+
timeout-minutes: 5
|
|
917
|
+
# Group by the issue/PR number so duplicate events don't race each other.
|
|
918
|
+
concurrency:
|
|
919
|
+
group: greetings-\${{ github.event.issue.number || github.event.pull_request.number }}
|
|
920
|
+
cancel-in-progress: false
|
|
921
|
+
steps:
|
|
922
|
+
- uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
|
923
|
+
name: Greet on first contribution
|
|
924
|
+
with:
|
|
925
|
+
script: |
|
|
926
|
+
// Only greet on the initial open — ignore synchronize, reopened, etc.
|
|
927
|
+
if (context.payload.action !== 'opened') return;
|
|
928
|
+
|
|
929
|
+
const actor = context.actor;
|
|
930
|
+
const { owner, repo } = context.repo;
|
|
931
|
+
|
|
932
|
+
// Payload inspection is more reliable than context.eventName for detecting
|
|
933
|
+
// whether this is an issue vs. PR event — works regardless of how GitHub
|
|
934
|
+
// propagates event names through workflow_call chains.
|
|
935
|
+
const isIssue = !!context.payload.issue && !context.payload.pull_request;
|
|
936
|
+
// listForRepo returns both issues and PRs (GitHub treats PRs as issues),
|
|
937
|
+
// sorted newest-first. Filter by type to track first-issue and first-PR
|
|
938
|
+
// independently, and avoid search-index eventual-consistency lag.
|
|
939
|
+
const { data: recent } = await github.rest.issues.listForRepo({
|
|
940
|
+
owner, repo,
|
|
941
|
+
creator: actor,
|
|
942
|
+
state: 'all',
|
|
943
|
+
per_page: 100
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
const sameType = recent.filter(item =>
|
|
947
|
+
isIssue ? !item.pull_request : !!item.pull_request
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
if (sameType.length !== 1) return;
|
|
951
|
+
const body = isIssue
|
|
952
|
+
? \`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.\`
|
|
953
|
+
: \`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.\`;
|
|
954
|
+
|
|
955
|
+
await github.rest.issues.createComment({
|
|
956
|
+
owner,
|
|
957
|
+
repo,
|
|
958
|
+
issue_number: context.issue.number,
|
|
959
|
+
body
|
|
960
|
+
});
|
|
961
|
+
`,
|
|
962
|
+
lint: `\
|
|
963
|
+
name: Lint
|
|
964
|
+
|
|
965
|
+
on: # yamllint disable-line rule:truthy
|
|
966
|
+
workflow_call:
|
|
967
|
+
inputs:
|
|
968
|
+
prettier-config:
|
|
969
|
+
type: string
|
|
970
|
+
required: false
|
|
971
|
+
default: prettier.config.js
|
|
972
|
+
yaml-config:
|
|
973
|
+
type: string
|
|
974
|
+
required: false
|
|
975
|
+
default: yamllint.config.yml
|
|
976
|
+
enable-auto-commit:
|
|
977
|
+
description: Auto-commit super-linter fixes via GPG-signed commit
|
|
978
|
+
type: boolean
|
|
979
|
+
required: false
|
|
980
|
+
default: false
|
|
981
|
+
secrets:
|
|
982
|
+
SUPER_LINTER_GPG_PRIVATE_KEY:
|
|
983
|
+
required: false
|
|
984
|
+
SUPER_LINTER_GPG_PASSPHRASE:
|
|
985
|
+
required: false
|
|
986
|
+
|
|
987
|
+
jobs:
|
|
988
|
+
super-lint:
|
|
989
|
+
name: Lint entire codebase
|
|
990
|
+
permissions:
|
|
991
|
+
contents: write
|
|
992
|
+
statuses: write
|
|
993
|
+
runs-on: ubuntu-latest
|
|
994
|
+
timeout-minutes: 30
|
|
995
|
+
steps:
|
|
996
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
997
|
+
name: Checkout repository
|
|
998
|
+
with:
|
|
999
|
+
fetch-depth: 0
|
|
1000
|
+
token: \${{ github.token }}
|
|
1001
|
+
|
|
1002
|
+
- uses: theholocron/.github/.github/actions/setup@main
|
|
1003
|
+
name: Setup
|
|
1004
|
+
|
|
1005
|
+
- uses: super-linter/super-linter/slim@b92721f792f381cedc002ecdbb9847a15ece5bb8 # v7.1.0
|
|
1006
|
+
name: Run Super Linter
|
|
1007
|
+
env:
|
|
1008
|
+
GITHUB_TOKEN: \${{ github.token }}
|
|
1009
|
+
ANNOTATE_ONLY: true
|
|
1010
|
+
DISABLE_COMMENTS: false
|
|
1011
|
+
IGNORE_GITIGNORED_FILES: true
|
|
1012
|
+
LINTER_RULES_PATH: /
|
|
1013
|
+
EDITORCONFIG_FILE_NAME: ".editorconfig-checker.json"
|
|
1014
|
+
FIX_ENV: true
|
|
1015
|
+
FIX_GRAPHQL_PRETTIER: true
|
|
1016
|
+
FIX_HTML_PRETTIER: true
|
|
1017
|
+
FIX_JAVASCRIPT_PRETTIER: true
|
|
1018
|
+
FIX_JSX_PRETTIER: true
|
|
1019
|
+
FIX_MARKDOWN_PRETTIER: true
|
|
1020
|
+
FIX_TSX: true
|
|
1021
|
+
FIX_TYPESCRIPT_PRETTIER: true
|
|
1022
|
+
PRETTIER_CONFIG: \${{ inputs.prettier-config }}
|
|
1023
|
+
VALIDATE_DOCKERFILE: true
|
|
1024
|
+
VALIDATE_EDITORCONFIG: true
|
|
1025
|
+
VALIDATE_ENV: true
|
|
1026
|
+
VALIDATE_GIT_COMMITLINT: true
|
|
1027
|
+
VALIDATE_GIT_MERGE_CONFLICT_MARKERS: true
|
|
1028
|
+
VALIDATE_GITHUB_ACTIONS: true
|
|
1029
|
+
VALIDATE_GITLEAKS: true
|
|
1030
|
+
VALIDATE_GRAPHQL_PRETTIER: true
|
|
1031
|
+
VALIDATE_HTML_PRETTIER: true
|
|
1032
|
+
VALIDATE_JAVASCRIPT_PRETTIER: true
|
|
1033
|
+
VALIDATE_JSX_PRETTIER: true
|
|
1034
|
+
VALIDATE_MARKDOWN_PRETTIER: true
|
|
1035
|
+
VALIDATE_TSX: true
|
|
1036
|
+
VALIDATE_TYPESCRIPT_PRETTIER: true
|
|
1037
|
+
VALIDATE_YAML: true
|
|
1038
|
+
YAML_CONFIG_FILE: \${{ inputs.yaml-config }}
|
|
1039
|
+
|
|
1040
|
+
- uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
|
|
1041
|
+
name: Import GPG Key
|
|
1042
|
+
# Conditions mirror auto-commit exactly — no point importing GPG if the
|
|
1043
|
+
# commit step will be skipped (fork PR, default branch, or secret unset).
|
|
1044
|
+
if: >
|
|
1045
|
+
inputs.enable-auto-commit == true &&
|
|
1046
|
+
github.event.pull_request != null &&
|
|
1047
|
+
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
1048
|
+
github.ref_name != github.event.repository.default_branch &&
|
|
1049
|
+
secrets.SUPER_LINTER_GPG_PRIVATE_KEY != ''
|
|
1050
|
+
with:
|
|
1051
|
+
git_user_signingkey: true
|
|
1052
|
+
git_commit_gpgsign: true
|
|
1053
|
+
GPG_PRIVATE_KEY: \${{ secrets.SUPER_LINTER_GPG_PRIVATE_KEY }}
|
|
1054
|
+
PASSPHRASE: \${{ secrets.SUPER_LINTER_GPG_PASSPHRASE }}
|
|
1055
|
+
|
|
1056
|
+
- uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5
|
|
1057
|
+
name: Commit and push linting fixes
|
|
1058
|
+
if: >
|
|
1059
|
+
inputs.enable-auto-commit == true &&
|
|
1060
|
+
github.event.pull_request != null &&
|
|
1061
|
+
github.event.pull_request.head.repo.full_name == github.repository &&
|
|
1062
|
+
github.ref_name != github.event.repository.default_branch &&
|
|
1063
|
+
secrets.SUPER_LINTER_GPG_PRIVATE_KEY != ''
|
|
1064
|
+
with:
|
|
1065
|
+
branch: \${{ github.event.pull_request.head.ref || github.head_ref || github.ref }}
|
|
1066
|
+
commit_message: "chore: fix linting issues"
|
|
1067
|
+
commit_options: "--no-verify --signoff"
|
|
1068
|
+
commit_user_name: super-linter
|
|
1069
|
+
commit_user_email: super-linter@super-linter.dev
|
|
1070
|
+
`,
|
|
1071
|
+
release: `\
|
|
1072
|
+
name: Release
|
|
1073
|
+
|
|
1074
|
+
# Semantic-release with OIDC Trusted Publishing — no NPM_TOKEN required.
|
|
1075
|
+
# The calling repo must have a .releaserc.json that configures branches,
|
|
1076
|
+
# plugins, and any publish options. npm@11+ is installed to support OIDC.
|
|
1077
|
+
|
|
1078
|
+
on: # yamllint disable-line rule:truthy
|
|
1079
|
+
workflow_call:
|
|
1080
|
+
inputs:
|
|
1081
|
+
run-build:
|
|
1082
|
+
description: Run \`pnpm build\` before releasing
|
|
1083
|
+
type: boolean
|
|
1084
|
+
required: false
|
|
1085
|
+
default: true
|
|
1086
|
+
|
|
1087
|
+
jobs:
|
|
1088
|
+
release:
|
|
1089
|
+
name: Semantic release
|
|
1090
|
+
permissions:
|
|
1091
|
+
contents: write
|
|
1092
|
+
id-token: write
|
|
1093
|
+
issues: write
|
|
1094
|
+
pull-requests: write
|
|
1095
|
+
runs-on: ubuntu-latest
|
|
1096
|
+
timeout-minutes: 30
|
|
1097
|
+
# Do not cancel in-progress releases — a partial release is worse than a slow one.
|
|
1098
|
+
concurrency:
|
|
1099
|
+
group: release-\${{ github.ref }}
|
|
1100
|
+
cancel-in-progress: false
|
|
1101
|
+
steps:
|
|
1102
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
1103
|
+
name: Checkout repository
|
|
1104
|
+
with:
|
|
1105
|
+
fetch-depth: 0
|
|
1106
|
+
token: \${{ github.token }}
|
|
1107
|
+
|
|
1108
|
+
- uses: theholocron/.github/.github/actions/setup@main
|
|
1109
|
+
name: Setup
|
|
1110
|
+
|
|
1111
|
+
- run: npm install -g npm@11 sigstore
|
|
1112
|
+
name: Upgrade npm for OIDC support
|
|
1113
|
+
# sigstore is required by libnpmpublish/provenance.js at module parse
|
|
1114
|
+
# time — before any config takes effect. Some npm 11.x builds stopped
|
|
1115
|
+
# bundling it; installing it globally into the same prefix ensures it
|
|
1116
|
+
# resolves regardless of npm version. (Discovered 2026-07-09.)
|
|
1117
|
+
|
|
1118
|
+
- run: pnpm build
|
|
1119
|
+
name: Build
|
|
1120
|
+
if: \${{ inputs.run-build == true }}
|
|
1121
|
+
|
|
1122
|
+
- run: npx semantic-release
|
|
1123
|
+
name: Release
|
|
1124
|
+
env:
|
|
1125
|
+
GITHUB_TOKEN: \${{ github.token }}
|
|
1126
|
+
NPM_CONFIG_PROVENANCE: true
|
|
1127
|
+
`,
|
|
1128
|
+
review: `\
|
|
1129
|
+
name: Review
|
|
1130
|
+
|
|
1131
|
+
# ReviewDog is the annotation layer — posts inline PR diff annotations.
|
|
1132
|
+
# Runs on pull_request only: inline annotations require PR context,
|
|
1133
|
+
# and branch protection ensures all changes go through PRs anyway.
|
|
1134
|
+
# super-linter (lint.yml) is the CI gate covering push + PR events.
|
|
1135
|
+
# Gitleaks and YAML are intentionally duplicated: super-linter gates
|
|
1136
|
+
# merges; ReviewDog surfaces exact line annotations in the PR diff.
|
|
1137
|
+
|
|
1138
|
+
on: # yamllint disable-line rule:truthy
|
|
1139
|
+
workflow_call:
|
|
1140
|
+
|
|
1141
|
+
concurrency:
|
|
1142
|
+
group: review-\${{ github.workflow }}-\${{ github.ref }}
|
|
1143
|
+
cancel-in-progress: true
|
|
1144
|
+
|
|
1145
|
+
jobs:
|
|
1146
|
+
reviewdog:
|
|
1147
|
+
name: Review PRs
|
|
1148
|
+
runs-on: ubuntu-latest
|
|
1149
|
+
timeout-minutes: 20
|
|
1150
|
+
permissions:
|
|
1151
|
+
contents: read
|
|
1152
|
+
checks: write
|
|
1153
|
+
pull-requests: write
|
|
1154
|
+
|
|
1155
|
+
steps:
|
|
1156
|
+
- name: Checkout repository
|
|
1157
|
+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
1158
|
+
|
|
1159
|
+
- name: Setup
|
|
1160
|
+
if: \${{ hashFiles('pnpm-lock.yaml') != '' }}
|
|
1161
|
+
uses: theholocron/.github/.github/actions/setup@main
|
|
1162
|
+
|
|
1163
|
+
- name: Install ReviewDog
|
|
1164
|
+
uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1
|
|
1165
|
+
with:
|
|
1166
|
+
reviewdog_version: latest
|
|
1167
|
+
|
|
1168
|
+
# Detect which tools are relevant for this repo, excluding node_modules.
|
|
1169
|
+
# hashFiles('**/*') recurses into node_modules/.pnpm and produces false
|
|
1170
|
+
# positives for repos that don't own those file types.
|
|
1171
|
+
# -print -quit stops find after the first match without a pipe, avoiding
|
|
1172
|
+
# the SIGPIPE/pipefail exit-141 that find|head-1 triggers under
|
|
1173
|
+
# GitHub Actions' default bash --noprofile --norc -e -o pipefail mode.
|
|
1174
|
+
- name: Detect project features
|
|
1175
|
+
id: detect
|
|
1176
|
+
shell: bash
|
|
1177
|
+
run: |
|
|
1178
|
+
has() { find . -not -path '*/node_modules/*' -name "$1" -print -quit 2>/dev/null | grep -q .; }
|
|
1179
|
+
has_ext() { find . -not -path '*/node_modules/*' -name "$1" -print -quit 2>/dev/null | grep -q .; }
|
|
1180
|
+
{ { has 'eslint.config.js' || has 'eslint.config.mjs' || has 'eslint.config.cjs' || \\
|
|
1181
|
+
has 'eslint.config.ts' || has '.eslintrc' || has '.eslintrc.js' || \\
|
|
1182
|
+
has '.eslintrc.cjs' || has '.eslintrc.json' || has '.eslintrc.yaml' || \\
|
|
1183
|
+
has '.eslintrc.yml'; } && grep -qF '"eslint":' package.json 2>/dev/null; } && echo "eslint=true" >> $GITHUB_OUTPUT || echo "eslint=false" >> $GITHUB_OUTPUT
|
|
1184
|
+
{ has 'tsconfig.json' && grep -qF '"typescript":' package.json 2>/dev/null; } && echo "tsconfig=true" >> $GITHUB_OUTPUT || echo "tsconfig=false" >> $GITHUB_OUTPUT
|
|
1185
|
+
has_ext '*.sh' && echo "shell=true" >> $GITHUB_OUTPUT || echo "shell=false" >> $GITHUB_OUTPUT
|
|
1186
|
+
has 'Dockerfile' || has_ext '*.Dockerfile' || has 'Containerfile' && \\
|
|
1187
|
+
echo "docker=true" >> $GITHUB_OUTPUT || echo "docker=false" >> $GITHUB_OUTPUT
|
|
1188
|
+
has_ext '.env*' && echo "dotenv=true" >> $GITHUB_OUTPUT || echo "dotenv=false" >> $GITHUB_OUTPUT
|
|
1189
|
+
has_ext '*.md' && echo "markdown=true" >> $GITHUB_OUTPUT || echo "markdown=false" >> $GITHUB_OUTPUT
|
|
1190
|
+
|
|
1191
|
+
#
|
|
1192
|
+
# Always applicable
|
|
1193
|
+
#
|
|
1194
|
+
|
|
1195
|
+
- name: Gitleaks (secrets)
|
|
1196
|
+
uses: reviewdog/action-gitleaks@2b7b5685e3e3eecddab5d30cfa04f18123031421 # v1
|
|
1197
|
+
with:
|
|
1198
|
+
reporter: github-pr-check
|
|
1199
|
+
|
|
1200
|
+
- name: YamlLint
|
|
1201
|
+
uses: reviewdog/action-yamllint@b5f7217d8c815ae374d1d55840d5e569d82f01f0 # v1
|
|
1202
|
+
with:
|
|
1203
|
+
reporter: github-pr-check
|
|
1204
|
+
yamllint_flags: >-
|
|
1205
|
+
\${{ hashFiles('yamllint.config.yml') != ''
|
|
1206
|
+
&& format('-c {0}/yamllint.config.yml {0}', github.workspace)
|
|
1207
|
+
|| github.workspace }}
|
|
1208
|
+
|
|
1209
|
+
- name: ActionLint (GitHub Actions)
|
|
1210
|
+
if: \${{ hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}
|
|
1211
|
+
uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1
|
|
1212
|
+
with:
|
|
1213
|
+
reporter: github-pr-check
|
|
1214
|
+
|
|
1215
|
+
#
|
|
1216
|
+
# TypeScript / JavaScript
|
|
1217
|
+
#
|
|
1218
|
+
|
|
1219
|
+
- name: ESLint
|
|
1220
|
+
if: steps.detect.outputs.eslint == 'true'
|
|
1221
|
+
uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0
|
|
1222
|
+
with:
|
|
1223
|
+
reporter: github-pr-check
|
|
1224
|
+
eslint_flags: .
|
|
1225
|
+
|
|
1226
|
+
- name: TypeScript
|
|
1227
|
+
if: steps.detect.outputs.tsconfig == 'true'
|
|
1228
|
+
uses: EPMatt/reviewdog-action-tsc@63d923a3c5b4497671940b8874f58a404e2351b5 # v1
|
|
1229
|
+
with:
|
|
1230
|
+
reporter: github-pr-check
|
|
1231
|
+
|
|
1232
|
+
#
|
|
1233
|
+
# Shell
|
|
1234
|
+
#
|
|
1235
|
+
|
|
1236
|
+
- name: ShellCheck
|
|
1237
|
+
if: steps.detect.outputs.shell == 'true'
|
|
1238
|
+
uses: reviewdog/action-shellcheck@4c07458293ac342d477251099501a718ae5ef86e # v1
|
|
1239
|
+
with:
|
|
1240
|
+
reporter: github-pr-check
|
|
1241
|
+
fail_level: none
|
|
1242
|
+
|
|
1243
|
+
#
|
|
1244
|
+
# Docker
|
|
1245
|
+
#
|
|
1246
|
+
|
|
1247
|
+
- name: Hadolint
|
|
1248
|
+
if: steps.detect.outputs.docker == 'true'
|
|
1249
|
+
uses: reviewdog/action-hadolint@1b2cfa6ba72072ad35158d7ff3aa49bbdc03506d # v1
|
|
1250
|
+
with:
|
|
1251
|
+
reporter: github-pr-check
|
|
1252
|
+
fail_level: none
|
|
1253
|
+
|
|
1254
|
+
#
|
|
1255
|
+
# Environment files
|
|
1256
|
+
#
|
|
1257
|
+
|
|
1258
|
+
- name: dotenv-linter
|
|
1259
|
+
if: steps.detect.outputs.dotenv == 'true'
|
|
1260
|
+
uses: dotenv-linter/action-dotenv-linter@21287e2624aaf2dc8da5dd8ccfe8e49c63501116 # v2
|
|
1261
|
+
with:
|
|
1262
|
+
reporter: github-code-suggestions
|
|
1263
|
+
|
|
1264
|
+
#
|
|
1265
|
+
# Documentation
|
|
1266
|
+
#
|
|
1267
|
+
|
|
1268
|
+
- name: Alex (inclusive language)
|
|
1269
|
+
if: steps.detect.outputs.markdown == 'true'
|
|
1270
|
+
uses: reviewdog/action-alex@347481655add010a2ae302df34b57c9bcfa0d6e4 # v1
|
|
1271
|
+
with:
|
|
1272
|
+
reporter: github-pr-check
|
|
1273
|
+
`,
|
|
1274
|
+
stale: `\
|
|
1275
|
+
name: Stale
|
|
1276
|
+
|
|
1277
|
+
on: # yamllint disable-line rule:truthy
|
|
1278
|
+
workflow_call:
|
|
1279
|
+
inputs:
|
|
1280
|
+
days-before-stale:
|
|
1281
|
+
description: Days of inactivity before an issue is marked stale
|
|
1282
|
+
type: number
|
|
1283
|
+
required: false
|
|
1284
|
+
default: 30
|
|
1285
|
+
days-before-close:
|
|
1286
|
+
description: Days of inactivity after stale label before closing
|
|
1287
|
+
type: number
|
|
1288
|
+
required: false
|
|
1289
|
+
default: 5
|
|
1290
|
+
|
|
1291
|
+
jobs:
|
|
1292
|
+
stale:
|
|
1293
|
+
name: Mark stale issues and pull requests
|
|
1294
|
+
permissions:
|
|
1295
|
+
contents: write
|
|
1296
|
+
issues: write
|
|
1297
|
+
pull-requests: write
|
|
1298
|
+
runs-on: ubuntu-latest
|
|
1299
|
+
timeout-minutes: 10
|
|
1300
|
+
steps:
|
|
1301
|
+
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
|
1302
|
+
name: Run Stale
|
|
1303
|
+
with:
|
|
1304
|
+
close-issue-message: >
|
|
1305
|
+
This issue was closed because it has been stalled for
|
|
1306
|
+
\${{ inputs.days-before-close }} days with no activity.
|
|
1307
|
+
days-before-close: \${{ inputs.days-before-close }}
|
|
1308
|
+
days-before-stale: \${{ inputs.days-before-stale }}
|
|
1309
|
+
exempt-all-pr-milestones: true
|
|
1310
|
+
stale-issue-label: wontfix
|
|
1311
|
+
stale-issue-message: >
|
|
1312
|
+
This issue is stale because it has been open \${{ inputs.days-before-stale }}
|
|
1313
|
+
days with no activity. Remove the stale label or comment, or this will be
|
|
1314
|
+
closed in \${{ inputs.days-before-close }} days.
|
|
1315
|
+
stale-pr-label: wontfix
|
|
1316
|
+
stale-pr-message: >
|
|
1317
|
+
This PR is stale because it has been open \${{ inputs.days-before-stale }}
|
|
1318
|
+
days with no activity. Remove the stale label or comment, or this will be
|
|
1319
|
+
closed in \${{ inputs.days-before-close }} days.
|
|
1320
|
+
`,
|
|
1321
|
+
test: `\
|
|
1322
|
+
name: Test
|
|
1323
|
+
|
|
1324
|
+
on: # yamllint disable-line rule:truthy
|
|
1325
|
+
workflow_call:
|
|
1326
|
+
secrets:
|
|
1327
|
+
CODECOV_TOKEN:
|
|
1328
|
+
required: false
|
|
1329
|
+
|
|
1330
|
+
jobs:
|
|
1331
|
+
unit:
|
|
1332
|
+
name: Run tests and collect coverage
|
|
1333
|
+
permissions:
|
|
1334
|
+
contents: read
|
|
1335
|
+
runs-on: ubuntu-latest
|
|
1336
|
+
timeout-minutes: 15
|
|
1337
|
+
steps:
|
|
1338
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
1339
|
+
name: Checkout repository
|
|
1340
|
+
with:
|
|
1341
|
+
fetch-depth: 0
|
|
1342
|
+
|
|
1343
|
+
- uses: theholocron/.github/.github/actions/setup@main
|
|
1344
|
+
name: Setup
|
|
1345
|
+
|
|
1346
|
+
- run: pnpm test -- --coverage
|
|
1347
|
+
name: Run tests with coverage
|
|
1348
|
+
|
|
1349
|
+
- uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
|
|
1350
|
+
name: Upload results to Codecov
|
|
1351
|
+
with:
|
|
1352
|
+
token: \${{ secrets.CODECOV_TOKEN }}
|
|
1353
|
+
`,
|
|
1354
|
+
"sync-github": `\
|
|
1355
|
+
name: Sync GitHub Templates
|
|
1356
|
+
|
|
1357
|
+
# Builds the holocron CLI from source and pushes updated workflow templates
|
|
1358
|
+
# and composite actions to downstream .github repos. Runs whenever the
|
|
1359
|
+
# template source files change on main or alpha.
|
|
1360
|
+
#
|
|
1361
|
+
# Secrets required:
|
|
1362
|
+
# SYNC_TOKEN — fine-grained PAT or GitHub App token with Contents write
|
|
1363
|
+
# access to the primary and secondary repos.
|
|
1364
|
+
|
|
1365
|
+
on: # yamllint disable-line rule:truthy
|
|
1366
|
+
workflow_call:
|
|
1367
|
+
inputs:
|
|
1368
|
+
primary-repo:
|
|
1369
|
+
description: >
|
|
1370
|
+
Primary .github repo — receives composite actions, reusable workflows,
|
|
1371
|
+
and thin-caller templates. Requires a PR (branch protection assumed).
|
|
1372
|
+
type: string
|
|
1373
|
+
required: false
|
|
1374
|
+
default: theholocron/.github
|
|
1375
|
+
secondary-repos:
|
|
1376
|
+
description: >
|
|
1377
|
+
Space-separated list of secondary repos (reusable workflows + thin
|
|
1378
|
+
callers only, no composite actions). Pushed directly to main.
|
|
1379
|
+
type: string
|
|
1380
|
+
required: false
|
|
1381
|
+
default: ""
|
|
1382
|
+
sync-branch:
|
|
1383
|
+
description: Branch name used for the primary-repo PR
|
|
1384
|
+
type: string
|
|
1385
|
+
required: false
|
|
1386
|
+
default: chore/sync-templates
|
|
1387
|
+
secrets:
|
|
1388
|
+
SYNC_TOKEN:
|
|
1389
|
+
required: true
|
|
1390
|
+
|
|
1391
|
+
jobs:
|
|
1392
|
+
sync:
|
|
1393
|
+
name: Sync templates
|
|
1394
|
+
runs-on: ubuntu-latest
|
|
1395
|
+
timeout-minutes: 15
|
|
1396
|
+
permissions:
|
|
1397
|
+
contents: read
|
|
1398
|
+
steps:
|
|
1399
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
1400
|
+
name: Checkout repository
|
|
1401
|
+
|
|
1402
|
+
- uses: theholocron/.github/.github/actions/setup@main
|
|
1403
|
+
name: Setup
|
|
1404
|
+
|
|
1405
|
+
- run: pnpm build
|
|
1406
|
+
name: Build CLI
|
|
1407
|
+
|
|
1408
|
+
- name: Validate generated workflows
|
|
1409
|
+
run: |
|
|
1410
|
+
node packages/cli/dist/cli.mjs sync-github \\
|
|
1411
|
+
--repo "$PRIMARY_REPO" \\
|
|
1412
|
+
--output-dir /tmp/sync-validate
|
|
1413
|
+
curl -fsSL https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz \\
|
|
1414
|
+
| tar -xz -C /tmp actionlint
|
|
1415
|
+
/tmp/actionlint /tmp/sync-validate/.github/workflows/*.yml
|
|
1416
|
+
env:
|
|
1417
|
+
PRIMARY_REPO: \${{ inputs.primary-repo }}
|
|
1418
|
+
|
|
1419
|
+
- name: Sync primary repo (PR)
|
|
1420
|
+
run: >
|
|
1421
|
+
node packages/cli/dist/cli.mjs sync-github
|
|
1422
|
+
--repo "$PRIMARY_REPO"
|
|
1423
|
+
--branch "$SYNC_BRANCH"
|
|
1424
|
+
--pr
|
|
1425
|
+
env:
|
|
1426
|
+
GITHUB_TOKEN: \${{ secrets.SYNC_TOKEN }}
|
|
1427
|
+
PRIMARY_REPO: \${{ inputs.primary-repo }}
|
|
1428
|
+
SYNC_BRANCH: \${{ inputs.sync-branch }}
|
|
1429
|
+
|
|
1430
|
+
- name: Sync secondary repos (direct push)
|
|
1431
|
+
if: \${{ inputs.secondary-repos != '' }}
|
|
1432
|
+
run: |
|
|
1433
|
+
for repo in $SECONDARY_REPOS; do
|
|
1434
|
+
node packages/cli/dist/cli.mjs sync-github --repo "$repo"
|
|
1435
|
+
done
|
|
1436
|
+
env:
|
|
1437
|
+
GITHUB_TOKEN: \${{ secrets.SYNC_TOKEN }}
|
|
1438
|
+
SECONDARY_REPOS: \${{ inputs.secondary-repos }}
|
|
1439
|
+
`,
|
|
1440
|
+
typecheck: `\
|
|
1441
|
+
name: Typecheck
|
|
1442
|
+
|
|
1443
|
+
on: # yamllint disable-line rule:truthy
|
|
1444
|
+
workflow_call:
|
|
1445
|
+
|
|
1446
|
+
jobs:
|
|
1447
|
+
typecheck:
|
|
1448
|
+
name: tsc --noEmit
|
|
1449
|
+
permissions:
|
|
1450
|
+
contents: read
|
|
1451
|
+
runs-on: ubuntu-latest
|
|
1452
|
+
timeout-minutes: 10
|
|
1453
|
+
steps:
|
|
1454
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
1455
|
+
name: Checkout repository
|
|
1456
|
+
|
|
1457
|
+
- uses: theholocron/.github/.github/actions/setup@main
|
|
1458
|
+
name: Setup
|
|
1459
|
+
|
|
1460
|
+
- run: pnpm typecheck
|
|
1461
|
+
name: Type check
|
|
1462
|
+
`
|
|
1463
|
+
};
|
|
1464
|
+
const WORKFLOW_TEMPLATE_PROPERTIES = { "sync-github": JSON.stringify({
|
|
1465
|
+
name: "Sync GitHub Templates",
|
|
1466
|
+
description: "Sync workflow templates and composite actions from the holocron CLI.",
|
|
1467
|
+
iconName: "octicon sync"
|
|
1468
|
+
}, null, 2) };
|
|
1469
|
+
//#endregion
|
|
1470
|
+
//#region src/commands/setup-workflows.ts
|
|
1471
|
+
/**
|
|
1472
|
+
* Thin workflow wrapper templates for `holocron setup`.
|
|
1473
|
+
*
|
|
1474
|
+
* Each entry is a complete `.github/workflows/<name>.yml` that delegates
|
|
1475
|
+
* to the corresponding reusable `ci-<name>.yml` in `theholocron/.github`.
|
|
1476
|
+
* Files are overwritten on each setup run — they are generated artifacts.
|
|
1477
|
+
*/
|
|
1478
|
+
const WORKFLOW_REPO = "theholocron/.github";
|
|
1479
|
+
const WORKFLOW_REF = "main";
|
|
1480
|
+
function ref(name) {
|
|
1481
|
+
return `${WORKFLOW_REPO}/.github/workflows/${name}.yml@${WORKFLOW_REF}`;
|
|
1482
|
+
}
|
|
1483
|
+
/** Header prepended when holocron setup writes a thin caller to a repo. */
|
|
1484
|
+
function workflowHeader() {
|
|
1485
|
+
return [
|
|
1486
|
+
`# AUTO-GENERATED — do not edit directly.`,
|
|
1487
|
+
`# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
|
|
1488
|
+
`# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
1489
|
+
`# Tool: holocron setup`,
|
|
1490
|
+
`# Changes: run \`holocron setup\` to regenerate.`,
|
|
1491
|
+
``
|
|
1492
|
+
].join("\n");
|
|
1493
|
+
}
|
|
1494
|
+
const WORKFLOW_TEMPLATES = {
|
|
1495
|
+
lint: `\
|
|
1496
|
+
name: Lint
|
|
1497
|
+
|
|
1498
|
+
on: # yamllint disable-line rule:truthy
|
|
1499
|
+
push:
|
|
1500
|
+
branches: [main, alpha]
|
|
1501
|
+
pull_request:
|
|
1502
|
+
|
|
1503
|
+
concurrency:
|
|
1504
|
+
group: lint-\${{ github.ref }}
|
|
1505
|
+
cancel-in-progress: true
|
|
1506
|
+
|
|
1507
|
+
permissions:
|
|
1508
|
+
contents: write
|
|
1509
|
+
statuses: write
|
|
1510
|
+
|
|
1511
|
+
jobs:
|
|
1512
|
+
lint:
|
|
1513
|
+
name: Lint
|
|
1514
|
+
uses: ${ref("lint")}
|
|
1515
|
+
secrets: inherit
|
|
1516
|
+
with:
|
|
1517
|
+
enable-auto-commit: true
|
|
1518
|
+
`,
|
|
1519
|
+
test: `\
|
|
1520
|
+
name: Test
|
|
1521
|
+
|
|
1522
|
+
on: # yamllint disable-line rule:truthy
|
|
1523
|
+
push:
|
|
1524
|
+
branches: [main, alpha]
|
|
1525
|
+
pull_request:
|
|
1526
|
+
|
|
1527
|
+
concurrency:
|
|
1528
|
+
group: test-\${{ github.ref }}
|
|
1529
|
+
cancel-in-progress: true
|
|
1530
|
+
|
|
1531
|
+
permissions:
|
|
1532
|
+
contents: read
|
|
1533
|
+
|
|
1534
|
+
jobs:
|
|
1535
|
+
test:
|
|
1536
|
+
name: Test
|
|
1537
|
+
uses: ${ref("test")}
|
|
1538
|
+
secrets: inherit
|
|
1539
|
+
`,
|
|
1540
|
+
typecheck: `\
|
|
1541
|
+
name: Typecheck
|
|
1542
|
+
|
|
1543
|
+
on: # yamllint disable-line rule:truthy
|
|
1544
|
+
push:
|
|
1545
|
+
branches: [main, alpha]
|
|
1546
|
+
pull_request:
|
|
1547
|
+
|
|
1548
|
+
concurrency:
|
|
1549
|
+
group: typecheck-\${{ github.ref }}
|
|
1550
|
+
cancel-in-progress: true
|
|
1551
|
+
|
|
1552
|
+
permissions:
|
|
1553
|
+
contents: read
|
|
1554
|
+
|
|
1555
|
+
jobs:
|
|
1556
|
+
typecheck:
|
|
1557
|
+
name: Typecheck
|
|
1558
|
+
uses: ${ref("typecheck")}
|
|
1559
|
+
secrets: inherit
|
|
1560
|
+
`,
|
|
1561
|
+
codeql: `\
|
|
1562
|
+
name: CodeQL
|
|
1563
|
+
|
|
1564
|
+
on: # yamllint disable-line rule:truthy
|
|
1565
|
+
push:
|
|
1566
|
+
branches:
|
|
1567
|
+
- main
|
|
1568
|
+
pull_request:
|
|
1569
|
+
branches:
|
|
1570
|
+
- main
|
|
1571
|
+
schedule:
|
|
1572
|
+
- cron: "0 0 * * 1"
|
|
1573
|
+
|
|
1574
|
+
permissions:
|
|
1575
|
+
actions: read
|
|
1576
|
+
contents: read
|
|
1577
|
+
security-events: write
|
|
1578
|
+
|
|
1579
|
+
jobs:
|
|
1580
|
+
codeql:
|
|
1581
|
+
uses: ${ref("codeql")}
|
|
1582
|
+
secrets: inherit
|
|
1583
|
+
`,
|
|
1584
|
+
review: `\
|
|
1585
|
+
name: Review
|
|
1586
|
+
|
|
1587
|
+
on: # yamllint disable-line rule:truthy
|
|
1588
|
+
pull_request:
|
|
1589
|
+
|
|
1590
|
+
concurrency:
|
|
1591
|
+
group: review-\${{ github.ref }}
|
|
1592
|
+
cancel-in-progress: true
|
|
1593
|
+
|
|
1594
|
+
permissions:
|
|
1595
|
+
contents: read
|
|
1596
|
+
checks: write
|
|
1597
|
+
pull-requests: write
|
|
1598
|
+
|
|
1599
|
+
jobs:
|
|
1600
|
+
review:
|
|
1601
|
+
name: Review
|
|
1602
|
+
uses: ${ref("review")}
|
|
1603
|
+
secrets: inherit
|
|
1604
|
+
`,
|
|
1605
|
+
release: `\
|
|
1606
|
+
name: Release
|
|
1607
|
+
|
|
1608
|
+
on: # yamllint disable-line rule:truthy
|
|
1609
|
+
push:
|
|
1610
|
+
branches:
|
|
1611
|
+
- main
|
|
1612
|
+
- alpha
|
|
1613
|
+
workflow_dispatch:
|
|
1614
|
+
|
|
1615
|
+
permissions:
|
|
1616
|
+
contents: write
|
|
1617
|
+
id-token: write
|
|
1618
|
+
issues: write
|
|
1619
|
+
pull-requests: write
|
|
1620
|
+
|
|
1621
|
+
concurrency:
|
|
1622
|
+
group: \${{ github.workflow }}-\${{ github.ref }}
|
|
1623
|
+
cancel-in-progress: false
|
|
1624
|
+
|
|
1625
|
+
jobs:
|
|
1626
|
+
release:
|
|
1627
|
+
uses: ${ref("release")}
|
|
1628
|
+
secrets: inherit
|
|
1629
|
+
`,
|
|
1630
|
+
stale: `\
|
|
1631
|
+
name: Stale
|
|
1632
|
+
|
|
1633
|
+
on: # yamllint disable-line rule:truthy
|
|
1634
|
+
schedule:
|
|
1635
|
+
- cron: "30 1 * * *"
|
|
1636
|
+
|
|
1637
|
+
permissions:
|
|
1638
|
+
contents: write
|
|
1639
|
+
issues: write
|
|
1640
|
+
pull-requests: write
|
|
1641
|
+
|
|
1642
|
+
jobs:
|
|
1643
|
+
stale:
|
|
1644
|
+
uses: ${ref("stale")}
|
|
1645
|
+
secrets: inherit
|
|
1646
|
+
`,
|
|
1647
|
+
greetings: `\
|
|
1648
|
+
name: Greetings
|
|
1649
|
+
|
|
1650
|
+
on: # yamllint disable-line rule:truthy
|
|
1651
|
+
pull_request:
|
|
1652
|
+
issues:
|
|
1653
|
+
|
|
1654
|
+
permissions:
|
|
1655
|
+
issues: write
|
|
1656
|
+
pull-requests: write
|
|
1657
|
+
|
|
1658
|
+
jobs:
|
|
1659
|
+
greetings:
|
|
1660
|
+
uses: ${ref("greetings")}
|
|
1661
|
+
secrets: inherit
|
|
1662
|
+
`,
|
|
1663
|
+
dependencies: `\
|
|
1664
|
+
name: Dependencies
|
|
1665
|
+
|
|
1666
|
+
on: # yamllint disable-line rule:truthy
|
|
1667
|
+
pull_request:
|
|
1668
|
+
|
|
1669
|
+
permissions:
|
|
1670
|
+
contents: write
|
|
1671
|
+
pull-requests: write
|
|
1672
|
+
|
|
1673
|
+
jobs:
|
|
1674
|
+
dependencies:
|
|
1675
|
+
uses: ${ref("dependencies")}
|
|
1676
|
+
secrets: inherit
|
|
1677
|
+
`,
|
|
1678
|
+
"bookkeeping-pr": `\
|
|
1679
|
+
name: PR Bookkeeping
|
|
1680
|
+
|
|
1681
|
+
on: # yamllint disable-line rule:truthy
|
|
1682
|
+
pull_request:
|
|
1683
|
+
types:
|
|
1684
|
+
- opened
|
|
1685
|
+
- edited
|
|
1686
|
+
|
|
1687
|
+
permissions:
|
|
1688
|
+
contents: read
|
|
1689
|
+
pull-requests: write
|
|
1690
|
+
|
|
1691
|
+
jobs:
|
|
1692
|
+
bookkeeping:
|
|
1693
|
+
uses: ${ref("bookkeeping-pr")}
|
|
1694
|
+
secrets: inherit
|
|
1695
|
+
`,
|
|
1696
|
+
audit: `\
|
|
1697
|
+
name: Audit
|
|
1698
|
+
|
|
1699
|
+
on: # yamllint disable-line rule:truthy
|
|
1700
|
+
push:
|
|
1701
|
+
branches: [main, alpha]
|
|
1702
|
+
pull_request:
|
|
1703
|
+
|
|
1704
|
+
permissions:
|
|
1705
|
+
contents: read
|
|
1706
|
+
|
|
1707
|
+
jobs:
|
|
1708
|
+
audit:
|
|
1709
|
+
uses: ${ref("audit")}
|
|
1710
|
+
secrets: inherit
|
|
1711
|
+
`,
|
|
1712
|
+
"sync-github": `\
|
|
1713
|
+
name: Sync GitHub Templates
|
|
1714
|
+
|
|
1715
|
+
on: # yamllint disable-line rule:truthy
|
|
1716
|
+
push:
|
|
1717
|
+
branches: [main, alpha]
|
|
1718
|
+
paths:
|
|
1719
|
+
- packages/cli/src/templates/index.ts
|
|
1720
|
+
- packages/cli/src/commands/setup-workflows.ts
|
|
1721
|
+
|
|
1722
|
+
concurrency:
|
|
1723
|
+
group: sync-github-\${{ github.ref }}
|
|
1724
|
+
cancel-in-progress: true
|
|
1725
|
+
|
|
1726
|
+
permissions:
|
|
1727
|
+
contents: read
|
|
1728
|
+
|
|
1729
|
+
jobs:
|
|
1730
|
+
sync:
|
|
1731
|
+
name: Sync
|
|
1732
|
+
uses: ${ref("sync-github")}
|
|
1733
|
+
with:
|
|
1734
|
+
secondary-repos: theholocron/.github-private
|
|
1735
|
+
secrets:
|
|
1736
|
+
SYNC_TOKEN: \${{ secrets.SYNC_TOKEN }}
|
|
1737
|
+
`
|
|
1738
|
+
};
|
|
1739
|
+
const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
|
|
1740
|
+
/**
|
|
1741
|
+
* Generate the thin caller content for a workflow, optionally injecting
|
|
1742
|
+
* `with:` inputs before `secrets: inherit` in the jobs block.
|
|
1743
|
+
*/
|
|
1744
|
+
function generateThinCallerContent(name, withOverrides) {
|
|
1745
|
+
const base = WORKFLOW_TEMPLATES[name];
|
|
1746
|
+
if (!base) return "";
|
|
1747
|
+
if (!withOverrides || Object.keys(withOverrides).length === 0) return base;
|
|
1748
|
+
const withBlock = Object.entries(withOverrides).map(([k, v]) => ` ${k}: ${v === true ? "true" : v === false ? "false" : String(v)}`).join("\n");
|
|
1749
|
+
return base.replace(/ {4}secrets: inherit\n$/, ` with:\n${withBlock}\n secrets: inherit\n`);
|
|
1750
|
+
}
|
|
1751
|
+
//#endregion
|
|
1752
|
+
//#region src/commands/sync-github.ts
|
|
1753
|
+
const DEFAULT_REPO = "theholocron/.github";
|
|
1754
|
+
const API_BASE = "https://api.github.com";
|
|
1755
|
+
function reusableHeader(source) {
|
|
1756
|
+
return [
|
|
1757
|
+
`# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
|
|
1758
|
+
`# Source: theholocron/holocron · ${source}`,
|
|
1759
|
+
`# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
1760
|
+
`# Tool: holocron sync-github`,
|
|
1761
|
+
`# Changes: edit source in theholocron/holocron and push to alpha or main.`,
|
|
1762
|
+
``
|
|
1763
|
+
].join("\n");
|
|
1764
|
+
}
|
|
1765
|
+
function thinCallerHeader() {
|
|
1766
|
+
return [
|
|
1767
|
+
`# AUTO-GENERATED — do not edit in theholocron/.github directly.`,
|
|
1768
|
+
`# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts`,
|
|
1769
|
+
`# Synced: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
1770
|
+
`# Tool: holocron sync-github`,
|
|
1771
|
+
`# Changes: edit setup-workflows.ts in theholocron/holocron and push.`,
|
|
1772
|
+
``
|
|
1773
|
+
].join("\n");
|
|
1774
|
+
}
|
|
1775
|
+
function buildBatch(repo, allowedWorkflows) {
|
|
1776
|
+
const files = [];
|
|
1777
|
+
const isPrimaryGithubRepo = repo === DEFAULT_REPO;
|
|
1778
|
+
if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(ACTIONS)) files.push({
|
|
1779
|
+
path: `.github/actions/${name}.yml`,
|
|
1780
|
+
content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
|
|
1781
|
+
});
|
|
1782
|
+
for (const [name, content] of Object.entries(REUSABLE_WORKFLOWS)) {
|
|
1783
|
+
if (allowedWorkflows && !allowedWorkflows.has(name)) continue;
|
|
1784
|
+
files.push({
|
|
1785
|
+
path: `.github/workflows/${name}.yml`,
|
|
1786
|
+
content: reusableHeader(`packages/cli/src/templates/index.ts`) + content
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
if (isPrimaryGithubRepo) for (const [name, content] of Object.entries(WORKFLOW_TEMPLATES)) {
|
|
1790
|
+
files.push({
|
|
1791
|
+
path: `workflow-templates/${name}.yml`,
|
|
1792
|
+
content: thinCallerHeader() + content
|
|
1793
|
+
});
|
|
1794
|
+
const props = WORKFLOW_TEMPLATE_PROPERTIES[name];
|
|
1795
|
+
if (props) files.push({
|
|
1796
|
+
path: `workflow-templates/${name}.properties.json`,
|
|
1797
|
+
content: props
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
return files;
|
|
1801
|
+
}
|
|
1802
|
+
/** Git blob SHA: sha1("blob {len}\0{content}") — used to detect unchanged files. */
|
|
1803
|
+
function gitBlobSha(content) {
|
|
1804
|
+
const buf = Buffer.from(content, "utf8");
|
|
1805
|
+
return createHash("sha1").update(`blob ${buf.length}\0`).update(buf).digest("hex");
|
|
1806
|
+
}
|
|
1807
|
+
async function runSyncGithub(input) {
|
|
1808
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
1809
|
+
const repo = input.repo ?? DEFAULT_REPO;
|
|
1810
|
+
const [owner, repoName] = repo.split("/");
|
|
1811
|
+
const { token, dryRun = false, branch, createPr = false } = input;
|
|
1812
|
+
const message = input.message ?? `chore: sync from theholocron/holocron`;
|
|
1813
|
+
const fetchFn = input.fetch ?? globalThis.fetch;
|
|
1814
|
+
const headers = {
|
|
1815
|
+
Authorization: `Bearer ${token}`,
|
|
1816
|
+
Accept: "application/vnd.github+json",
|
|
1817
|
+
"Content-Type": "application/json",
|
|
1818
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
1819
|
+
};
|
|
1820
|
+
print(`holocron sync-github${dryRun ? " (dry-run)" : ""}`);
|
|
1821
|
+
print(` repo: ${repo}`);
|
|
1822
|
+
if (branch) print(` branch: ${branch}`);
|
|
1823
|
+
print("");
|
|
1824
|
+
if (input.outputDir) {
|
|
1825
|
+
const batch = buildBatch(repo);
|
|
1826
|
+
for (const file of batch) {
|
|
1827
|
+
const dest = join(input.outputDir, file.path);
|
|
1828
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
1829
|
+
writeFileSync(dest, file.content, "utf8");
|
|
1830
|
+
}
|
|
1831
|
+
print(` ${batch.length} files written to ${input.outputDir}`);
|
|
1832
|
+
return {
|
|
1833
|
+
status: "ok",
|
|
1834
|
+
created: batch.length,
|
|
1835
|
+
updated: 0,
|
|
1836
|
+
unchanged: 0
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
let targetBranch = branch;
|
|
1840
|
+
if (!targetBranch) {
|
|
1841
|
+
const repoRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}`, { headers });
|
|
1842
|
+
if (!repoRes.ok) {
|
|
1843
|
+
const msg = "failed to fetch repo metadata";
|
|
1844
|
+
print(` ✗ ${msg}`);
|
|
1845
|
+
return {
|
|
1846
|
+
status: "fail",
|
|
1847
|
+
created: 0,
|
|
1848
|
+
updated: 0,
|
|
1849
|
+
unchanged: 0,
|
|
1850
|
+
message: msg
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
targetBranch = (await repoRes.json()).default_branch;
|
|
1854
|
+
}
|
|
1855
|
+
const refRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/ref/heads/${targetBranch}`, { headers });
|
|
1856
|
+
if (!refRes.ok) {
|
|
1857
|
+
const msg = `Branch ${targetBranch} not found`;
|
|
1858
|
+
print(` ✗ ${msg}`);
|
|
1859
|
+
return {
|
|
1860
|
+
status: "fail",
|
|
1861
|
+
created: 0,
|
|
1862
|
+
updated: 0,
|
|
1863
|
+
unchanged: 0,
|
|
1864
|
+
message: msg
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
const { object: { sha: headSha } } = await refRes.json();
|
|
1868
|
+
const { tree: { sha: baseTreeSha } } = await (await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/commits/${headSha}`, { headers })).json();
|
|
1869
|
+
const { tree: existingTree } = await (await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/trees/${baseTreeSha}?recursive=1`, { headers })).json();
|
|
1870
|
+
const existingBlobs = new Map(existingTree.filter((i) => i.type === "blob").map((i) => [i.path, i.sha]));
|
|
1871
|
+
let allowedWorkflows;
|
|
1872
|
+
if (repo !== DEFAULT_REPO) try {
|
|
1873
|
+
const configRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/contents/holocron.config.json`, { headers });
|
|
1874
|
+
if (configRes.ok) {
|
|
1875
|
+
const configData = await configRes.json();
|
|
1876
|
+
const workflows = JSON.parse(Buffer.from(configData.content.replace(/\n/g, ""), "base64").toString("utf8"))?.project?.workflows ?? [];
|
|
1877
|
+
if (workflows.length > 0) allowedWorkflows = new Set(workflows.map((w) => typeof w === "string" ? w : w.name));
|
|
1878
|
+
}
|
|
1879
|
+
} catch {}
|
|
1880
|
+
const batch = buildBatch(repo, allowedWorkflows);
|
|
1881
|
+
let created = 0;
|
|
1882
|
+
let updated = 0;
|
|
1883
|
+
let unchanged = 0;
|
|
1884
|
+
const changedFiles = [];
|
|
1885
|
+
for (const file of batch) {
|
|
1886
|
+
const localSha = gitBlobSha(file.content);
|
|
1887
|
+
const existingSha = existingBlobs.get(file.path);
|
|
1888
|
+
if (existingSha === localSha) {
|
|
1889
|
+
print(` · unchanged ${file.path}`);
|
|
1890
|
+
unchanged++;
|
|
1891
|
+
} else if (existingSha) {
|
|
1892
|
+
print(` ${dryRun ? "~" : "✓"} updated ${file.path}`);
|
|
1893
|
+
updated++;
|
|
1894
|
+
if (!dryRun) changedFiles.push(file);
|
|
1895
|
+
} else {
|
|
1896
|
+
print(` ${dryRun ? "~" : "✓"} created ${file.path}`);
|
|
1897
|
+
created++;
|
|
1898
|
+
if (!dryRun) changedFiles.push(file);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
print("");
|
|
1902
|
+
print(` ${created} created, ${updated} updated, ${unchanged} unchanged`);
|
|
1903
|
+
if (dryRun || changedFiles.length === 0) return {
|
|
1904
|
+
status: dryRun ? "dry-run" : "ok",
|
|
1905
|
+
created,
|
|
1906
|
+
updated,
|
|
1907
|
+
unchanged
|
|
1908
|
+
};
|
|
1909
|
+
const treeEntries = [];
|
|
1910
|
+
for (const file of changedFiles) {
|
|
1911
|
+
const blobRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/blobs`, {
|
|
1912
|
+
method: "POST",
|
|
1913
|
+
headers,
|
|
1914
|
+
body: JSON.stringify({
|
|
1915
|
+
content: file.content,
|
|
1916
|
+
encoding: "utf-8"
|
|
1917
|
+
})
|
|
1918
|
+
});
|
|
1919
|
+
if (!blobRes.ok) {
|
|
1920
|
+
const err = await blobRes.json();
|
|
1921
|
+
const msg = `failed to create blob for ${file.path}: ${err.message ?? blobRes.status}`;
|
|
1922
|
+
print(` ✗ ${msg}`);
|
|
1923
|
+
return {
|
|
1924
|
+
status: "fail",
|
|
1925
|
+
created,
|
|
1926
|
+
updated,
|
|
1927
|
+
unchanged,
|
|
1928
|
+
message: msg
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
const { sha: blobSha } = await blobRes.json();
|
|
1932
|
+
treeEntries.push({
|
|
1933
|
+
path: file.path,
|
|
1934
|
+
mode: "100644",
|
|
1935
|
+
type: "blob",
|
|
1936
|
+
sha: blobSha
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
const newTreeRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/trees`, {
|
|
1940
|
+
method: "POST",
|
|
1941
|
+
headers,
|
|
1942
|
+
body: JSON.stringify({
|
|
1943
|
+
base_tree: baseTreeSha,
|
|
1944
|
+
tree: treeEntries
|
|
1945
|
+
})
|
|
1946
|
+
});
|
|
1947
|
+
if (!newTreeRes.ok) {
|
|
1948
|
+
const msg = `failed to create tree: ${(await newTreeRes.json()).message ?? newTreeRes.status}`;
|
|
1949
|
+
print(` ✗ ${msg}`);
|
|
1950
|
+
return {
|
|
1951
|
+
status: "fail",
|
|
1952
|
+
created,
|
|
1953
|
+
updated,
|
|
1954
|
+
unchanged,
|
|
1955
|
+
message: msg
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
const { sha: newTreeSha } = await newTreeRes.json();
|
|
1959
|
+
const newCommitRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/commits`, {
|
|
1960
|
+
method: "POST",
|
|
1961
|
+
headers,
|
|
1962
|
+
body: JSON.stringify({
|
|
1963
|
+
message,
|
|
1964
|
+
tree: newTreeSha,
|
|
1965
|
+
parents: [headSha]
|
|
1966
|
+
})
|
|
1967
|
+
});
|
|
1968
|
+
if (!newCommitRes.ok) {
|
|
1969
|
+
const msg = `failed to create commit: ${(await newCommitRes.json()).message ?? newCommitRes.status}`;
|
|
1970
|
+
print(` ✗ ${msg}`);
|
|
1971
|
+
return {
|
|
1972
|
+
status: "fail",
|
|
1973
|
+
created,
|
|
1974
|
+
updated,
|
|
1975
|
+
unchanged,
|
|
1976
|
+
message: msg
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
const { sha: newCommitSha } = await newCommitRes.json();
|
|
1980
|
+
const updateRefRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/git/refs/heads/${targetBranch}`, {
|
|
1981
|
+
method: "PATCH",
|
|
1982
|
+
headers,
|
|
1983
|
+
body: JSON.stringify({ sha: newCommitSha })
|
|
1984
|
+
});
|
|
1985
|
+
if (!updateRefRes.ok) {
|
|
1986
|
+
const msg = `failed to update ref: ${(await updateRefRes.json()).message ?? updateRefRes.status}`;
|
|
1987
|
+
print(` ✗ ${msg}`);
|
|
1988
|
+
return {
|
|
1989
|
+
status: "fail",
|
|
1990
|
+
created,
|
|
1991
|
+
updated,
|
|
1992
|
+
unchanged,
|
|
1993
|
+
message: msg
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
let prUrl;
|
|
1997
|
+
if (branch && createPr && !dryRun) {
|
|
1998
|
+
const prRes = await fetchFn(`${API_BASE}/repos/${owner}/${repoName}/pulls`, {
|
|
1999
|
+
method: "POST",
|
|
2000
|
+
headers,
|
|
2001
|
+
body: JSON.stringify({
|
|
2002
|
+
title: message,
|
|
2003
|
+
head: branch,
|
|
2004
|
+
base: "main",
|
|
2005
|
+
body: "Auto-generated by `holocron sync-github`. Review and merge to apply template updates."
|
|
2006
|
+
})
|
|
2007
|
+
});
|
|
2008
|
+
if (prRes.ok) {
|
|
2009
|
+
prUrl = (await prRes.json()).html_url;
|
|
2010
|
+
print(` → PR opened: ${prUrl}`);
|
|
2011
|
+
} else {
|
|
2012
|
+
const err = await prRes.json();
|
|
2013
|
+
if (err.errors?.some((e) => e.message.includes("already exists"))) print(` → PR already open for ${branch} — branch updated, ready to merge`);
|
|
2014
|
+
else print(` ⚠ PR creation failed: ${err.message ?? prRes.status}`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
return {
|
|
2018
|
+
status: "ok",
|
|
2019
|
+
created,
|
|
2020
|
+
updated,
|
|
2021
|
+
unchanged,
|
|
2022
|
+
prUrl
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
//#endregion
|
|
2026
|
+
//#region src/commands/npm-publish-initial.ts
|
|
2027
|
+
/**
|
|
2028
|
+
* `holocron npm publish-initial` — bottles up the chicken-and-egg
|
|
2029
|
+
* bootstrap that every new npm-published holocron monorepo hits.
|
|
2030
|
+
*
|
|
2031
|
+
* npm requires a package to exist before Trusted Publishing can be
|
|
2032
|
+
* configured on it. So the first publish has to happen outside the
|
|
2033
|
+
* OIDC flow — using either a browser-auth session (`npm login
|
|
2034
|
+
* --auth-type=web`) or an ephemeral automation token. This command
|
|
2035
|
+
* runs the publish step + tells you exactly what to do next.
|
|
2036
|
+
*
|
|
2037
|
+
* Workflow:
|
|
2038
|
+
*
|
|
2039
|
+
* $ npm login --auth-type=web # one-time, browser-based
|
|
2040
|
+
* $ pnpm install --frozen-lockfile
|
|
2041
|
+
* $ pnpm build
|
|
2042
|
+
* $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
|
|
2043
|
+
*
|
|
2044
|
+
* The command itself only handles the publish step + the post-publish
|
|
2045
|
+
* Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
|
|
2046
|
+
* stay outside the command (no pnpm-inside-pnpm).
|
|
2047
|
+
*
|
|
2048
|
+
* If `NPM_TOKEN` is detected in env, the command prints a final
|
|
2049
|
+
* "revoke this token at <url>" reminder — same pattern as `rando vc
|
|
2050
|
+
* setup` for the ephemeral GH admin PAT.
|
|
2051
|
+
*/
|
|
2052
|
+
const PUBLISHABLE_PACKAGES = [
|
|
2053
|
+
"@theholocron/cli",
|
|
2054
|
+
"@theholocron/holocron-plugin-github",
|
|
2055
|
+
"@theholocron/holocron-plugin-vercel",
|
|
2056
|
+
"@theholocron/holocron-plugin-neon",
|
|
2057
|
+
"@theholocron/holocron-plugin-clerk",
|
|
2058
|
+
"@theholocron/holocron-plugin-1password",
|
|
2059
|
+
"@theholocron/holocron-plugin-postman"
|
|
2060
|
+
];
|
|
2061
|
+
async function runNpmPublishInitial(input = {}) {
|
|
2062
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
2063
|
+
const cwd = input.cwd ?? process.cwd();
|
|
2064
|
+
const tag = input.tag ?? "alpha";
|
|
2065
|
+
const dryRun = input.dryRun ?? false;
|
|
2066
|
+
const otp = input.otp;
|
|
2067
|
+
const env = input.env ?? process.env;
|
|
2068
|
+
const exec = input.exec ?? defaultExec;
|
|
2069
|
+
const publishArgs = [
|
|
2070
|
+
"-r",
|
|
2071
|
+
"--filter=./packages/*",
|
|
2072
|
+
"--filter=!@theholocron/cli-utils",
|
|
2073
|
+
"publish",
|
|
2074
|
+
"--access",
|
|
2075
|
+
"public",
|
|
2076
|
+
"--no-git-checks",
|
|
2077
|
+
"--tag",
|
|
2078
|
+
tag,
|
|
2079
|
+
...otp ? ["--otp", otp] : []
|
|
2080
|
+
];
|
|
2081
|
+
print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
|
|
2082
|
+
print(` cwd: ${cwd}`);
|
|
2083
|
+
print(` tag: ${tag}`);
|
|
2084
|
+
if (otp) print(` otp: <${otp.length} chars>`);
|
|
2085
|
+
print("");
|
|
2086
|
+
print(" → verifying npm auth (`npm whoami`)…");
|
|
2087
|
+
const whoami = await exec("npm", ["whoami"], { cwd });
|
|
2088
|
+
if (whoami.exitCode !== 0) {
|
|
2089
|
+
const message = "npm is not authenticated. Run `npm login --auth-type=web` (browser flow, no token stored) or `npm login`, then re-run this command.";
|
|
2090
|
+
print(` ✗ ${message}`);
|
|
2091
|
+
return {
|
|
2092
|
+
status: "fail",
|
|
2093
|
+
message,
|
|
2094
|
+
packageNames: PUBLISHABLE_PACKAGES
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
print(` ✓ authed as ${whoami.stdout.trim() || "<unknown>"}`);
|
|
2098
|
+
if (dryRun) {
|
|
2099
|
+
print("");
|
|
2100
|
+
print(" … (dry-run) skipping actual publish");
|
|
2101
|
+
print(` would run: pnpm ${publishArgs.join(" ")}`);
|
|
2102
|
+
printNextSteps$1(print, env);
|
|
2103
|
+
return {
|
|
2104
|
+
status: "dry-run",
|
|
2105
|
+
message: "dry-run — no publish executed",
|
|
2106
|
+
packageNames: PUBLISHABLE_PACKAGES
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
print("");
|
|
2110
|
+
print(" → publishing all public @theholocron/* packages…");
|
|
2111
|
+
const publish = await exec("pnpm", publishArgs, { cwd });
|
|
2112
|
+
if (publish.exitCode !== 0) {
|
|
2113
|
+
const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
|
|
2114
|
+
print(` ✗ ${message}`);
|
|
2115
|
+
if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
|
|
2116
|
+
print("");
|
|
2117
|
+
print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
|
|
2118
|
+
print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
|
|
2119
|
+
}
|
|
2120
|
+
return {
|
|
2121
|
+
status: "fail",
|
|
2122
|
+
message,
|
|
2123
|
+
packageNames: PUBLISHABLE_PACKAGES
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
print(" ✓ publish complete");
|
|
2127
|
+
printNextSteps$1(print, env);
|
|
2128
|
+
return {
|
|
2129
|
+
status: "ok",
|
|
2130
|
+
packageNames: PUBLISHABLE_PACKAGES
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
function printNextSteps$1(print, env) {
|
|
2134
|
+
print("");
|
|
2135
|
+
print(" → next: configure Trusted Publisher for each package on npm:");
|
|
2136
|
+
for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
|
|
2137
|
+
print(" Publisher: GitHub Actions Org: theholocron Repo: holocron Workflow: release.yml");
|
|
2138
|
+
if (env.NPM_TOKEN) {
|
|
2139
|
+
print("");
|
|
2140
|
+
print(" → cleanup: $NPM_TOKEN was used. Revoke it now (no API for self-revoke; UI-only):");
|
|
2141
|
+
print(" https://www.npmjs.com/settings/~/tokens");
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
const defaultExec = async (cmd, args, opts) => {
|
|
2145
|
+
const result = spawnSync(cmd, args, {
|
|
2146
|
+
cwd: opts.cwd,
|
|
2147
|
+
encoding: "utf8",
|
|
2148
|
+
stdio: [
|
|
2149
|
+
"inherit",
|
|
2150
|
+
"pipe",
|
|
2151
|
+
"pipe"
|
|
2152
|
+
]
|
|
2153
|
+
});
|
|
2154
|
+
return {
|
|
2155
|
+
exitCode: result.status ?? -1,
|
|
2156
|
+
stdout: result.stdout ?? "",
|
|
2157
|
+
stderr: result.stderr ?? ""
|
|
2158
|
+
};
|
|
2159
|
+
};
|
|
2160
|
+
//#endregion
|
|
2161
|
+
//#region src/commands/plugin-create/template-inputs.ts
|
|
2162
|
+
/** Derive the standard defaults from a slug + vendor name. */
|
|
2163
|
+
function deriveDefaults(input) {
|
|
2164
|
+
const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
|
|
2165
|
+
const capability = input.capability;
|
|
2166
|
+
return {
|
|
2167
|
+
vendorUpper,
|
|
2168
|
+
capabilityClass: `${input.vendorName}${capability.charAt(0).toUpperCase() + capability.slice(1)}`,
|
|
2169
|
+
tokenEnv: `HOLOCRON_${vendorUpper}_TOKEN`,
|
|
2170
|
+
transport: "rest"
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
//#endregion
|
|
2174
|
+
//#region src/commands/plugin-create/templates/auth.ts
|
|
2175
|
+
function render$17(inputs) {
|
|
2176
|
+
return `/**
|
|
2177
|
+
* Token resolution for the ${inputs.vendorName} plugin.
|
|
2178
|
+
*
|
|
2179
|
+
* Resolution order (matches the standard 4-step precedence set by
|
|
2180
|
+
* \`.notes/tech-auth-bootstrap.spec.md\`):
|
|
2181
|
+
* 1. explicit \`cliToken\` argument (from \`--token\` flag)
|
|
2182
|
+
* 2. ${inputs.tokenEnv} env var (preferred — explicit intent)
|
|
2183
|
+
* 3. ${inputs.vendorEnv} env var (vendor-native)
|
|
2184
|
+
* 4. keyring (com.theholocron.cli / "${inputs.slug}")
|
|
2185
|
+
* 5. AuthError naming all four options + the bootstrap hint
|
|
2186
|
+
*/
|
|
2187
|
+
|
|
2188
|
+
import { getToken as getKeyringToken } from "@theholocron/cli";
|
|
2189
|
+
|
|
2190
|
+
export class AuthError extends Error {
|
|
2191
|
+
override name = "AuthError";
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
export interface ResolveTokenInput {
|
|
2195
|
+
/** From \`--token\` CLI flag. */
|
|
2196
|
+
cliToken?: string;
|
|
2197
|
+
/** Env vars; passed in for testability. Defaults to \`process.env\`. */
|
|
2198
|
+
env?: NodeJS.ProcessEnv;
|
|
2199
|
+
/** Keyring lookup fn; passed in for testability. Defaults to \`getToken(provider)\`. */
|
|
2200
|
+
keyring?: (provider: string) => string | null;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
export function resolveToken(input: ResolveTokenInput = {}): string {
|
|
2204
|
+
const env = input.env ?? process.env;
|
|
2205
|
+
const keyring = input.keyring ?? getKeyringToken;
|
|
2206
|
+
// Bracket access so numeric-prefixed slugs (e.g., env.HOLOCRON_1PASSWORD_TOKEN
|
|
2207
|
+
// which is invalid JS) still produce syntactically valid code.
|
|
2208
|
+
const token =
|
|
2209
|
+
input.cliToken || env["${inputs.tokenEnv}"] || env["${inputs.vendorEnv}"] || keyring("${inputs.slug}");
|
|
2210
|
+
if (!token) {
|
|
2211
|
+
throw new AuthError(
|
|
2212
|
+
"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
|
|
2213
|
+
"or run: holocron auth set ${inputs.slug} <TOKEN>"
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
return token;
|
|
2217
|
+
}
|
|
2218
|
+
`;
|
|
2219
|
+
}
|
|
2220
|
+
//#endregion
|
|
2221
|
+
//#region src/commands/plugin-create/templates/auth-test.ts
|
|
2222
|
+
function render$16(inputs) {
|
|
2223
|
+
return `import { describe, expect, it } from "vitest";
|
|
2224
|
+
|
|
2225
|
+
import { AuthError, resolveToken } from "../auth.js";
|
|
2226
|
+
|
|
2227
|
+
const noKeyring = () => null;
|
|
2228
|
+
|
|
2229
|
+
describe("resolveToken", () => {
|
|
2230
|
+
it("prefers --token over env vars + keyring", () => {
|
|
2231
|
+
expect(
|
|
2232
|
+
resolveToken({
|
|
2233
|
+
cliToken: "flag",
|
|
2234
|
+
env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
|
|
2235
|
+
keyring: () => "kr",
|
|
2236
|
+
})
|
|
2237
|
+
).toBe("flag");
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
it("prefers ${inputs.tokenEnv} over ${inputs.vendorEnv}", () => {
|
|
2241
|
+
expect(
|
|
2242
|
+
resolveToken({
|
|
2243
|
+
env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
|
|
2244
|
+
keyring: noKeyring,
|
|
2245
|
+
})
|
|
2246
|
+
).toBe("hlc");
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
it("falls back to ${inputs.vendorEnv} when ${inputs.tokenEnv} is unset", () => {
|
|
2250
|
+
expect(resolveToken({ env: { ${inputs.vendorEnv}: "vendor" }, keyring: noKeyring })).toBe("vendor");
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
it("falls back to keyring when env vars are unset", () => {
|
|
2254
|
+
expect(resolveToken({ env: {}, keyring: (p) => (p === "${inputs.slug}" ? "kr" : null) })).toBe("kr");
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
it("throws AuthError with a helpful message when nothing is set", () => {
|
|
2258
|
+
try {
|
|
2259
|
+
resolveToken({ env: {}, keyring: noKeyring });
|
|
2260
|
+
throw new Error("should have thrown");
|
|
2261
|
+
} catch (err) {
|
|
2262
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
2263
|
+
expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
|
|
2264
|
+
expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
`;
|
|
2269
|
+
}
|
|
2270
|
+
//#endregion
|
|
2271
|
+
//#region src/commands/plugin-create/templates/capability.ts
|
|
2272
|
+
function render$15(inputs) {
|
|
2273
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2274
|
+
const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
|
|
2275
|
+
return `/**
|
|
2276
|
+
* \`${inputs.capability}\` capability for ${inputs.vendorName}.
|
|
2277
|
+
*
|
|
2278
|
+
* Methods are STUBS — implement them against ${inputs.vendorName}'s
|
|
2279
|
+
* REST API per the \`${capabilityInterface}\` interface contract in
|
|
2280
|
+
* \`@theholocron/cli\`.
|
|
2281
|
+
*
|
|
2282
|
+
* TODO once you've stubbed the interface methods, restore the type
|
|
2283
|
+
* import + \`implements\` clause:
|
|
2284
|
+
* import type { ${capabilityInterface} } from "@theholocron/cli";
|
|
2285
|
+
* export class ${inputs.capabilityClass} implements ${capabilityInterface} { ... }
|
|
2286
|
+
*/
|
|
2287
|
+
|
|
2288
|
+
import type { ${clientClass} } from "../rest.js";
|
|
2289
|
+
|
|
2290
|
+
export class ${inputs.capabilityClass} {
|
|
2291
|
+
readonly key = "${inputs.capability}" as const;
|
|
2292
|
+
readonly providerName = "${inputs.slug}";
|
|
2293
|
+
|
|
2294
|
+
constructor(private readonly rest: ${clientClass}) {}
|
|
2295
|
+
|
|
2296
|
+
// TODO: implement the ${capabilityInterface} interface methods
|
|
2297
|
+
// (see \`packages/cli/src/capabilities/index.ts\`). Each method
|
|
2298
|
+
// should hit a specific ${inputs.vendorName} REST endpoint via
|
|
2299
|
+
// \`this.rest.request(...)\`. Once methods are stubbed, add
|
|
2300
|
+
// \`implements ${capabilityInterface}\` to the class declaration
|
|
2301
|
+
// above and remove the \`as unknown as\` cast in src/index.ts.
|
|
2302
|
+
}
|
|
2303
|
+
`;
|
|
2304
|
+
}
|
|
2305
|
+
//#endregion
|
|
2306
|
+
//#region src/commands/plugin-create/templates/capability-test.ts
|
|
2307
|
+
function render$14(inputs) {
|
|
2308
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2309
|
+
return `import { describe, it } from "vitest";
|
|
2310
|
+
|
|
2311
|
+
import { ${inputs.capabilityClass} } from "../capabilities/${inputs.capability}.js";
|
|
2312
|
+
import { ${clientClass} } from "../rest.js";
|
|
2313
|
+
import { stubFetch } from "./helpers.js";
|
|
2314
|
+
|
|
2315
|
+
// Stub client used by the constructor smoke test. Real capability
|
|
2316
|
+
// tests replace this with per-method stubs once implementations land.
|
|
2317
|
+
function makeCapability() {
|
|
2318
|
+
const stub = stubFetch([]);
|
|
2319
|
+
const rest = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
2320
|
+
return new ${inputs.capabilityClass}(rest);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
describe("${inputs.capabilityClass}", () => {
|
|
2324
|
+
it("constructs with a REST client", () => {
|
|
2325
|
+
makeCapability();
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
// TODO: implement one test per ${inputs.capability} capability method
|
|
2329
|
+
// as you fill in the class stubs. See other plugins for reference
|
|
2330
|
+
// patterns:
|
|
2331
|
+
// - REST behaviors (URL / method / body / query) via \`stub.calls\`
|
|
2332
|
+
// - Error paths via \`stubFetch([{ status: 4xx, body: {...} }])\`
|
|
2333
|
+
// - Idempotency (409 handling for ensure* methods, if applicable)
|
|
2334
|
+
it.todo("implement per-method tests");
|
|
2335
|
+
});
|
|
2336
|
+
`;
|
|
2337
|
+
}
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region src/commands/plugin-create/templates/eslint-config.ts
|
|
2340
|
+
function render$13(_inputs) {
|
|
2341
|
+
return `import root from "../../eslint.config.js";
|
|
2342
|
+
|
|
2343
|
+
export default [
|
|
2344
|
+
...root,
|
|
2345
|
+
{
|
|
2346
|
+
ignores: ["dist/**", "coverage/**"],
|
|
2347
|
+
},
|
|
2348
|
+
];
|
|
2349
|
+
`;
|
|
2350
|
+
}
|
|
2351
|
+
//#endregion
|
|
2352
|
+
//#region src/commands/plugin-create/templates/helpers.ts
|
|
2353
|
+
function render$12(_inputs) {
|
|
2354
|
+
return `import { vi, type Mock } from "vitest";
|
|
2355
|
+
|
|
2356
|
+
export interface FetchCall {
|
|
2357
|
+
url: string;
|
|
2358
|
+
method: string;
|
|
2359
|
+
headers: Record<string, string>;
|
|
2360
|
+
body: unknown;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
export interface FetchStub {
|
|
2364
|
+
fetch: typeof fetch;
|
|
2365
|
+
calls: FetchCall[];
|
|
2366
|
+
mock: Mock;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
export function stubFetch(responses: Array<{ status?: number; body?: unknown; text?: string }>): FetchStub {
|
|
2370
|
+
const calls: FetchCall[] = [];
|
|
2371
|
+
let i = 0;
|
|
2372
|
+
const mock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
2373
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
2374
|
+
const body = typeof init?.body === "string" ? safeJsonParse(init.body) : (init?.body ?? null);
|
|
2375
|
+
calls.push({
|
|
2376
|
+
url,
|
|
2377
|
+
method: (init?.method ?? "GET").toUpperCase(),
|
|
2378
|
+
headers: (init?.headers as Record<string, string>) ?? {},
|
|
2379
|
+
body,
|
|
2380
|
+
});
|
|
2381
|
+
const next = responses[i++] ?? { status: 200, body: {} };
|
|
2382
|
+
const status = next.status ?? 200;
|
|
2383
|
+
if (status === 204 || status === 205 || status === 304) {
|
|
2384
|
+
return new Response(null, { status });
|
|
2385
|
+
}
|
|
2386
|
+
const text = next.text ?? (typeof next.body === "string" ? next.body : JSON.stringify(next.body ?? {}));
|
|
2387
|
+
return new Response(text, { status });
|
|
2388
|
+
});
|
|
2389
|
+
return { fetch: mock as unknown as typeof fetch, calls, mock };
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function safeJsonParse(s: string): unknown {
|
|
2393
|
+
try {
|
|
2394
|
+
return JSON.parse(s);
|
|
2395
|
+
} catch {
|
|
2396
|
+
return s;
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
`;
|
|
2400
|
+
}
|
|
2401
|
+
//#endregion
|
|
2402
|
+
//#region src/commands/plugin-create/templates/index-test.ts
|
|
2403
|
+
function render$11(inputs) {
|
|
2404
|
+
return `import { describe, expect, it } from "vitest";
|
|
2405
|
+
|
|
2406
|
+
import { AUTH_HINT, createPlugin } from "../index.js";
|
|
2407
|
+
import { stubFetch } from "./helpers.js";
|
|
2408
|
+
|
|
2409
|
+
describe("createPlugin", () => {
|
|
2410
|
+
it("wires the ${inputs.capability} capability against the given fetch + token", () => {
|
|
2411
|
+
const stub = stubFetch([]);
|
|
2412
|
+
const plugin = createPlugin({
|
|
2413
|
+
cliToken: "test-token",
|
|
2414
|
+
fetch: stub.fetch,
|
|
2415
|
+
});
|
|
2416
|
+
expect(plugin.name).toBe("@theholocron/holocron-plugin-${inputs.slug}");
|
|
2417
|
+
expect(typeof plugin.capabilities.${inputs.capability}).toBe("function");
|
|
2418
|
+
});
|
|
2419
|
+
});
|
|
2420
|
+
|
|
2421
|
+
describe("AUTH_HINT", () => {
|
|
2422
|
+
it("mentions the holocron auth set command", () => {
|
|
2423
|
+
expect(AUTH_HINT).toMatch(/holocron auth set ${inputs.slug}/);
|
|
2424
|
+
});
|
|
2425
|
+
});
|
|
2426
|
+
`;
|
|
2427
|
+
}
|
|
2428
|
+
//#endregion
|
|
2429
|
+
//#region src/commands/plugin-create/templates/package-json.ts
|
|
2430
|
+
function render$10(inputs) {
|
|
2431
|
+
return `{
|
|
2432
|
+
"name": "@theholocron/holocron-plugin-${inputs.slug}",
|
|
2433
|
+
"version": "2.0.0-alpha.1",
|
|
2434
|
+
"description": "Holocron plugin for ${inputs.vendorName}. Implements the ${inputs.capability} capability against ${inputs.vendorName}'s REST API, plus exports verifyToken + AUTH_HINT for \`holocron auth\`.",
|
|
2435
|
+
"homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-${inputs.slug}#readme",
|
|
2436
|
+
"bugs": "https://github.com/theholocron/holocron/issues",
|
|
2437
|
+
"repository": {
|
|
2438
|
+
"type": "git",
|
|
2439
|
+
"url": "git+https://github.com/theholocron/holocron.git",
|
|
2440
|
+
"directory": "packages/holocron-plugin-${inputs.slug}"
|
|
2441
|
+
},
|
|
2442
|
+
"license": "MIT",
|
|
2443
|
+
"author": "Newton Koumantzelis",
|
|
2444
|
+
"type": "module",
|
|
2445
|
+
"main": "./src/index.ts",
|
|
2446
|
+
"exports": {
|
|
2447
|
+
".": "./src/index.ts"
|
|
2448
|
+
},
|
|
2449
|
+
"scripts": {
|
|
2450
|
+
"build": "tsdown",
|
|
2451
|
+
"lint": "eslint .",
|
|
2452
|
+
"typecheck": "tsc --noEmit",
|
|
2453
|
+
"test": "vitest run",
|
|
2454
|
+
"test:watch": "vitest",
|
|
2455
|
+
"test:coverage": "vitest run --coverage",
|
|
2456
|
+
"validate": "tsx scripts/validate.mjs"
|
|
2457
|
+
},
|
|
2458
|
+
"peerDependencies": {
|
|
2459
|
+
"@theholocron/cli": "workspace:*"
|
|
2460
|
+
},
|
|
2461
|
+
"devDependencies": {
|
|
2462
|
+
"@theholocron/cli": "workspace:*",
|
|
2463
|
+
"@theholocron/tsconfig": "catalog:",
|
|
2464
|
+
"@tsconfig/node-lts": "catalog:",
|
|
2465
|
+
"@vitest/coverage-v8": "catalog:",
|
|
2466
|
+
"eslint": "catalog:",
|
|
2467
|
+
"globals": "catalog:",
|
|
2468
|
+
"typescript": "catalog:",
|
|
2469
|
+
"vitest": "catalog:",
|
|
2470
|
+
"tsdown": "catalog:",
|
|
2471
|
+
"tsx": "catalog:"
|
|
2472
|
+
},
|
|
2473
|
+
"publishConfig": {
|
|
2474
|
+
"access": "public",
|
|
2475
|
+
"main": "./dist/index.mjs",
|
|
2476
|
+
"types": "./dist/index.d.mts",
|
|
2477
|
+
"exports": {
|
|
2478
|
+
".": {
|
|
2479
|
+
"types": "./dist/index.d.mts",
|
|
2480
|
+
"import": "./dist/index.mjs",
|
|
2481
|
+
"default": "./dist/index.mjs"
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
},
|
|
2485
|
+
"files": [
|
|
2486
|
+
"dist",
|
|
2487
|
+
"README.md"
|
|
2488
|
+
]
|
|
2489
|
+
}
|
|
2490
|
+
`;
|
|
2491
|
+
}
|
|
2492
|
+
//#endregion
|
|
2493
|
+
//#region src/commands/plugin-create/templates/plugin-index.ts
|
|
2494
|
+
function render$9(inputs) {
|
|
2495
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2496
|
+
const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
|
|
2497
|
+
return `/**
|
|
2498
|
+
* \`@theholocron/holocron-plugin-${inputs.slug}\` — entrypoint.
|
|
2499
|
+
*
|
|
2500
|
+
* Implements the \`${inputs.capability}\` capability against ${inputs.vendorName}'s
|
|
2501
|
+
* REST API. Also exports \`verifyToken\` + \`AUTH_HINT\` for
|
|
2502
|
+
* \`holocron auth\`. See README for auth + config docs.
|
|
2503
|
+
*
|
|
2504
|
+
* TODO: once you fill in the ${inputs.capabilityClass} methods and add
|
|
2505
|
+
* \`implements ${capabilityInterface}\` to the class, add the type import:
|
|
2506
|
+
* import type { ${capabilityInterface} } from "@theholocron/cli";
|
|
2507
|
+
* and set \`: ${capabilityInterface}\` as the factory return type below.
|
|
2508
|
+
*/
|
|
2509
|
+
|
|
2510
|
+
import { resolveToken, type ResolveTokenInput } from "./auth.js";
|
|
2511
|
+
import { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
2512
|
+
import { ${clientClass} } from "./rest.js";
|
|
2513
|
+
|
|
2514
|
+
export interface ${inputs.vendorName}PluginOptions extends ResolveTokenInput {
|
|
2515
|
+
/** Override base URL for tests. */
|
|
2516
|
+
baseUrl?: string;
|
|
2517
|
+
/** Override \`fetch\` for tests. */
|
|
2518
|
+
fetch?: typeof fetch;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
export interface PluginContext {
|
|
2522
|
+
options: ${inputs.vendorName}PluginOptions;
|
|
2523
|
+
rest: ${clientClass};
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
export function createContext(options: ${inputs.vendorName}PluginOptions): PluginContext {
|
|
2527
|
+
const token = resolveToken(options);
|
|
2528
|
+
const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
|
|
2529
|
+
if (options.baseUrl !== undefined) restOpts.baseUrl = options.baseUrl;
|
|
2530
|
+
if (options.fetch !== undefined) restOpts.fetch = options.fetch;
|
|
2531
|
+
return {
|
|
2532
|
+
options,
|
|
2533
|
+
rest: new ${clientClass}(restOpts),
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
export function ${inputs.capability}(ctx: PluginContext) {
|
|
2538
|
+
// Return type inferred at scaffold time — the class doesn't yet
|
|
2539
|
+
// \`implements ${capabilityInterface}\`. Add the type import + the
|
|
2540
|
+
// \`: ${capabilityInterface}\` annotation once methods are stubbed.
|
|
2541
|
+
return new ${inputs.capabilityClass}(ctx.rest);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
export function createPlugin(options: ${inputs.vendorName}PluginOptions) {
|
|
2545
|
+
const ctx = createContext(options);
|
|
2546
|
+
return {
|
|
2547
|
+
name: "@theholocron/holocron-plugin-${inputs.slug}",
|
|
2548
|
+
capabilities: {
|
|
2549
|
+
${inputs.capability}: () => ${inputs.capability}(ctx),
|
|
2550
|
+
},
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
/**
|
|
2555
|
+
* One-line hint printed by \`holocron auth set ${inputs.slug}\` when no
|
|
2556
|
+
* token is supplied or the supplied token is rejected. Edit this to
|
|
2557
|
+
* point operators at the specific ${inputs.vendorName} docs path for
|
|
2558
|
+
* generating a token.
|
|
2559
|
+
*/
|
|
2560
|
+
export const AUTH_HINT =
|
|
2561
|
+
"generate a ${inputs.vendorName} API token, then run: holocron auth set ${inputs.slug} <TOKEN>";
|
|
2562
|
+
|
|
2563
|
+
// ── Public re-exports ────────────────────────────────────────────────
|
|
2564
|
+
|
|
2565
|
+
export * from "./auth.js";
|
|
2566
|
+
export { ${clientClass} } from "./rest.js";
|
|
2567
|
+
export { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
2568
|
+
export { verifyToken } from "./verify-token.js";
|
|
2569
|
+
export type { VerifyTokenResult, VerifyTokenSuccess, VerifyTokenFailure } from "./verify-token.js";
|
|
2570
|
+
`;
|
|
2571
|
+
}
|
|
2572
|
+
//#endregion
|
|
2573
|
+
//#region src/commands/plugin-create/templates/readme.ts
|
|
2574
|
+
function render$8(inputs) {
|
|
2575
|
+
return `<!-- editorconfig-checker-disable-file -->
|
|
2576
|
+
|
|
2577
|
+
# \`@theholocron/holocron-plugin-${inputs.slug}\`
|
|
2578
|
+
|
|
2579
|
+
${inputs.vendorName} plugin for [Holocron](../cli). Implements the
|
|
2580
|
+
\`${inputs.capability}\` capability against [${inputs.vendorName}'s REST API](${inputs.baseUrl}),
|
|
2581
|
+
plus exports \`verifyToken\` + \`AUTH_HINT\` for use by \`holocron auth\`.
|
|
2582
|
+
|
|
2583
|
+
## Install
|
|
2584
|
+
|
|
2585
|
+
\`\`\`bash
|
|
2586
|
+
pnpm add -D @theholocron/holocron-plugin-${inputs.slug}@alpha
|
|
2587
|
+
\`\`\`
|
|
2588
|
+
|
|
2589
|
+
## Auth
|
|
2590
|
+
|
|
2591
|
+
Token resolution order (matches the standard 4-step precedence set by
|
|
2592
|
+
\`.notes/tech-auth-bootstrap.spec.md\`):
|
|
2593
|
+
|
|
2594
|
+
1. \`--token <TOKEN>\` flag on the holocron invocation
|
|
2595
|
+
2. \`${inputs.tokenEnv}\` env var (preferred — explicit intent)
|
|
2596
|
+
3. \`${inputs.vendorEnv}\` env var (${inputs.vendorName}-native)
|
|
2597
|
+
4. **Keyring** — \`com.theholocron.cli\` service, account \`${inputs.slug}\`
|
|
2598
|
+
5. \`AuthError\` naming all four options + the bootstrap hint
|
|
2599
|
+
|
|
2600
|
+
## Setup
|
|
2601
|
+
|
|
2602
|
+
\`\`\`bash
|
|
2603
|
+
# Generate a ${inputs.vendorName} API token (see vendor docs), then:
|
|
2604
|
+
holocron auth set ${inputs.slug} <TOKEN>
|
|
2605
|
+
holocron auth check ${inputs.slug} # verify
|
|
2606
|
+
\`\`\`
|
|
2607
|
+
|
|
2608
|
+
## Config
|
|
2609
|
+
|
|
2610
|
+
\`\`\`jsonc
|
|
2611
|
+
{
|
|
2612
|
+
"providers": {
|
|
2613
|
+
"${inputs.capability}": "${inputs.slug}",
|
|
2614
|
+
},
|
|
2615
|
+
}
|
|
2616
|
+
\`\`\`
|
|
2617
|
+
|
|
2618
|
+
Plugin options extend \`ResolveTokenInput\` — add whatever ${inputs.vendorName}-
|
|
2619
|
+
specific options you need here (project id, workspace slug, etc.) via
|
|
2620
|
+
the tuple form:
|
|
2621
|
+
|
|
2622
|
+
\`\`\`jsonc
|
|
2623
|
+
{
|
|
2624
|
+
"providers": {
|
|
2625
|
+
"${inputs.capability}": ["${inputs.slug}", { "baseUrl": "${inputs.baseUrl}" }],
|
|
2626
|
+
},
|
|
2627
|
+
}
|
|
2628
|
+
\`\`\`
|
|
2629
|
+
|
|
2630
|
+
## What's implemented
|
|
2631
|
+
|
|
2632
|
+
TODO: fill in as capability methods land.
|
|
2633
|
+
|
|
2634
|
+
## Status
|
|
2635
|
+
|
|
2636
|
+
**\`v2.0.0-alpha.1\`** — scaffolded via \`holocron plugin create\`.
|
|
2637
|
+
Not yet published; capability methods are stubs.
|
|
2638
|
+
`;
|
|
2639
|
+
}
|
|
2640
|
+
//#endregion
|
|
2641
|
+
//#region src/commands/plugin-create/templates/rest.ts
|
|
2642
|
+
function render$7(inputs) {
|
|
2643
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2644
|
+
return `/**
|
|
2645
|
+
* Thin REST wrapper around ${inputs.baseUrl}.
|
|
2646
|
+
*
|
|
2647
|
+
* Bearer auth, JSON-only bodies, transport-failure wrapping with
|
|
2648
|
+
* \`status: 0\` so orchestrator soft-skip paths see a clear message
|
|
2649
|
+
* instead of a generic \`TypeError: fetch failed\`.
|
|
2650
|
+
*/
|
|
2651
|
+
|
|
2652
|
+
import { ProviderApiError } from "@theholocron/cli";
|
|
2653
|
+
|
|
2654
|
+
export interface RestClientOptions {
|
|
2655
|
+
token: string;
|
|
2656
|
+
fetch?: typeof fetch;
|
|
2657
|
+
baseUrl?: string;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
export interface RequestOptions {
|
|
2661
|
+
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
2662
|
+
body?: unknown;
|
|
2663
|
+
query?: Record<string, string>;
|
|
2664
|
+
/** Treat this response as void even if 200 is returned. */
|
|
2665
|
+
expectNoContent?: boolean;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
export class ${clientClass} {
|
|
2669
|
+
private readonly token: string;
|
|
2670
|
+
private readonly fetchImpl: typeof fetch;
|
|
2671
|
+
readonly baseUrl: string;
|
|
2672
|
+
|
|
2673
|
+
constructor(opts: RestClientOptions) {
|
|
2674
|
+
this.token = opts.token;
|
|
2675
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
2676
|
+
// Manual trailing-slash trim — CodeQL flags regex on library
|
|
2677
|
+
// input as polynomial ReDoS. O(n) loop, no backtracking risk.
|
|
2678
|
+
let url = opts.baseUrl ?? "${inputs.baseUrl}";
|
|
2679
|
+
while (url.endsWith("/")) url = url.slice(0, -1);
|
|
2680
|
+
this.baseUrl = url;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
2684
|
+
const url = new URL(\`\${this.baseUrl}\${path.startsWith("/") ? path : "/" + path}\`);
|
|
2685
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
|
|
2686
|
+
const fullUrl = url.toString();
|
|
2687
|
+
|
|
2688
|
+
const headers: Record<string, string> = {
|
|
2689
|
+
authorization: \`Bearer \${this.token}\`,
|
|
2690
|
+
accept: "application/json",
|
|
2691
|
+
};
|
|
2692
|
+
const init: RequestInit = {
|
|
2693
|
+
method: opts.method ?? "GET",
|
|
2694
|
+
headers,
|
|
2695
|
+
};
|
|
2696
|
+
if (opts.body !== undefined) {
|
|
2697
|
+
headers["content-type"] = "application/json";
|
|
2698
|
+
init.body = JSON.stringify(opts.body);
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
let res: Response;
|
|
2702
|
+
try {
|
|
2703
|
+
res = await this.fetchImpl(fullUrl, init);
|
|
2704
|
+
} catch (err) {
|
|
2705
|
+
const detail = err instanceof Error ? \`\${err.name}: \${err.message}\` : String(err);
|
|
2706
|
+
throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} failed: \${detail}\`, 0, undefined);
|
|
2707
|
+
}
|
|
2708
|
+
if (!res.ok) {
|
|
2709
|
+
const body = await res.text().catch(() => "");
|
|
2710
|
+
throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} → \${res.status}\`, res.status, body);
|
|
2711
|
+
}
|
|
2712
|
+
if (opts.expectNoContent || res.status === 204) return undefined as T;
|
|
2713
|
+
const text = await res.text();
|
|
2714
|
+
if (!text) return undefined as T;
|
|
2715
|
+
return JSON.parse(text) as T;
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
`;
|
|
2719
|
+
}
|
|
2720
|
+
//#endregion
|
|
2721
|
+
//#region src/commands/plugin-create/templates/rest-test.ts
|
|
2722
|
+
function render$6(inputs) {
|
|
2723
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2724
|
+
return `import { ProviderApiError } from "@theholocron/cli";
|
|
2725
|
+
import { describe, expect, it } from "vitest";
|
|
2726
|
+
|
|
2727
|
+
import { ${clientClass} } from "../rest.js";
|
|
2728
|
+
import { stubFetch } from "./helpers.js";
|
|
2729
|
+
|
|
2730
|
+
describe("${clientClass}", () => {
|
|
2731
|
+
it("sends bearer + accept headers and returns the parsed body", async () => {
|
|
2732
|
+
const stub = stubFetch([{ status: 200, body: { ok: true } }]);
|
|
2733
|
+
const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
2734
|
+
const res = await client.request<{ ok: boolean }>("/me");
|
|
2735
|
+
expect(res.ok).toBe(true);
|
|
2736
|
+
expect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
|
|
2737
|
+
expect(stub.calls[0]?.headers["accept"]).toBe("application/json");
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
it("serializes body as JSON and sets content-type when present", async () => {
|
|
2741
|
+
const stub = stubFetch([{ status: 200, body: {} }]);
|
|
2742
|
+
const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
2743
|
+
await client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
|
|
2744
|
+
expect(stub.calls[0]?.method).toBe("POST");
|
|
2745
|
+
expect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
|
|
2746
|
+
expect(stub.calls[0]?.body).toEqual({ name: "demo" });
|
|
2747
|
+
});
|
|
2748
|
+
|
|
2749
|
+
it("returns undefined on 204", async () => {
|
|
2750
|
+
const stub = stubFetch([{ status: 204 }]);
|
|
2751
|
+
const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
2752
|
+
expect(await client.request<unknown>("/whatever")).toBeUndefined();
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
it("throws ProviderApiError with the HTTP status on non-2xx", async () => {
|
|
2756
|
+
const stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
|
|
2757
|
+
const client = new ${clientClass}({ token: "bad", fetch: stub.fetch });
|
|
2758
|
+
try {
|
|
2759
|
+
await client.request<unknown>("/me");
|
|
2760
|
+
throw new Error("should have thrown");
|
|
2761
|
+
} catch (err) {
|
|
2762
|
+
expect(err).toBeInstanceOf(ProviderApiError);
|
|
2763
|
+
expect((err as ProviderApiError).status).toBe(401);
|
|
2764
|
+
}
|
|
2765
|
+
});
|
|
2766
|
+
|
|
2767
|
+
it("wraps transport-level failures with status 0", async () => {
|
|
2768
|
+
const throwing: typeof fetch = async () => {
|
|
2769
|
+
throw new TypeError("fetch failed");
|
|
2770
|
+
};
|
|
2771
|
+
const client = new ${clientClass}({ token: "t", fetch: throwing });
|
|
2772
|
+
try {
|
|
2773
|
+
await client.request<unknown>("/me");
|
|
2774
|
+
throw new Error("should have thrown");
|
|
2775
|
+
} catch (err) {
|
|
2776
|
+
expect(err).toBeInstanceOf(ProviderApiError);
|
|
2777
|
+
expect((err as ProviderApiError).status).toBe(0);
|
|
2778
|
+
}
|
|
2779
|
+
});
|
|
2780
|
+
|
|
2781
|
+
it("trims trailing slashes from the base URL", () => {
|
|
2782
|
+
const client = new ${clientClass}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
|
|
2783
|
+
expect(client.baseUrl).toBe("${inputs.baseUrl}");
|
|
2784
|
+
});
|
|
2785
|
+
});
|
|
2786
|
+
`;
|
|
2787
|
+
}
|
|
2788
|
+
//#endregion
|
|
2789
|
+
//#region src/commands/plugin-create/templates/tsconfig-json.ts
|
|
2790
|
+
function render$5(inputs) {
|
|
2791
|
+
return `{
|
|
2792
|
+
"display": "Holocron Plugin: ${inputs.vendorName}",
|
|
2793
|
+
"extends": "@tsconfig/node-lts/tsconfig.json",
|
|
2794
|
+
"compilerOptions": {
|
|
2795
|
+
"baseUrl": "./",
|
|
2796
|
+
"outDir": "./dist",
|
|
2797
|
+
"paths": {
|
|
2798
|
+
"@/*": ["./src/*"]
|
|
2799
|
+
}
|
|
2800
|
+
},
|
|
2801
|
+
"include": ["src/**/*.ts"],
|
|
2802
|
+
"exclude": ["node_modules", "dist"]
|
|
2803
|
+
}
|
|
2804
|
+
`;
|
|
2805
|
+
}
|
|
2806
|
+
//#endregion
|
|
2807
|
+
//#region src/commands/plugin-create/templates/tsdown-config.ts
|
|
2808
|
+
function render$4(_inputs) {
|
|
2809
|
+
return `import { defineConfig } from "tsdown";
|
|
2810
|
+
|
|
2811
|
+
export default defineConfig({
|
|
2812
|
+
entry: ["src/index.ts"],
|
|
2813
|
+
format: "esm",
|
|
2814
|
+
dts: true,
|
|
2815
|
+
clean: true,
|
|
2816
|
+
deps: { neverBundle: [/^@theholocron\\//] },
|
|
2817
|
+
});
|
|
2818
|
+
`;
|
|
2819
|
+
}
|
|
2820
|
+
//#endregion
|
|
2821
|
+
//#region src/commands/plugin-create/templates/validate-script.ts
|
|
2822
|
+
/**
|
|
2823
|
+
* Scaffolds `scripts/validate.mjs` — a smoke-test the operator runs
|
|
2824
|
+
* against a live vendor account to verify the plugin's REST endpoints,
|
|
2825
|
+
* auth, and response parsing all work in reality (the unit tests only
|
|
2826
|
+
* exercise stubbed HTTP responses). READ-ONLY BY DESIGN.
|
|
2827
|
+
*
|
|
2828
|
+
* Convention: every plugin ships this script + a matching `validate`
|
|
2829
|
+
* entry in `package.json`'s scripts block, so operators run
|
|
2830
|
+
* `pnpm --filter @theholocron/holocron-plugin-<slug> validate`.
|
|
2831
|
+
* Capability-specific args (project id, workspace, etc.) come from
|
|
2832
|
+
* positional command-line arguments; the plugin author fills in the
|
|
2833
|
+
* exact shape per their vendor.
|
|
2834
|
+
*
|
|
2835
|
+
* Includes a `hintFor(message)` helper the operator customizes with
|
|
2836
|
+
* vendor-specific "here's the likely fix" guidance for common error
|
|
2837
|
+
* shapes (401 / 403 / 404 / network / 5xx). Points users at docs and
|
|
2838
|
+
* setup steps rather than leaving them staring at a raw stack trace.
|
|
2839
|
+
*/
|
|
2840
|
+
function render$3(inputs) {
|
|
2841
|
+
return `#!/usr/bin/env node
|
|
2842
|
+
/**
|
|
2843
|
+
* Read-only smoke test for @theholocron/holocron-plugin-${inputs.slug} against
|
|
2844
|
+
* a live ${inputs.vendorName} account.
|
|
2845
|
+
*
|
|
2846
|
+
* READ-ONLY BY DESIGN. Never calls write(), or bootstrap methods
|
|
2847
|
+
* (\`ensureProject\`, \`ensureEnvironment\`, etc.). Any ERROR line means
|
|
2848
|
+
* the plugin needs adjusting — the \`hintFor\` helper below points at
|
|
2849
|
+
* the most likely fix per error shape.
|
|
2850
|
+
*
|
|
2851
|
+
* Auth: reads the ${inputs.vendorName} token from holocron's keyring —
|
|
2852
|
+
* you must have run \`pnpm holocron auth set ${inputs.slug} <TOKEN>\` first.
|
|
2853
|
+
*
|
|
2854
|
+
* Usage:
|
|
2855
|
+
* pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <arg1> [arg2] ...
|
|
2856
|
+
*
|
|
2857
|
+
* TODO: replace the positional args below with whatever your
|
|
2858
|
+
* capability's methods need (e.g., project id, environment slug,
|
|
2859
|
+
* secret name). Adapt the test steps to your capability's method
|
|
2860
|
+
* surface. Model on \`packages/holocron-plugin-infisical/scripts/validate.mjs\`.
|
|
2861
|
+
*/
|
|
2862
|
+
|
|
2863
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- filled in by operator
|
|
2864
|
+
import { AuthError, createPlugin, resolveToken, verifyToken } from "../src/index.ts";
|
|
2865
|
+
|
|
2866
|
+
const args = process.argv.slice(2);
|
|
2867
|
+
|
|
2868
|
+
if (args.length === 0) {
|
|
2869
|
+
console.error("usage: pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <args>");
|
|
2870
|
+
process.exit(2);
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// Use the plugin's own auth resolution so the validate script honors
|
|
2874
|
+
// the same 4-step precedence as the plugin's runtime:
|
|
2875
|
+
// --token → ${inputs.tokenEnv} → ${inputs.vendorEnv} → keyring
|
|
2876
|
+
// (--token isn't available here since this is a bare Node script;
|
|
2877
|
+
// the other three all work.)
|
|
2878
|
+
let token;
|
|
2879
|
+
try {
|
|
2880
|
+
token = resolveToken();
|
|
2881
|
+
} catch (err) {
|
|
2882
|
+
if (err instanceof AuthError) {
|
|
2883
|
+
console.error(err.message);
|
|
2884
|
+
console.error(" see: packages/holocron-plugin-${inputs.slug}/README.md#setup");
|
|
2885
|
+
process.exit(2);
|
|
2886
|
+
}
|
|
2887
|
+
throw err;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
console.log("Validating @theholocron/holocron-plugin-${inputs.slug} (READ-ONLY)");
|
|
2891
|
+
console.log("");
|
|
2892
|
+
|
|
2893
|
+
// ── 1. verifyToken ─────────────────────────────────────────────────
|
|
2894
|
+
console.log("[1/N] verifyToken");
|
|
2895
|
+
const verifyResult = await verifyToken(token);
|
|
2896
|
+
console.log(\` \${verifyResult.ok ? "✓" : "✗"} \${JSON.stringify(verifyResult)}\`);
|
|
2897
|
+
if (!verifyResult.ok) {
|
|
2898
|
+
const hint = hintFor(verifyResult.message);
|
|
2899
|
+
if (hint) console.log(\` hint: \${hint}\`);
|
|
2900
|
+
}
|
|
2901
|
+
console.log("");
|
|
2902
|
+
|
|
2903
|
+
// ── 2..N. capability method calls ──────────────────────────────────
|
|
2904
|
+
// TODO: implement one \`runStep\`-wrapped call per meaningful read-side
|
|
2905
|
+
// capability method. Model on the infisical validate.mjs. Never call
|
|
2906
|
+
// write / ensure* / other mutating paths.
|
|
2907
|
+
//
|
|
2908
|
+
// Example:
|
|
2909
|
+
// console.log("[2/N] vault.list()");
|
|
2910
|
+
// await runStep(async () => {
|
|
2911
|
+
// const keys = await vault.list();
|
|
2912
|
+
// console.log(\` ✓ \${keys.length} secrets\`);
|
|
2913
|
+
// });
|
|
2914
|
+
|
|
2915
|
+
console.log("Done. Fill in capability method calls above before shipping.");
|
|
2916
|
+
|
|
2917
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
2918
|
+
|
|
2919
|
+
/** Wrap a capability call, print ERROR + hint on failure. */
|
|
2920
|
+
async function runStep(body) {
|
|
2921
|
+
try {
|
|
2922
|
+
await body();
|
|
2923
|
+
} catch (err) {
|
|
2924
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2925
|
+
console.log(\` ✗ ERROR: \${message}\`);
|
|
2926
|
+
const hint = hintFor(message);
|
|
2927
|
+
if (hint) console.log(\` hint: \${hint}\`);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
/**
|
|
2932
|
+
* Point the operator at the most likely fix based on the error shape.
|
|
2933
|
+
* Generic defaults below — CUSTOMIZE per your vendor's docs URLs and
|
|
2934
|
+
* common permission/config gotchas.
|
|
2935
|
+
*/
|
|
2936
|
+
function hintFor(message) {
|
|
2937
|
+
if (/→ 401/.test(message)) {
|
|
2938
|
+
return "token invalid — regenerate per ${inputs.vendorName}'s docs (see README §Setup)";
|
|
2939
|
+
}
|
|
2940
|
+
if (/→ 403/.test(message)) {
|
|
2941
|
+
return "token authenticates but lacks scope — check the token/identity has permissions on the resource";
|
|
2942
|
+
}
|
|
2943
|
+
if (/→ 404/.test(message)) {
|
|
2944
|
+
return "endpoint or resource not found — verify your positional args match a real resource";
|
|
2945
|
+
}
|
|
2946
|
+
if (/fetch failed|status: 0|network/i.test(message)) {
|
|
2947
|
+
return "network error — check the base URL (${inputs.baseUrl}) and connectivity";
|
|
2948
|
+
}
|
|
2949
|
+
if (/→ 5\\d\\d/.test(message)) {
|
|
2950
|
+
return "server error — vendor-side. Retry, and check the vendor's status page if persistent";
|
|
2951
|
+
}
|
|
2952
|
+
return null;
|
|
2953
|
+
}
|
|
2954
|
+
`;
|
|
2955
|
+
}
|
|
2956
|
+
//#endregion
|
|
2957
|
+
//#region src/commands/plugin-create/templates/verify-token.ts
|
|
2958
|
+
function render$2(inputs) {
|
|
2959
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
2960
|
+
return `/**
|
|
2961
|
+
* \`verifyToken\` — plugin-level export used by \`holocron auth set\` +
|
|
2962
|
+
* \`holocron auth check\`. Hits a lightweight whoami-style endpoint
|
|
2963
|
+
* and translates the response into the normalized \`VerifyTokenResult\`
|
|
2964
|
+
* shape.
|
|
2965
|
+
*
|
|
2966
|
+
* Kept as a standalone function (not a capability method) so the auth
|
|
2967
|
+
* command can call it without initializing the full plugin — plugin
|
|
2968
|
+
* construction requires an already-resolved token, which is exactly
|
|
2969
|
+
* what we don't have yet at bootstrap time.
|
|
2970
|
+
*
|
|
2971
|
+
* TODO: replace \`/me\` with the ${inputs.vendorName} equivalent of a
|
|
2972
|
+
* "check my token" endpoint. Common shapes: \`/user\`, \`/whoami\`,
|
|
2973
|
+
* \`/me\`, \`/account\`.
|
|
2974
|
+
*/
|
|
2975
|
+
|
|
2976
|
+
import { ${clientClass} } from "./rest.js";
|
|
2977
|
+
|
|
2978
|
+
export interface VerifyTokenSuccess {
|
|
2979
|
+
ok: true;
|
|
2980
|
+
subject: string;
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
export interface VerifyTokenFailure {
|
|
2984
|
+
ok: false;
|
|
2985
|
+
message: string;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
export type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
|
|
2989
|
+
|
|
2990
|
+
interface MeResponse {
|
|
2991
|
+
/** Adjust to whatever ${inputs.vendorName}'s whoami endpoint returns. */
|
|
2992
|
+
name?: string;
|
|
2993
|
+
email?: string;
|
|
2994
|
+
id?: string;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
export interface VerifyTokenOptions {
|
|
2998
|
+
baseUrl?: string;
|
|
2999
|
+
fetch?: typeof fetch;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
export async function verifyToken(token: string, opts: VerifyTokenOptions = {}): Promise<VerifyTokenResult> {
|
|
3003
|
+
const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
|
|
3004
|
+
if (opts.baseUrl !== undefined) restOpts.baseUrl = opts.baseUrl;
|
|
3005
|
+
if (opts.fetch !== undefined) restOpts.fetch = opts.fetch;
|
|
3006
|
+
const rest = new ${clientClass}(restOpts);
|
|
3007
|
+
try {
|
|
3008
|
+
const me = await rest.request<MeResponse>("/me");
|
|
3009
|
+
// Optional chaining because \`me\` is \`undefined\` on 204 / empty body.
|
|
3010
|
+
const subject = me?.email ?? me?.name ?? me?.id ?? "unknown";
|
|
3011
|
+
return { ok: true, subject: \`user @ \${subject}\` };
|
|
3012
|
+
} catch (err) {
|
|
3013
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3014
|
+
return { ok: false, message };
|
|
179
3015
|
}
|
|
180
3016
|
}
|
|
181
|
-
|
|
182
|
-
return `${report.lifecycle.filter((l) => l.resolved).length}/${report.lifecycle.length} lifecycle slots ready`;
|
|
3017
|
+
`;
|
|
183
3018
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
3019
|
+
//#endregion
|
|
3020
|
+
//#region src/commands/plugin-create/templates/verify-token-test.ts
|
|
3021
|
+
function render$1(_inputs) {
|
|
3022
|
+
return `import { describe, expect, it } from "vitest";
|
|
3023
|
+
|
|
3024
|
+
import { verifyToken } from "../verify-token.js";
|
|
3025
|
+
import { stubFetch } from "./helpers.js";
|
|
3026
|
+
|
|
3027
|
+
describe("verifyToken", () => {
|
|
3028
|
+
it("returns ok with a subject when /me returns 200", async () => {
|
|
3029
|
+
const stub = stubFetch([{ status: 200, body: { email: "user@example.com" } }]);
|
|
3030
|
+
const result = await verifyToken("token", { fetch: stub.fetch });
|
|
3031
|
+
expect(result.ok).toBe(true);
|
|
3032
|
+
if (result.ok) {
|
|
3033
|
+
expect(result.subject).toMatch(/user@example.com/);
|
|
3034
|
+
}
|
|
3035
|
+
});
|
|
3036
|
+
|
|
3037
|
+
it("returns ok:false with the error message on 401", async () => {
|
|
3038
|
+
const stub = stubFetch([{ status: 401, body: { messages: ["Invalid token"] } }]);
|
|
3039
|
+
const result = await verifyToken("bad", { fetch: stub.fetch });
|
|
3040
|
+
expect(result.ok).toBe(false);
|
|
3041
|
+
if (!result.ok) {
|
|
3042
|
+
expect(result.message).toMatch(/→ 401/);
|
|
3043
|
+
}
|
|
3044
|
+
});
|
|
3045
|
+
|
|
3046
|
+
it("returns ok:false when the network layer throws", async () => {
|
|
3047
|
+
const throwing: typeof fetch = async () => {
|
|
3048
|
+
throw new TypeError("network down");
|
|
3049
|
+
};
|
|
3050
|
+
const result = await verifyToken("t", { fetch: throwing });
|
|
3051
|
+
expect(result.ok).toBe(false);
|
|
3052
|
+
if (!result.ok) {
|
|
3053
|
+
expect(result.message).toMatch(/network down/);
|
|
3054
|
+
}
|
|
3055
|
+
});
|
|
3056
|
+
});
|
|
3057
|
+
`;
|
|
191
3058
|
}
|
|
192
|
-
|
|
193
|
-
|
|
3059
|
+
//#endregion
|
|
3060
|
+
//#region src/commands/plugin-create/templates/vitest-config.ts
|
|
3061
|
+
function render(_inputs) {
|
|
3062
|
+
return `import { defineConfig } from "vitest/config";
|
|
3063
|
+
|
|
3064
|
+
export default defineConfig({
|
|
3065
|
+
test: {
|
|
3066
|
+
environment: "node",
|
|
3067
|
+
globals: false,
|
|
3068
|
+
coverage: {
|
|
3069
|
+
provider: "v8",
|
|
3070
|
+
reporter: ["text", "html", "json-summary"],
|
|
3071
|
+
include: ["src/**/*.ts"],
|
|
3072
|
+
exclude: ["src/**/__tests__/**", "src/**/*.test.ts", "src/index.ts"],
|
|
3073
|
+
thresholds: { lines: 0, functions: 0, branches: 0, statements: 0 },
|
|
3074
|
+
},
|
|
3075
|
+
},
|
|
3076
|
+
});
|
|
3077
|
+
`;
|
|
194
3078
|
}
|
|
195
3079
|
//#endregion
|
|
196
|
-
//#region src/commands/
|
|
3080
|
+
//#region src/commands/plugin-create/index.ts
|
|
197
3081
|
/**
|
|
198
|
-
* `holocron
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* npm requires a package to exist before Trusted Publishing can be
|
|
202
|
-
* configured on it. So the first publish has to happen outside the
|
|
203
|
-
* OIDC flow — using either a browser-auth session (`npm login
|
|
204
|
-
* --auth-type=web`) or an ephemeral automation token. This command
|
|
205
|
-
* runs the publish step + tells you exactly what to do next.
|
|
206
|
-
*
|
|
207
|
-
* Workflow:
|
|
208
|
-
*
|
|
209
|
-
* $ npm login --auth-type=web # one-time, browser-based
|
|
210
|
-
* $ pnpm install --frozen-lockfile
|
|
211
|
-
* $ pnpm build
|
|
212
|
-
* $ pnpm exec tsx packages/cli/src/cli.ts npm publish-initial
|
|
3082
|
+
* `holocron plugin create <slug> <vendor>` — scaffold a new plugin
|
|
3083
|
+
* package matching the proven template.
|
|
213
3084
|
*
|
|
214
|
-
*
|
|
215
|
-
* Trusted Publisher setup reminder. `pnpm install` + `pnpm build`
|
|
216
|
-
* stay outside the command (no pnpm-inside-pnpm).
|
|
3085
|
+
* Design: see `.notes/tool-plugin-create.spec.md`.
|
|
217
3086
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
3087
|
+
* Flow:
|
|
3088
|
+
* 1. Preflight — verify CWD is a workspace root (pnpm-workspace.yaml
|
|
3089
|
+
* + packages/ dir present).
|
|
3090
|
+
* 2. Slug collision — packages/holocron-plugin-<slug>/ must not exist.
|
|
3091
|
+
* 3. Capability sanity — must be one of the 14 known keys; warn for
|
|
3092
|
+
* many-cardinality caps.
|
|
3093
|
+
* 4. Prompt — fill in any missing flags via cli-utils / inquirer
|
|
3094
|
+
* (Phase B; Phase A takes fully-populated input).
|
|
3095
|
+
* 5. Generate — for each template, write to
|
|
3096
|
+
* packages/holocron-plugin-<slug>/<path>.
|
|
3097
|
+
* 6. Verify (unless --no-verify) — Phase B; runs pnpm install +
|
|
3098
|
+
* pnpm --filter <pkg> typecheck lint test.
|
|
3099
|
+
* 7. Print next steps.
|
|
221
3100
|
*/
|
|
222
|
-
|
|
223
|
-
"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
3101
|
+
var PluginCreateError = class extends Error {
|
|
3102
|
+
name = "PluginCreateError";
|
|
3103
|
+
};
|
|
3104
|
+
const TEMPLATES = [
|
|
3105
|
+
{
|
|
3106
|
+
path: "package.json",
|
|
3107
|
+
render: render$10
|
|
3108
|
+
},
|
|
3109
|
+
{
|
|
3110
|
+
path: "tsconfig.json",
|
|
3111
|
+
render: render$5
|
|
3112
|
+
},
|
|
3113
|
+
{
|
|
3114
|
+
path: "vitest.config.ts",
|
|
3115
|
+
render
|
|
3116
|
+
},
|
|
3117
|
+
{
|
|
3118
|
+
path: "eslint.config.js",
|
|
3119
|
+
render: render$13
|
|
3120
|
+
},
|
|
3121
|
+
{
|
|
3122
|
+
path: "tsdown.config.ts",
|
|
3123
|
+
render: render$4
|
|
3124
|
+
},
|
|
3125
|
+
{
|
|
3126
|
+
path: "README.md",
|
|
3127
|
+
render: render$8
|
|
3128
|
+
},
|
|
3129
|
+
{
|
|
3130
|
+
path: "src/auth.ts",
|
|
3131
|
+
render: render$17
|
|
3132
|
+
},
|
|
3133
|
+
{
|
|
3134
|
+
path: "src/rest.ts",
|
|
3135
|
+
render: render$7
|
|
3136
|
+
},
|
|
3137
|
+
{
|
|
3138
|
+
path: "src/verify-token.ts",
|
|
3139
|
+
render: render$2
|
|
3140
|
+
},
|
|
3141
|
+
{
|
|
3142
|
+
path: "src/index.ts",
|
|
3143
|
+
render: render$9
|
|
3144
|
+
},
|
|
3145
|
+
{
|
|
3146
|
+
path: "src/capabilities/{{capability}}.ts",
|
|
3147
|
+
render: render$15
|
|
3148
|
+
},
|
|
3149
|
+
{
|
|
3150
|
+
path: "src/__tests__/helpers.ts",
|
|
3151
|
+
render: render$12
|
|
3152
|
+
},
|
|
3153
|
+
{
|
|
3154
|
+
path: "src/__tests__/auth.test.ts",
|
|
3155
|
+
render: render$16
|
|
3156
|
+
},
|
|
3157
|
+
{
|
|
3158
|
+
path: "src/__tests__/rest.test.ts",
|
|
3159
|
+
render: render$6
|
|
3160
|
+
},
|
|
3161
|
+
{
|
|
3162
|
+
path: "src/__tests__/verify-token.test.ts",
|
|
3163
|
+
render: render$1
|
|
3164
|
+
},
|
|
3165
|
+
{
|
|
3166
|
+
path: "src/__tests__/{{capability}}.test.ts",
|
|
3167
|
+
render: render$14
|
|
3168
|
+
},
|
|
3169
|
+
{
|
|
3170
|
+
path: "src/__tests__/index.test.ts",
|
|
3171
|
+
render: render$11
|
|
3172
|
+
},
|
|
3173
|
+
{
|
|
3174
|
+
path: "scripts/validate.mjs",
|
|
3175
|
+
render: render$3
|
|
3176
|
+
}
|
|
230
3177
|
];
|
|
231
|
-
|
|
232
|
-
|
|
3178
|
+
/** Replace `{{capability}}` in a template path with the actual capability key. */
|
|
3179
|
+
function resolvePath(template, inputs) {
|
|
3180
|
+
return template.replace(/\{\{capability\}\}/g, inputs.capability);
|
|
3181
|
+
}
|
|
3182
|
+
function runPluginCreate(input) {
|
|
233
3183
|
const cwd = input.cwd ?? process.cwd();
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
print
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
"publish",
|
|
272
|
-
"--access",
|
|
273
|
-
"public",
|
|
274
|
-
"--no-git-checks",
|
|
275
|
-
"--tag",
|
|
276
|
-
tag
|
|
277
|
-
], { cwd });
|
|
278
|
-
if (publish.exitCode !== 0) {
|
|
279
|
-
const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
|
|
280
|
-
print(` ✗ ${message}`);
|
|
281
|
-
return {
|
|
282
|
-
status: "fail",
|
|
283
|
-
message,
|
|
284
|
-
packageNames: PUBLISHABLE_PACKAGES
|
|
285
|
-
};
|
|
3184
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
3185
|
+
const write = input.writeFile ?? defaultWrite;
|
|
3186
|
+
preflight(cwd);
|
|
3187
|
+
validateSlug(input.slug);
|
|
3188
|
+
validateVendorName(input.vendorName);
|
|
3189
|
+
const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
|
|
3190
|
+
if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
|
|
3191
|
+
validateCapability(input.capability, print);
|
|
3192
|
+
const derived = deriveDefaults({
|
|
3193
|
+
slug: input.slug,
|
|
3194
|
+
vendorName: input.vendorName,
|
|
3195
|
+
capability: input.capability
|
|
3196
|
+
});
|
|
3197
|
+
const inputs = {
|
|
3198
|
+
slug: input.slug,
|
|
3199
|
+
vendorName: input.vendorName,
|
|
3200
|
+
vendorUpper: derived.vendorUpper,
|
|
3201
|
+
capability: input.capability,
|
|
3202
|
+
capabilityClass: derived.capabilityClass,
|
|
3203
|
+
tokenEnv: input.tokenEnv ?? derived.tokenEnv,
|
|
3204
|
+
vendorEnv: input.vendorEnv,
|
|
3205
|
+
baseUrl: trimTrailingSlashes(input.baseUrl),
|
|
3206
|
+
transport: derived.transport
|
|
3207
|
+
};
|
|
3208
|
+
const filesWritten = [];
|
|
3209
|
+
print(`Scaffolding @theholocron/holocron-plugin-${inputs.slug}${input.dryRun ? " (dry-run)" : ""}`);
|
|
3210
|
+
print(` → ${packageDir}`);
|
|
3211
|
+
for (const template of TEMPLATES) {
|
|
3212
|
+
const resolvedPath = resolvePath(template.path, inputs);
|
|
3213
|
+
const filepath = path.join(packageDir, resolvedPath);
|
|
3214
|
+
const content = template.render(inputs);
|
|
3215
|
+
if (input.dryRun) print(` … would write ${resolvedPath} (${content.length} bytes)`);
|
|
3216
|
+
else {
|
|
3217
|
+
write(filepath, content);
|
|
3218
|
+
print(` ✓ ${resolvedPath}`);
|
|
3219
|
+
}
|
|
3220
|
+
filesWritten.push(resolvedPath);
|
|
286
3221
|
}
|
|
287
|
-
|
|
288
|
-
printNextSteps(print, env);
|
|
3222
|
+
if (!input.dryRun) printNextSteps(print, inputs);
|
|
289
3223
|
return {
|
|
290
3224
|
status: "ok",
|
|
291
|
-
|
|
3225
|
+
packagePath: packageDir,
|
|
3226
|
+
filesWritten
|
|
292
3227
|
};
|
|
293
3228
|
}
|
|
294
|
-
function
|
|
3229
|
+
function preflight(cwd) {
|
|
3230
|
+
if (!existsSync(path.join(cwd, "pnpm-workspace.yaml"))) throw new PluginCreateError(`\`pnpm-workspace.yaml\` not found in \`${cwd}\`. Run \`holocron plugin create\` from the monorepo root.`);
|
|
3231
|
+
if (!existsSync(path.join(cwd, "packages"))) throw new PluginCreateError(`\`packages/\` directory not found in \`${cwd}\`.`);
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* npm-package-name conventions: kebab-case, starts with lowercase
|
|
3235
|
+
* letter. Rejecting numeric-prefixed slugs also sidesteps the
|
|
3236
|
+
* `env.HOLOCRON_1FOO_TOKEN` invalid-identifier trap in the auth
|
|
3237
|
+
* template.
|
|
3238
|
+
*/
|
|
3239
|
+
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
3240
|
+
function validateSlug(slug) {
|
|
3241
|
+
if (!SLUG_RE.test(slug)) throw new PluginCreateError(`invalid slug "${slug}" — must be kebab-case: lowercase letter followed by lowercase letters, digits, or hyphens. Examples: "clerk", "cloud-flare", "doppler".`);
|
|
3242
|
+
}
|
|
3243
|
+
/** PascalCase — starts with uppercase, alphanumeric only. */
|
|
3244
|
+
const VENDOR_NAME_RE = /^[A-Z][a-zA-Z0-9]*$/;
|
|
3245
|
+
function validateVendorName(name) {
|
|
3246
|
+
if (!VENDOR_NAME_RE.test(name)) throw new PluginCreateError(`invalid vendor name "${name}" — must be PascalCase: starts with uppercase, alphanumeric only. Examples: "Clerk", "CloudFlare", "Doppler".`);
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Trim trailing slashes with a plain loop (no regex — matches the
|
|
3250
|
+
* rest.ts template's own trimming to stay ReDoS-safe under CodeQL).
|
|
3251
|
+
*/
|
|
3252
|
+
function trimTrailingSlashes(url) {
|
|
3253
|
+
let out = url;
|
|
3254
|
+
while (out.endsWith("/")) out = out.slice(0, -1);
|
|
3255
|
+
return out;
|
|
3256
|
+
}
|
|
3257
|
+
function validateCapability(capability, print) {
|
|
3258
|
+
if (!(capability in CARDINALITY)) throw new PluginCreateError(`\`${capability}\` is not a known capability. See \`packages/cli/src/capabilities/index.ts\`.`);
|
|
3259
|
+
if (CARDINALITY[capability] === "many") print(` ! ${capability} is a many-cardinality capability — multiple providers can be active at once. Confirm your config wiring accordingly.`);
|
|
3260
|
+
}
|
|
3261
|
+
function defaultWrite(filepath, content) {
|
|
3262
|
+
mkdirSync(path.dirname(filepath), { recursive: true });
|
|
3263
|
+
writeFileSync(filepath, content, "utf8");
|
|
3264
|
+
}
|
|
3265
|
+
function printNextSteps(print, inputs) {
|
|
295
3266
|
print("");
|
|
296
|
-
print(
|
|
297
|
-
|
|
298
|
-
print("
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
3267
|
+
print(` Scaffolded @theholocron/holocron-plugin-${inputs.slug} (18 files).`);
|
|
3268
|
+
print("");
|
|
3269
|
+
print(" Next:");
|
|
3270
|
+
print(` 1. pnpm install # picks up the workspace package`);
|
|
3271
|
+
print(` 2. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} typecheck # green`);
|
|
3272
|
+
print(` 3. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} lint # green`);
|
|
3273
|
+
print(` 4. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} test # green (stubs pass, real tests are it.todo)`);
|
|
3274
|
+
print(` 5. Implement ${inputs.capabilityClass} methods in src/capabilities/${inputs.capability}.ts`);
|
|
3275
|
+
print(` 6. Replace it.todo(...) with real tests in src/__tests__/${inputs.capability}.test.ts`);
|
|
3276
|
+
print(" 7. Commit + push when capability is functionally complete.");
|
|
304
3277
|
}
|
|
305
|
-
const defaultExec = async (cmd, args, opts) => {
|
|
306
|
-
const result = spawnSync(cmd, args, {
|
|
307
|
-
cwd: opts.cwd,
|
|
308
|
-
encoding: "utf8",
|
|
309
|
-
stdio: [
|
|
310
|
-
"inherit",
|
|
311
|
-
"pipe",
|
|
312
|
-
"pipe"
|
|
313
|
-
]
|
|
314
|
-
});
|
|
315
|
-
return {
|
|
316
|
-
exitCode: result.status ?? -1,
|
|
317
|
-
stdout: result.stdout ?? "",
|
|
318
|
-
stderr: result.stderr ?? ""
|
|
319
|
-
};
|
|
320
|
-
};
|
|
321
3278
|
//#endregion
|
|
322
3279
|
//#region src/commands/secret-set.ts
|
|
323
3280
|
async function runSecretSet(input) {
|
|
@@ -486,6 +3443,153 @@ function vaultProviderName(loader) {
|
|
|
486
3443
|
}
|
|
487
3444
|
//#endregion
|
|
488
3445
|
//#region src/commands/setup.ts
|
|
3446
|
+
const DEPENDABOT_CONFIG = `\
|
|
3447
|
+
# AUTO-GENERATED by holocron — run \`holocron setup\` to regenerate.
|
|
3448
|
+
version: 2
|
|
3449
|
+
updates:
|
|
3450
|
+
- package-ecosystem: npm
|
|
3451
|
+
directory: /
|
|
3452
|
+
schedule:
|
|
3453
|
+
interval: weekly
|
|
3454
|
+
groups:
|
|
3455
|
+
security-patches:
|
|
3456
|
+
applies-to: security-updates
|
|
3457
|
+
patterns:
|
|
3458
|
+
- "*"
|
|
3459
|
+
all-dependencies:
|
|
3460
|
+
update-types:
|
|
3461
|
+
- minor
|
|
3462
|
+
- patch
|
|
3463
|
+
|
|
3464
|
+
- package-ecosystem: github-actions
|
|
3465
|
+
directory: /
|
|
3466
|
+
schedule:
|
|
3467
|
+
interval: weekly
|
|
3468
|
+
groups:
|
|
3469
|
+
all-actions:
|
|
3470
|
+
patterns:
|
|
3471
|
+
- "*"
|
|
3472
|
+
`;
|
|
3473
|
+
const RULESET_NAME = "holocron-default-branch";
|
|
3474
|
+
const BALANCED_REPO_SETTINGS = {
|
|
3475
|
+
allow_squash_merge: true,
|
|
3476
|
+
allow_merge_commit: false,
|
|
3477
|
+
allow_rebase_merge: false,
|
|
3478
|
+
allow_auto_merge: true,
|
|
3479
|
+
allow_update_branch: true,
|
|
3480
|
+
delete_branch_on_merge: true,
|
|
3481
|
+
has_issues: true,
|
|
3482
|
+
has_discussions: true,
|
|
3483
|
+
has_projects: true,
|
|
3484
|
+
has_wiki: false
|
|
3485
|
+
};
|
|
3486
|
+
function buildClassicProtectionPayload(requiredChecks = []) {
|
|
3487
|
+
return {
|
|
3488
|
+
required_status_checks: requiredChecks.length > 0 ? {
|
|
3489
|
+
strict: false,
|
|
3490
|
+
contexts: requiredChecks
|
|
3491
|
+
} : null,
|
|
3492
|
+
enforce_admins: false,
|
|
3493
|
+
required_pull_request_reviews: {
|
|
3494
|
+
required_approving_review_count: 0,
|
|
3495
|
+
dismiss_stale_reviews: false,
|
|
3496
|
+
require_code_owner_reviews: false
|
|
3497
|
+
},
|
|
3498
|
+
restrictions: null,
|
|
3499
|
+
allow_force_pushes: false,
|
|
3500
|
+
allow_deletions: false
|
|
3501
|
+
};
|
|
3502
|
+
}
|
|
3503
|
+
function buildRulesetPayload(requiredChecks = []) {
|
|
3504
|
+
const rules = [
|
|
3505
|
+
{ type: "deletion" },
|
|
3506
|
+
{ type: "non_fast_forward" },
|
|
3507
|
+
{
|
|
3508
|
+
type: "pull_request",
|
|
3509
|
+
parameters: {
|
|
3510
|
+
required_approving_review_count: 0,
|
|
3511
|
+
dismiss_stale_reviews_on_push: false,
|
|
3512
|
+
require_code_owner_review: false,
|
|
3513
|
+
require_last_push_approval: false,
|
|
3514
|
+
required_review_thread_resolution: false
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
];
|
|
3518
|
+
if (requiredChecks.length > 0) rules.push({
|
|
3519
|
+
type: "required_status_checks",
|
|
3520
|
+
parameters: {
|
|
3521
|
+
required_status_checks: requiredChecks.map((context) => ({ context })),
|
|
3522
|
+
strict_required_status_checks_policy: false
|
|
3523
|
+
}
|
|
3524
|
+
});
|
|
3525
|
+
return {
|
|
3526
|
+
name: RULESET_NAME,
|
|
3527
|
+
target: "branch",
|
|
3528
|
+
enforcement: "active",
|
|
3529
|
+
conditions: { ref_name: {
|
|
3530
|
+
include: ["~DEFAULT_BRANCH"],
|
|
3531
|
+
exclude: []
|
|
3532
|
+
} },
|
|
3533
|
+
rules
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
3536
|
+
async function upsertBranchProtection(source, dryRun, requiredChecks) {
|
|
3537
|
+
const step = `upsert ruleset ${RULESET_NAME}`;
|
|
3538
|
+
if (dryRun) return {
|
|
3539
|
+
capability: "source",
|
|
3540
|
+
step,
|
|
3541
|
+
status: "dry-run"
|
|
3542
|
+
};
|
|
3543
|
+
try {
|
|
3544
|
+
const found = (await source.listRulesets()).find((r) => r.name === RULESET_NAME);
|
|
3545
|
+
if (found) {
|
|
3546
|
+
await source.updateRuleset(found.id, buildRulesetPayload(requiredChecks));
|
|
3547
|
+
return {
|
|
3548
|
+
capability: "source",
|
|
3549
|
+
step,
|
|
3550
|
+
status: "ok",
|
|
3551
|
+
message: "updated"
|
|
3552
|
+
};
|
|
3553
|
+
}
|
|
3554
|
+
await source.createRuleset(buildRulesetPayload(requiredChecks));
|
|
3555
|
+
return {
|
|
3556
|
+
capability: "source",
|
|
3557
|
+
step,
|
|
3558
|
+
status: "ok",
|
|
3559
|
+
message: "created"
|
|
3560
|
+
};
|
|
3561
|
+
} catch (err) {
|
|
3562
|
+
if (!(err instanceof ProviderApiError) || err.status !== 403) return {
|
|
3563
|
+
capability: "source",
|
|
3564
|
+
step,
|
|
3565
|
+
status: "fail",
|
|
3566
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
try {
|
|
3570
|
+
const repo = await source.getRepo();
|
|
3571
|
+
await source.protectBranch(repo.defaultBranch, buildClassicProtectionPayload(requiredChecks));
|
|
3572
|
+
return {
|
|
3573
|
+
capability: "source",
|
|
3574
|
+
step,
|
|
3575
|
+
status: "ok",
|
|
3576
|
+
message: `classic protection on ${repo.defaultBranch}`
|
|
3577
|
+
};
|
|
3578
|
+
} catch (err) {
|
|
3579
|
+
if (err instanceof ProviderApiError && err.status === 403) return {
|
|
3580
|
+
capability: "source",
|
|
3581
|
+
step,
|
|
3582
|
+
status: "skip",
|
|
3583
|
+
message: "branch protection unavailable on private repos without GitHub Pro/Team"
|
|
3584
|
+
};
|
|
3585
|
+
return {
|
|
3586
|
+
capability: "source",
|
|
3587
|
+
step,
|
|
3588
|
+
status: "fail",
|
|
3589
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3590
|
+
};
|
|
3591
|
+
}
|
|
3592
|
+
}
|
|
489
3593
|
async function runSetup(input) {
|
|
490
3594
|
const print = input.print ?? ((line) => console.log(line));
|
|
491
3595
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
@@ -503,13 +3607,59 @@ async function runSetup(input) {
|
|
|
503
3607
|
"enableVulnerabilityAlerts",
|
|
504
3608
|
"enableAutomatedSecurityFixes",
|
|
505
3609
|
"enableSecretScanning",
|
|
506
|
-
"enablePrivateVulnerabilityReporting"
|
|
3610
|
+
"enablePrivateVulnerabilityReporting",
|
|
3611
|
+
"enableDependencyGraph"
|
|
507
3612
|
]) {
|
|
508
3613
|
steps.push(await runStep("source", method, dryRun, async () => {
|
|
509
3614
|
await source[method]();
|
|
510
3615
|
}));
|
|
511
3616
|
print(formatStep(steps[steps.length - 1]));
|
|
512
3617
|
}
|
|
3618
|
+
steps.push(await runStep("source", "enableCodeScanning", dryRun, async () => {
|
|
3619
|
+
return await source.enableCodeScanning();
|
|
3620
|
+
}));
|
|
3621
|
+
print(formatStep(steps[steps.length - 1]));
|
|
3622
|
+
const policy = config.project.repoPolicy;
|
|
3623
|
+
if (policy && policy.preset !== "none") {
|
|
3624
|
+
const preset = policy.preset ?? "balanced";
|
|
3625
|
+
steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
|
|
3626
|
+
await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
|
|
3627
|
+
}));
|
|
3628
|
+
print(formatStep(steps[steps.length - 1]));
|
|
3629
|
+
const requiredChecks = preset === "strict" ? policy.requiredChecks ?? [] : [];
|
|
3630
|
+
steps.push(await upsertBranchProtection(source, dryRun, requiredChecks));
|
|
3631
|
+
print(formatStep(steps[steps.length - 1]));
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
const workflows = config.project.workflows;
|
|
3635
|
+
if (loader.has("source") && workflows && workflows.length > 0) {
|
|
3636
|
+
const source = loader.get("source");
|
|
3637
|
+
print(" → workflows");
|
|
3638
|
+
for (const entry of workflows) {
|
|
3639
|
+
const name = typeof entry === "string" ? entry : entry.name;
|
|
3640
|
+
const withOverrides = typeof entry === "object" ? entry.with : void 0;
|
|
3641
|
+
if (!KNOWN_WORKFLOWS.has(name)) {
|
|
3642
|
+
steps.push({
|
|
3643
|
+
capability: "source",
|
|
3644
|
+
step: `write workflow ${name}`,
|
|
3645
|
+
status: "skip",
|
|
3646
|
+
message: `unknown workflow "${name}" — no template available`
|
|
3647
|
+
});
|
|
3648
|
+
print(formatStep(steps[steps.length - 1]));
|
|
3649
|
+
continue;
|
|
3650
|
+
}
|
|
3651
|
+
steps.push(await runStep("source", `write workflow ${name}`, dryRun, async () => {
|
|
3652
|
+
await source.writeWorkflowFile(`${name}.yml`, workflowHeader() + generateThinCallerContent(name, withOverrides));
|
|
3653
|
+
}));
|
|
3654
|
+
print(formatStep(steps[steps.length - 1]));
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
if (loader.has("source") && config.project.repoPolicy?.preset !== "none") {
|
|
3658
|
+
const source = loader.get("source");
|
|
3659
|
+
steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
|
|
3660
|
+
await source.writeRepoFile(".github/dependabot.yml", DEPENDABOT_CONFIG);
|
|
3661
|
+
}));
|
|
3662
|
+
print(formatStep(steps[steps.length - 1]));
|
|
513
3663
|
}
|
|
514
3664
|
if (loader.has("environments")) {
|
|
515
3665
|
const envs = loader.get("environments");
|
|
@@ -550,6 +3700,22 @@ async function runSetup(input) {
|
|
|
550
3700
|
if (loader.has("vault")) {
|
|
551
3701
|
const vault = loader.get("vault");
|
|
552
3702
|
print(" → vault");
|
|
3703
|
+
if (vault.ensureProject) {
|
|
3704
|
+
steps.push(await runStep("vault", `ensureProject ${config.project.name}`, dryRun, async () => {
|
|
3705
|
+
return `project ${(await vault.ensureProject(config.project.name)).alreadyExists ? "exists" : "created"}`;
|
|
3706
|
+
}));
|
|
3707
|
+
print(formatStep(steps[steps.length - 1]));
|
|
3708
|
+
}
|
|
3709
|
+
if (vault.ensureEnvironment) for (const envName of [
|
|
3710
|
+
"dev",
|
|
3711
|
+
"stg",
|
|
3712
|
+
"prd"
|
|
3713
|
+
]) {
|
|
3714
|
+
steps.push(await runStep("vault", `ensureEnvironment ${envName}`, dryRun, async () => {
|
|
3715
|
+
return `${envName} ${(await vault.ensureEnvironment(config.project.name, envName)).alreadyExists ? "exists" : "created"}`;
|
|
3716
|
+
}));
|
|
3717
|
+
print(formatStep(steps[steps.length - 1]));
|
|
3718
|
+
}
|
|
553
3719
|
try {
|
|
554
3720
|
const keys = await vault.list();
|
|
555
3721
|
steps.push({
|
|
@@ -686,6 +3852,7 @@ async function fileExists(path) {
|
|
|
686
3852
|
}
|
|
687
3853
|
//#endregion
|
|
688
3854
|
//#region src/cli.ts
|
|
3855
|
+
const { version: CLI_VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
689
3856
|
await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [options]").option("dry-run", {
|
|
690
3857
|
type: "boolean",
|
|
691
3858
|
default: false,
|
|
@@ -698,7 +3865,7 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
|
|
|
698
3865
|
default: process.cwd(),
|
|
699
3866
|
describe: "Directory to search for holocron.config.json"
|
|
700
3867
|
}).command("version", "Print the CLI version", () => {}, () => {
|
|
701
|
-
console.log(
|
|
3868
|
+
console.log(`holocron ${CLI_VERSION}`);
|
|
702
3869
|
}).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
|
|
703
3870
|
type: "string",
|
|
704
3871
|
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
@@ -806,20 +3973,134 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
|
|
|
806
3973
|
branch: argv.branch,
|
|
807
3974
|
...argv.target ? { target: argv.target } : {}
|
|
808
3975
|
})).status === "fail") process.exitCode = 1;
|
|
809
|
-
}).command("npm
|
|
3976
|
+
}).command("npm", "npm-related monorepo utilities", (y) => y.command("bump-versions <new-version>", "Bump all non-private package versions in lockstep (semantic-release prepareCmd)", (yy) => yy.positional("new-version", {
|
|
3977
|
+
type: "string",
|
|
3978
|
+
demandOption: true,
|
|
3979
|
+
describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
|
|
3980
|
+
}), async (argv) => {
|
|
3981
|
+
if ((await runNpmBumpVersions({
|
|
3982
|
+
version: argv.newVersion,
|
|
3983
|
+
cwd: argv.cwd,
|
|
3984
|
+
dryRun: argv.dryRun
|
|
3985
|
+
})).status === "fail") process.exitCode = 1;
|
|
3986
|
+
}).command("publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (yy) => yy.option("tag", {
|
|
810
3987
|
type: "string",
|
|
811
3988
|
default: "alpha",
|
|
812
3989
|
describe: "npm distribution tag (defaults to alpha)"
|
|
3990
|
+
}).option("otp", {
|
|
3991
|
+
type: "string",
|
|
3992
|
+
describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
|
|
813
3993
|
}), async (argv) => {
|
|
814
3994
|
if ((await runNpmPublishInitial({
|
|
815
3995
|
cwd: argv.cwd,
|
|
816
3996
|
tag: argv.tag,
|
|
817
|
-
dryRun: argv.dryRun
|
|
3997
|
+
dryRun: argv.dryRun,
|
|
3998
|
+
...argv.otp ? { otp: argv.otp } : {}
|
|
3999
|
+
})).status === "fail") process.exitCode = 1;
|
|
4000
|
+
}).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("sync-github", "Sync workflow templates and composite actions to theholocron/.github via the GitHub API", (y) => y.option("repo", {
|
|
4001
|
+
type: "string",
|
|
4002
|
+
default: "theholocron/.github",
|
|
4003
|
+
describe: "Target org/repo (default: theholocron/.github)"
|
|
4004
|
+
}).option("branch", {
|
|
4005
|
+
type: "string",
|
|
4006
|
+
describe: "Push to this branch instead of the default branch (enables PR-based workflow for protected repos)"
|
|
4007
|
+
}).option("pr", {
|
|
4008
|
+
type: "boolean",
|
|
4009
|
+
default: false,
|
|
4010
|
+
describe: "Open a PR after pushing to --branch (no-op without --branch)"
|
|
4011
|
+
}).option("message", {
|
|
4012
|
+
type: "string",
|
|
4013
|
+
describe: "Commit message (default: chore: sync from theholocron/holocron)"
|
|
4014
|
+
}).option("output-dir", {
|
|
4015
|
+
type: "string",
|
|
4016
|
+
describe: "Write generated files to this local directory instead of pushing (for validation)"
|
|
4017
|
+
}), async (argv) => {
|
|
4018
|
+
const outputDir = argv["output-dir"];
|
|
4019
|
+
const token = outputDir ? "no-token-needed" : argv.token ?? process.env.GITHUB_TOKEN ?? process.env.HOLOCRON_GITHUB_TOKEN;
|
|
4020
|
+
if (!token) {
|
|
4021
|
+
console.error("sync-github: GitHub token required — pass --token or set GITHUB_TOKEN");
|
|
4022
|
+
process.exitCode = 1;
|
|
4023
|
+
return;
|
|
4024
|
+
}
|
|
4025
|
+
if ((await runSyncGithub({
|
|
4026
|
+
token,
|
|
4027
|
+
repo: argv.repo,
|
|
4028
|
+
dryRun: argv.dryRun,
|
|
4029
|
+
...argv.branch ? { branch: argv.branch } : {},
|
|
4030
|
+
...argv.pr ? { createPr: true } : {},
|
|
4031
|
+
...argv.message ? { message: argv.message } : {},
|
|
4032
|
+
...outputDir ? { outputDir } : {}
|
|
818
4033
|
})).status === "fail") process.exitCode = 1;
|
|
819
4034
|
}).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
|
|
820
4035
|
const loaded = await loadConfig(argv.cwd);
|
|
821
4036
|
console.log(JSON.stringify(loaded.resolved, null, 2));
|
|
822
|
-
}).
|
|
4037
|
+
}).command("plugin create <slug> <vendor>", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
|
|
4038
|
+
type: "string",
|
|
4039
|
+
demandOption: true,
|
|
4040
|
+
describe: "Package slug (kebab-case)"
|
|
4041
|
+
}).positional("vendor", {
|
|
4042
|
+
type: "string",
|
|
4043
|
+
demandOption: true,
|
|
4044
|
+
describe: "Vendor display name (PascalCase)"
|
|
4045
|
+
}).option("capability", {
|
|
4046
|
+
type: "string",
|
|
4047
|
+
describe: "Capability key: source|ci|secrets|environments|issues|deployment|storage|auth|vault|dns|tooling|notifications|analytics|observability"
|
|
4048
|
+
}).option("token-env", {
|
|
4049
|
+
type: "string",
|
|
4050
|
+
describe: "Holocron env var name (defaults to HOLOCRON_<VENDOR>_TOKEN)"
|
|
4051
|
+
}).option("vendor-env", {
|
|
4052
|
+
type: "string",
|
|
4053
|
+
describe: "Vendor-native env var name"
|
|
4054
|
+
}).option("base-url", {
|
|
4055
|
+
type: "string",
|
|
4056
|
+
describe: "REST base URL"
|
|
4057
|
+
}).option("verify", {
|
|
4058
|
+
type: "boolean",
|
|
4059
|
+
default: true,
|
|
4060
|
+
describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
|
|
4061
|
+
}), (argv) => {
|
|
4062
|
+
try {
|
|
4063
|
+
if (!argv.capability || !argv.vendorEnv || !argv.baseUrl) throw new PluginCreateError("Phase A: --capability, --vendor-env, and --base-url are required. Interactive prompts land in Phase B.");
|
|
4064
|
+
if (runPluginCreate({
|
|
4065
|
+
slug: argv.slug,
|
|
4066
|
+
vendorName: argv.vendor,
|
|
4067
|
+
capability: argv.capability,
|
|
4068
|
+
vendorEnv: argv.vendorEnv,
|
|
4069
|
+
baseUrl: argv.baseUrl,
|
|
4070
|
+
...argv.tokenEnv ? { tokenEnv: argv.tokenEnv } : {},
|
|
4071
|
+
dryRun: argv.dryRun,
|
|
4072
|
+
noVerify: !argv.verify,
|
|
4073
|
+
cwd: argv.cwd
|
|
4074
|
+
}).status === "fail") process.exitCode = 1;
|
|
4075
|
+
} catch (err) {
|
|
4076
|
+
if (err instanceof PluginCreateError) {
|
|
4077
|
+
console.error(`plugin create: ${err.message}`);
|
|
4078
|
+
process.exitCode = 1;
|
|
4079
|
+
return;
|
|
4080
|
+
}
|
|
4081
|
+
throw err;
|
|
4082
|
+
}
|
|
4083
|
+
}).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", {
|
|
4084
|
+
type: "string",
|
|
4085
|
+
demandOption: true
|
|
4086
|
+
}).positional("token", { type: "string" }), async (argv) => {
|
|
4087
|
+
if ((await runAuthSet({
|
|
4088
|
+
provider: argv.provider,
|
|
4089
|
+
...argv.token ? { positional: argv.token } : {}
|
|
4090
|
+
})).status === "fail") process.exitCode = 1;
|
|
4091
|
+
}).command("unset <provider>", "Remove a stored bootstrap token", (yy) => yy.positional("provider", {
|
|
4092
|
+
type: "string",
|
|
4093
|
+
demandOption: true
|
|
4094
|
+
}), (argv) => {
|
|
4095
|
+
runAuthUnset({ provider: argv.provider });
|
|
4096
|
+
}).command("check <provider>", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", {
|
|
4097
|
+
type: "string",
|
|
4098
|
+
demandOption: true
|
|
4099
|
+
}), async (argv) => {
|
|
4100
|
+
if ((await runAuthCheck({ provider: argv.provider })).status === "fail") process.exitCode = 1;
|
|
4101
|
+
}).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
|
|
4102
|
+
await runAuthList();
|
|
4103
|
+
}).demandCommand(1, "Run `holocron auth --help` to see available auth subcommands."), () => {}).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
|
|
823
4104
|
/**
|
|
824
4105
|
* Parse `--scope` strings: `repo` | `env=NAME` | `org=NAME`.
|
|
825
4106
|
*/
|