@theholocron/cli 2.0.0-alpha.0 → 2.0.0-alpha.5
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 +2 -2
- package/dist/capabilities/index.mjs +1 -1
- package/dist/{capabilities-QjjhVlDd.mjs → capabilities-DapaKOlX.mjs} +6 -2
- package/dist/cli.mjs +1927 -24
- package/dist/{index-DDxKXqtR.d.mts → index-jxPVFH7-.d.mts} +84 -34
- package/dist/index.d.mts +90 -5
- package/dist/index.mjs +3 -3
- package/dist/{config-DWlIfFZm.mjs → keyring-DwNEmrBc.mjs} +73 -2
- package/package.json +2 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,11 +1,183 @@
|
|
|
1
1
|
#!/usr/bin/env -S tsx
|
|
2
|
-
import { t as CARDINALITY } from "./capabilities-
|
|
3
|
-
import { n as resolveConfig, t as
|
|
2
|
+
import { n as ProviderApiError, t as CARDINALITY } from "./capabilities-DapaKOlX.mjs";
|
|
3
|
+
import { a as ConfigError, c as resolvePluginPackage, i as setToken, n as getToken, o as resolveConfig, r as listStoredProviders, t as deleteToken } from "./keyring-DwNEmrBc.mjs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
4
5
|
import yargs from "yargs";
|
|
5
6
|
import { hideBin } from "yargs/helpers";
|
|
7
|
+
import path, { join } from "node:path";
|
|
6
8
|
import { spawnSync } from "node:child_process";
|
|
7
9
|
import { readFile, stat } from "node:fs/promises";
|
|
8
|
-
|
|
10
|
+
//#region src/commands/auth.ts
|
|
11
|
+
/**
|
|
12
|
+
* `holocron auth <subcommand>` — manage bootstrap credentials in the
|
|
13
|
+
* OS keyring.
|
|
14
|
+
*
|
|
15
|
+
* Subcommands:
|
|
16
|
+
* auth set <provider> [token] verify + store
|
|
17
|
+
* auth unset <provider> remove
|
|
18
|
+
* auth check <provider> re-verify a stored token
|
|
19
|
+
* auth list every provider with a stored entry
|
|
20
|
+
*
|
|
21
|
+
* See `.notes/tech-auth-bootstrap.spec.md`.
|
|
22
|
+
*
|
|
23
|
+
* Verification lives in each plugin as a top-level `verifyToken(token)`
|
|
24
|
+
* export. The auth command dynamically imports
|
|
25
|
+
* `@theholocron/holocron-plugin-<provider>` and calls it. Plugins that
|
|
26
|
+
* don't export `verifyToken` can still store — with a warning — because
|
|
27
|
+
* "no verify path" shouldn't block credential storage.
|
|
28
|
+
*/
|
|
29
|
+
const defaultImporter$1 = async (pkg) => await import(pkg);
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a token from (positional → HOLOCRON_<X> → vendor-native env).
|
|
32
|
+
* Keyring is NOT consulted here — `auth set` writes TO the keyring, so
|
|
33
|
+
* pulling FROM it would just re-store the same value.
|
|
34
|
+
*/
|
|
35
|
+
function resolveAuthSetToken(input) {
|
|
36
|
+
const env = input.env ?? process.env;
|
|
37
|
+
const upper = input.provider.toUpperCase();
|
|
38
|
+
const holocronKey = `HOLOCRON_${upper}_TOKEN`;
|
|
39
|
+
return input.positional || env[holocronKey] || env[upper + "_TOKEN"] || null;
|
|
40
|
+
}
|
|
41
|
+
async function runAuthSet(input) {
|
|
42
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
43
|
+
const importer = input.importer ?? defaultImporter$1;
|
|
44
|
+
const { provider } = input;
|
|
45
|
+
const token = resolveAuthSetToken({
|
|
46
|
+
provider,
|
|
47
|
+
positional: input.positional,
|
|
48
|
+
env: input.env
|
|
49
|
+
});
|
|
50
|
+
const packageName = resolvePluginPackage(provider);
|
|
51
|
+
if (!token) {
|
|
52
|
+
print(`no token supplied for \`${provider}\`.`);
|
|
53
|
+
print(` pass as positional arg: holocron auth set ${provider} <token>`);
|
|
54
|
+
print(` or via env: HOLOCRON_${provider.toUpperCase()}_TOKEN / ${provider.toUpperCase()}_TOKEN`);
|
|
55
|
+
const hint = await tryLoadHint(importer, packageName);
|
|
56
|
+
if (hint) print(` hint: ${hint}`);
|
|
57
|
+
return {
|
|
58
|
+
status: "fail",
|
|
59
|
+
message: "no token supplied"
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
let subject;
|
|
63
|
+
try {
|
|
64
|
+
const module = await importer(packageName);
|
|
65
|
+
if (typeof module.verifyToken === "function") {
|
|
66
|
+
const verified = await module.verifyToken(token);
|
|
67
|
+
if (!verified.ok) {
|
|
68
|
+
print(`token rejected by ${provider}: ${verified.message}`);
|
|
69
|
+
if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
|
|
70
|
+
return {
|
|
71
|
+
status: "fail",
|
|
72
|
+
message: verified.message
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
subject = verified.subject;
|
|
76
|
+
} else print(`(${provider} plugin has no verifyToken; storing without verification)`);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
print(`cannot verify token — failed to load ${packageName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
79
|
+
print(` storing token anyway; run 'holocron auth check ${provider}' once the plugin is installed`);
|
|
80
|
+
}
|
|
81
|
+
if (!setToken(provider, token)) {
|
|
82
|
+
print(`keyring unavailable — token not stored. Use env vars instead.`);
|
|
83
|
+
return {
|
|
84
|
+
status: "fail",
|
|
85
|
+
message: "keyring unavailable"
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
print(`stored ${provider} token${subject ? ` (${subject})` : ""}`);
|
|
89
|
+
return {
|
|
90
|
+
status: "ok",
|
|
91
|
+
...subject ? { message: subject } : {}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function runAuthUnset(input) {
|
|
95
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
96
|
+
if (deleteToken(input.provider)) {
|
|
97
|
+
print(`removed ${input.provider} token`);
|
|
98
|
+
return { status: "ok" };
|
|
99
|
+
}
|
|
100
|
+
print(`no stored token for ${input.provider}`);
|
|
101
|
+
return {
|
|
102
|
+
status: "skip",
|
|
103
|
+
message: "nothing to remove"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function runAuthCheck(input) {
|
|
107
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
108
|
+
const importer = input.importer ?? defaultImporter$1;
|
|
109
|
+
const { provider } = input;
|
|
110
|
+
const token = getToken(provider);
|
|
111
|
+
if (!token) {
|
|
112
|
+
print(`no stored token for ${provider}`);
|
|
113
|
+
return {
|
|
114
|
+
status: "skip",
|
|
115
|
+
message: "no stored token"
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const packageName = resolvePluginPackage(provider);
|
|
119
|
+
try {
|
|
120
|
+
const module = await importer(packageName);
|
|
121
|
+
if (typeof module.verifyToken !== "function") {
|
|
122
|
+
print(`${provider}: token stored (plugin has no verifyToken; can't confirm validity)`);
|
|
123
|
+
return {
|
|
124
|
+
status: "ok",
|
|
125
|
+
message: "stored, unverified"
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const verified = await module.verifyToken(token);
|
|
129
|
+
if (verified.ok) {
|
|
130
|
+
print(`${provider}: ok — ${verified.subject}`);
|
|
131
|
+
return {
|
|
132
|
+
status: "ok",
|
|
133
|
+
message: verified.subject
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
print(`${provider}: rejected — ${verified.message}`);
|
|
137
|
+
if (module.AUTH_HINT) print(` hint: ${module.AUTH_HINT}`);
|
|
138
|
+
return {
|
|
139
|
+
status: "fail",
|
|
140
|
+
message: verified.message
|
|
141
|
+
};
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
144
|
+
print(`${provider}: cannot verify — ${msg}`);
|
|
145
|
+
return {
|
|
146
|
+
status: "fail",
|
|
147
|
+
message: msg
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function runAuthList(input = {}) {
|
|
152
|
+
const print = input.print ?? ((l) => console.log(l));
|
|
153
|
+
const importer = input.importer ?? defaultImporter$1;
|
|
154
|
+
const providers = listStoredProviders();
|
|
155
|
+
if (providers.length === 0) {
|
|
156
|
+
print("no stored tokens.");
|
|
157
|
+
print("run: holocron auth set <provider> <token>");
|
|
158
|
+
return {
|
|
159
|
+
status: "ok",
|
|
160
|
+
message: "none"
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
for (const provider of providers.sort()) {
|
|
164
|
+
const check = await runAuthCheck({
|
|
165
|
+
provider,
|
|
166
|
+
importer,
|
|
167
|
+
print: () => {}
|
|
168
|
+
});
|
|
169
|
+
print(` ${check.status === "ok" ? "✓" : check.status === "fail" ? "✗" : "·"} ${provider}${check.message ? ` — ${check.message}` : ""}`);
|
|
170
|
+
}
|
|
171
|
+
return { status: "ok" };
|
|
172
|
+
}
|
|
173
|
+
async function tryLoadHint(importer, packageName) {
|
|
174
|
+
try {
|
|
175
|
+
return (await importer(packageName)).AUTH_HINT ?? null;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
9
181
|
//#region src/loader.ts
|
|
10
182
|
var LoaderError = class extends Error {
|
|
11
183
|
name = "LoaderError";
|
|
@@ -58,12 +230,23 @@ var PluginLoader = class {
|
|
|
58
230
|
});
|
|
59
231
|
if (typeof module.createPlugin !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not export \`createPlugin(options)\``);
|
|
60
232
|
const factory = module.createPlugin({
|
|
233
|
+
...this.projectDefaults(),
|
|
61
234
|
...this.context,
|
|
62
235
|
...tuple.options
|
|
63
236
|
}).capabilities[key];
|
|
64
237
|
if (typeof factory !== "function") throw new LoaderError(`\`${tuple.packageName}\` does not implement the \`${key}\` capability`);
|
|
65
238
|
return factory();
|
|
66
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Project-level defaults that get merged into every plugin's options
|
|
242
|
+
* unless overridden by the CLI context or per-plugin tuple options.
|
|
243
|
+
* See `.notes/tech-setup-and-config.spec.md` §Design.
|
|
244
|
+
*/
|
|
245
|
+
projectDefaults() {
|
|
246
|
+
const defaults = {};
|
|
247
|
+
if (this.config.project.repo) defaults.repo = this.config.project.repo;
|
|
248
|
+
return defaults;
|
|
249
|
+
}
|
|
67
250
|
};
|
|
68
251
|
/** Default importer — native dynamic import. */
|
|
69
252
|
const defaultImporter = async (pkg) => {
|
|
@@ -193,6 +376,76 @@ function pad(s, width) {
|
|
|
193
376
|
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
194
377
|
}
|
|
195
378
|
//#endregion
|
|
379
|
+
//#region src/commands/npm-bump-versions.ts
|
|
380
|
+
async function runNpmBumpVersions(input) {
|
|
381
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
382
|
+
const cwd = input.cwd ?? process.cwd();
|
|
383
|
+
const { version, dryRun = false } = input;
|
|
384
|
+
const readFile = input.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
385
|
+
const writeFile = input.writeFile ?? ((p, c) => writeFileSync(p, c));
|
|
386
|
+
const listDir = input.listDir ?? ((p) => readdirSync(p));
|
|
387
|
+
const isDir = input.isDir ?? ((p) => statSync(p).isDirectory());
|
|
388
|
+
const bumped = [];
|
|
389
|
+
const skipped = [];
|
|
390
|
+
print(`Bumping monorepo to ${version}${dryRun ? " (dry-run)" : ""}…`);
|
|
391
|
+
function bumpFile(absPath, label) {
|
|
392
|
+
let pkg;
|
|
393
|
+
try {
|
|
394
|
+
pkg = JSON.parse(readFile(absPath));
|
|
395
|
+
} catch {
|
|
396
|
+
print(` ✗ could not parse ${label}`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const old = pkg.version;
|
|
400
|
+
if (!dryRun) {
|
|
401
|
+
pkg.version = version;
|
|
402
|
+
writeFile(absPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
403
|
+
}
|
|
404
|
+
print(` ${dryRun ? "~" : "✓"} ${label}: ${old} → ${version}`);
|
|
405
|
+
bumped.push(label);
|
|
406
|
+
}
|
|
407
|
+
bumpFile(join(cwd, "package.json"), "root");
|
|
408
|
+
const packagesDir = join(cwd, "packages");
|
|
409
|
+
let entries;
|
|
410
|
+
try {
|
|
411
|
+
entries = listDir(packagesDir);
|
|
412
|
+
} catch {
|
|
413
|
+
return {
|
|
414
|
+
status: "fail",
|
|
415
|
+
bumped,
|
|
416
|
+
skipped,
|
|
417
|
+
message: `packages/ directory not found in ${cwd}`
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
for (const entry of entries) {
|
|
421
|
+
const pkgDir = join(packagesDir, entry);
|
|
422
|
+
try {
|
|
423
|
+
if (!isDir(pkgDir)) continue;
|
|
424
|
+
} catch {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const pkgFile = join(pkgDir, "package.json");
|
|
428
|
+
let pkg;
|
|
429
|
+
try {
|
|
430
|
+
pkg = JSON.parse(readFile(pkgFile));
|
|
431
|
+
} catch {
|
|
432
|
+
print(` ! skipping packages/${entry}: no package.json or malformed JSON`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (pkg.private) {
|
|
436
|
+
print(` · skipping private package packages/${entry}`);
|
|
437
|
+
skipped.push(`packages/${entry}`);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
bumpFile(pkgFile, `packages/${entry}`);
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
status: dryRun ? "dry-run" : "ok",
|
|
444
|
+
bumped,
|
|
445
|
+
skipped
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
//#endregion
|
|
196
449
|
//#region src/commands/npm-publish-initial.ts
|
|
197
450
|
/**
|
|
198
451
|
* `holocron npm publish-initial` — bottles up the chicken-and-egg
|
|
@@ -233,11 +486,25 @@ async function runNpmPublishInitial(input = {}) {
|
|
|
233
486
|
const cwd = input.cwd ?? process.cwd();
|
|
234
487
|
const tag = input.tag ?? "alpha";
|
|
235
488
|
const dryRun = input.dryRun ?? false;
|
|
489
|
+
const otp = input.otp;
|
|
236
490
|
const env = input.env ?? process.env;
|
|
237
491
|
const exec = input.exec ?? defaultExec;
|
|
492
|
+
const publishArgs = [
|
|
493
|
+
"-r",
|
|
494
|
+
"--filter=./packages/*",
|
|
495
|
+
"--filter=!@theholocron/cli-utils",
|
|
496
|
+
"publish",
|
|
497
|
+
"--access",
|
|
498
|
+
"public",
|
|
499
|
+
"--no-git-checks",
|
|
500
|
+
"--tag",
|
|
501
|
+
tag,
|
|
502
|
+
...otp ? ["--otp", otp] : []
|
|
503
|
+
];
|
|
238
504
|
print(`Holocron npm publish-initial${dryRun ? " (dry-run)" : ""}`);
|
|
239
505
|
print(` cwd: ${cwd}`);
|
|
240
506
|
print(` tag: ${tag}`);
|
|
507
|
+
if (otp) print(` otp: <${otp.length} chars>`);
|
|
241
508
|
print("");
|
|
242
509
|
print(" → verifying npm auth (`npm whoami`)…");
|
|
243
510
|
const whoami = await exec("npm", ["whoami"], { cwd });
|
|
@@ -254,8 +521,8 @@ async function runNpmPublishInitial(input = {}) {
|
|
|
254
521
|
if (dryRun) {
|
|
255
522
|
print("");
|
|
256
523
|
print(" … (dry-run) skipping actual publish");
|
|
257
|
-
print(` would run: pnpm
|
|
258
|
-
printNextSteps(print, env);
|
|
524
|
+
print(` would run: pnpm ${publishArgs.join(" ")}`);
|
|
525
|
+
printNextSteps$1(print, env);
|
|
259
526
|
return {
|
|
260
527
|
status: "dry-run",
|
|
261
528
|
message: "dry-run — no publish executed",
|
|
@@ -264,20 +531,15 @@ async function runNpmPublishInitial(input = {}) {
|
|
|
264
531
|
}
|
|
265
532
|
print("");
|
|
266
533
|
print(" → publishing all public @theholocron/* packages…");
|
|
267
|
-
const publish = await exec("pnpm",
|
|
268
|
-
"-r",
|
|
269
|
-
"--filter=./packages/*",
|
|
270
|
-
"--filter=!@theholocron/cli-utils",
|
|
271
|
-
"publish",
|
|
272
|
-
"--access",
|
|
273
|
-
"public",
|
|
274
|
-
"--no-git-checks",
|
|
275
|
-
"--tag",
|
|
276
|
-
tag
|
|
277
|
-
], { cwd });
|
|
534
|
+
const publish = await exec("pnpm", publishArgs, { cwd });
|
|
278
535
|
if (publish.exitCode !== 0) {
|
|
279
536
|
const message = `publish failed (exit ${publish.exitCode}): ${publish.stderr.trim() || publish.stdout.trim() || "no output"}`;
|
|
280
537
|
print(` ✗ ${message}`);
|
|
538
|
+
if (publish.stdout.includes("EOTP") || publish.stderr.includes("EOTP")) {
|
|
539
|
+
print("");
|
|
540
|
+
print(" → hint: your npm account requires 2FA for writes. Re-run with `--otp <code>`:");
|
|
541
|
+
print(` pnpm exec tsx packages/cli/src/cli.ts npm publish-initial --otp <6-digit-code>`);
|
|
542
|
+
}
|
|
281
543
|
return {
|
|
282
544
|
status: "fail",
|
|
283
545
|
message,
|
|
@@ -285,13 +547,13 @@ async function runNpmPublishInitial(input = {}) {
|
|
|
285
547
|
};
|
|
286
548
|
}
|
|
287
549
|
print(" ✓ publish complete");
|
|
288
|
-
printNextSteps(print, env);
|
|
550
|
+
printNextSteps$1(print, env);
|
|
289
551
|
return {
|
|
290
552
|
status: "ok",
|
|
291
553
|
packageNames: PUBLISHABLE_PACKAGES
|
|
292
554
|
};
|
|
293
555
|
}
|
|
294
|
-
function printNextSteps(print, env) {
|
|
556
|
+
function printNextSteps$1(print, env) {
|
|
295
557
|
print("");
|
|
296
558
|
print(" → next: configure Trusted Publisher for each package on npm:");
|
|
297
559
|
for (const name of PUBLISHABLE_PACKAGES) print(` https://www.npmjs.com/package/${name}/access`);
|
|
@@ -319,6 +581,1124 @@ const defaultExec = async (cmd, args, opts) => {
|
|
|
319
581
|
};
|
|
320
582
|
};
|
|
321
583
|
//#endregion
|
|
584
|
+
//#region src/commands/plugin-create/template-inputs.ts
|
|
585
|
+
/** Derive the standard defaults from a slug + vendor name. */
|
|
586
|
+
function deriveDefaults(input) {
|
|
587
|
+
const vendorUpper = input.slug.toUpperCase().replace(/-/g, "_");
|
|
588
|
+
const capability = input.capability;
|
|
589
|
+
return {
|
|
590
|
+
vendorUpper,
|
|
591
|
+
capabilityClass: `${input.vendorName}${capability.charAt(0).toUpperCase() + capability.slice(1)}`,
|
|
592
|
+
tokenEnv: `HOLOCRON_${vendorUpper}_TOKEN`,
|
|
593
|
+
transport: "rest"
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/commands/plugin-create/templates/auth.ts
|
|
598
|
+
function render$17(inputs) {
|
|
599
|
+
return `/**
|
|
600
|
+
* Token resolution for the ${inputs.vendorName} plugin.
|
|
601
|
+
*
|
|
602
|
+
* Resolution order (matches the standard 4-step precedence set by
|
|
603
|
+
* \`.notes/tech-auth-bootstrap.spec.md\`):
|
|
604
|
+
* 1. explicit \`cliToken\` argument (from \`--token\` flag)
|
|
605
|
+
* 2. ${inputs.tokenEnv} env var (preferred — explicit intent)
|
|
606
|
+
* 3. ${inputs.vendorEnv} env var (vendor-native)
|
|
607
|
+
* 4. keyring (com.theholocron.cli / "${inputs.slug}")
|
|
608
|
+
* 5. AuthError naming all four options + the bootstrap hint
|
|
609
|
+
*/
|
|
610
|
+
|
|
611
|
+
import { getToken as getKeyringToken } from "@theholocron/cli";
|
|
612
|
+
|
|
613
|
+
export class AuthError extends Error {
|
|
614
|
+
override name = "AuthError";
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export interface ResolveTokenInput {
|
|
618
|
+
/** From \`--token\` CLI flag. */
|
|
619
|
+
cliToken?: string;
|
|
620
|
+
/** Env vars; passed in for testability. Defaults to \`process.env\`. */
|
|
621
|
+
env?: NodeJS.ProcessEnv;
|
|
622
|
+
/** Keyring lookup fn; passed in for testability. Defaults to \`getToken(provider)\`. */
|
|
623
|
+
keyring?: (provider: string) => string | null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function resolveToken(input: ResolveTokenInput = {}): string {
|
|
627
|
+
const env = input.env ?? process.env;
|
|
628
|
+
const keyring = input.keyring ?? getKeyringToken;
|
|
629
|
+
// Bracket access so numeric-prefixed slugs (e.g., env.HOLOCRON_1PASSWORD_TOKEN
|
|
630
|
+
// which is invalid JS) still produce syntactically valid code.
|
|
631
|
+
const token =
|
|
632
|
+
input.cliToken || env["${inputs.tokenEnv}"] || env["${inputs.vendorEnv}"] || keyring("${inputs.slug}");
|
|
633
|
+
if (!token) {
|
|
634
|
+
throw new AuthError(
|
|
635
|
+
"no ${inputs.vendorName} token found. Pass --token <TOKEN>, set ${inputs.tokenEnv} / ${inputs.vendorEnv}, " +
|
|
636
|
+
"or run: holocron auth set ${inputs.slug} <TOKEN>"
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return token;
|
|
640
|
+
}
|
|
641
|
+
`;
|
|
642
|
+
}
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/commands/plugin-create/templates/auth-test.ts
|
|
645
|
+
function render$16(inputs) {
|
|
646
|
+
return `import { describe, expect, it } from "vitest";
|
|
647
|
+
|
|
648
|
+
import { AuthError, resolveToken } from "../auth.js";
|
|
649
|
+
|
|
650
|
+
const noKeyring = () => null;
|
|
651
|
+
|
|
652
|
+
describe("resolveToken", () => {
|
|
653
|
+
it("prefers --token over env vars + keyring", () => {
|
|
654
|
+
expect(
|
|
655
|
+
resolveToken({
|
|
656
|
+
cliToken: "flag",
|
|
657
|
+
env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
|
|
658
|
+
keyring: () => "kr",
|
|
659
|
+
})
|
|
660
|
+
).toBe("flag");
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("prefers ${inputs.tokenEnv} over ${inputs.vendorEnv}", () => {
|
|
664
|
+
expect(
|
|
665
|
+
resolveToken({
|
|
666
|
+
env: { ${inputs.tokenEnv}: "hlc", ${inputs.vendorEnv}: "vendor" },
|
|
667
|
+
keyring: noKeyring,
|
|
668
|
+
})
|
|
669
|
+
).toBe("hlc");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("falls back to ${inputs.vendorEnv} when ${inputs.tokenEnv} is unset", () => {
|
|
673
|
+
expect(resolveToken({ env: { ${inputs.vendorEnv}: "vendor" }, keyring: noKeyring })).toBe("vendor");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("falls back to keyring when env vars are unset", () => {
|
|
677
|
+
expect(resolveToken({ env: {}, keyring: (p) => (p === "${inputs.slug}" ? "kr" : null) })).toBe("kr");
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("throws AuthError with a helpful message when nothing is set", () => {
|
|
681
|
+
try {
|
|
682
|
+
resolveToken({ env: {}, keyring: noKeyring });
|
|
683
|
+
throw new Error("should have thrown");
|
|
684
|
+
} catch (err) {
|
|
685
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
686
|
+
expect((err as Error).message).toMatch(/${inputs.tokenEnv}/);
|
|
687
|
+
expect((err as Error).message).toMatch(/holocron auth set ${inputs.slug}/);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
`;
|
|
692
|
+
}
|
|
693
|
+
//#endregion
|
|
694
|
+
//#region src/commands/plugin-create/templates/capability.ts
|
|
695
|
+
function render$15(inputs) {
|
|
696
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
697
|
+
const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
|
|
698
|
+
return `/**
|
|
699
|
+
* \`${inputs.capability}\` capability for ${inputs.vendorName}.
|
|
700
|
+
*
|
|
701
|
+
* Methods are STUBS — implement them against ${inputs.vendorName}'s
|
|
702
|
+
* REST API per the \`${capabilityInterface}\` interface contract in
|
|
703
|
+
* \`@theholocron/cli\`.
|
|
704
|
+
*
|
|
705
|
+
* TODO once you've stubbed the interface methods, restore the type
|
|
706
|
+
* import + \`implements\` clause:
|
|
707
|
+
* import type { ${capabilityInterface} } from "@theholocron/cli";
|
|
708
|
+
* export class ${inputs.capabilityClass} implements ${capabilityInterface} { ... }
|
|
709
|
+
*/
|
|
710
|
+
|
|
711
|
+
import type { ${clientClass} } from "../rest.js";
|
|
712
|
+
|
|
713
|
+
export class ${inputs.capabilityClass} {
|
|
714
|
+
readonly key = "${inputs.capability}" as const;
|
|
715
|
+
readonly providerName = "${inputs.slug}";
|
|
716
|
+
|
|
717
|
+
constructor(private readonly rest: ${clientClass}) {}
|
|
718
|
+
|
|
719
|
+
// TODO: implement the ${capabilityInterface} interface methods
|
|
720
|
+
// (see \`packages/cli/src/capabilities/index.ts\`). Each method
|
|
721
|
+
// should hit a specific ${inputs.vendorName} REST endpoint via
|
|
722
|
+
// \`this.rest.request(...)\`. Once methods are stubbed, add
|
|
723
|
+
// \`implements ${capabilityInterface}\` to the class declaration
|
|
724
|
+
// above and remove the \`as unknown as\` cast in src/index.ts.
|
|
725
|
+
}
|
|
726
|
+
`;
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/commands/plugin-create/templates/capability-test.ts
|
|
730
|
+
function render$14(inputs) {
|
|
731
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
732
|
+
return `import { describe, it } from "vitest";
|
|
733
|
+
|
|
734
|
+
import { ${inputs.capabilityClass} } from "../capabilities/${inputs.capability}.js";
|
|
735
|
+
import { ${clientClass} } from "../rest.js";
|
|
736
|
+
import { stubFetch } from "./helpers.js";
|
|
737
|
+
|
|
738
|
+
// Stub client used by the constructor smoke test. Real capability
|
|
739
|
+
// tests replace this with per-method stubs once implementations land.
|
|
740
|
+
function makeCapability() {
|
|
741
|
+
const stub = stubFetch([]);
|
|
742
|
+
const rest = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
743
|
+
return new ${inputs.capabilityClass}(rest);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
describe("${inputs.capabilityClass}", () => {
|
|
747
|
+
it("constructs with a REST client", () => {
|
|
748
|
+
makeCapability();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// TODO: implement one test per ${inputs.capability} capability method
|
|
752
|
+
// as you fill in the class stubs. See other plugins for reference
|
|
753
|
+
// patterns:
|
|
754
|
+
// - REST behaviors (URL / method / body / query) via \`stub.calls\`
|
|
755
|
+
// - Error paths via \`stubFetch([{ status: 4xx, body: {...} }])\`
|
|
756
|
+
// - Idempotency (409 handling for ensure* methods, if applicable)
|
|
757
|
+
it.todo("implement per-method tests");
|
|
758
|
+
});
|
|
759
|
+
`;
|
|
760
|
+
}
|
|
761
|
+
//#endregion
|
|
762
|
+
//#region src/commands/plugin-create/templates/eslint-config.ts
|
|
763
|
+
function render$13(_inputs) {
|
|
764
|
+
return `import root from "../../eslint.config.js";
|
|
765
|
+
|
|
766
|
+
export default [
|
|
767
|
+
...root,
|
|
768
|
+
{
|
|
769
|
+
ignores: ["dist/**", "coverage/**"],
|
|
770
|
+
},
|
|
771
|
+
];
|
|
772
|
+
`;
|
|
773
|
+
}
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/commands/plugin-create/templates/helpers.ts
|
|
776
|
+
function render$12(_inputs) {
|
|
777
|
+
return `import { vi, type Mock } from "vitest";
|
|
778
|
+
|
|
779
|
+
export interface FetchCall {
|
|
780
|
+
url: string;
|
|
781
|
+
method: string;
|
|
782
|
+
headers: Record<string, string>;
|
|
783
|
+
body: unknown;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export interface FetchStub {
|
|
787
|
+
fetch: typeof fetch;
|
|
788
|
+
calls: FetchCall[];
|
|
789
|
+
mock: Mock;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function stubFetch(responses: Array<{ status?: number; body?: unknown; text?: string }>): FetchStub {
|
|
793
|
+
const calls: FetchCall[] = [];
|
|
794
|
+
let i = 0;
|
|
795
|
+
const mock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
796
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
797
|
+
const body = typeof init?.body === "string" ? safeJsonParse(init.body) : (init?.body ?? null);
|
|
798
|
+
calls.push({
|
|
799
|
+
url,
|
|
800
|
+
method: (init?.method ?? "GET").toUpperCase(),
|
|
801
|
+
headers: (init?.headers as Record<string, string>) ?? {},
|
|
802
|
+
body,
|
|
803
|
+
});
|
|
804
|
+
const next = responses[i++] ?? { status: 200, body: {} };
|
|
805
|
+
const status = next.status ?? 200;
|
|
806
|
+
if (status === 204 || status === 205 || status === 304) {
|
|
807
|
+
return new Response(null, { status });
|
|
808
|
+
}
|
|
809
|
+
const text = next.text ?? (typeof next.body === "string" ? next.body : JSON.stringify(next.body ?? {}));
|
|
810
|
+
return new Response(text, { status });
|
|
811
|
+
});
|
|
812
|
+
return { fetch: mock as unknown as typeof fetch, calls, mock };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function safeJsonParse(s: string): unknown {
|
|
816
|
+
try {
|
|
817
|
+
return JSON.parse(s);
|
|
818
|
+
} catch {
|
|
819
|
+
return s;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
`;
|
|
823
|
+
}
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/commands/plugin-create/templates/index-test.ts
|
|
826
|
+
function render$11(inputs) {
|
|
827
|
+
return `import { describe, expect, it } from "vitest";
|
|
828
|
+
|
|
829
|
+
import { AUTH_HINT, createPlugin } from "../index.js";
|
|
830
|
+
import { stubFetch } from "./helpers.js";
|
|
831
|
+
|
|
832
|
+
describe("createPlugin", () => {
|
|
833
|
+
it("wires the ${inputs.capability} capability against the given fetch + token", () => {
|
|
834
|
+
const stub = stubFetch([]);
|
|
835
|
+
const plugin = createPlugin({
|
|
836
|
+
cliToken: "test-token",
|
|
837
|
+
fetch: stub.fetch,
|
|
838
|
+
});
|
|
839
|
+
expect(plugin.name).toBe("@theholocron/holocron-plugin-${inputs.slug}");
|
|
840
|
+
expect(typeof plugin.capabilities.${inputs.capability}).toBe("function");
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
describe("AUTH_HINT", () => {
|
|
845
|
+
it("mentions the holocron auth set command", () => {
|
|
846
|
+
expect(AUTH_HINT).toMatch(/holocron auth set ${inputs.slug}/);
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
`;
|
|
850
|
+
}
|
|
851
|
+
//#endregion
|
|
852
|
+
//#region src/commands/plugin-create/templates/package-json.ts
|
|
853
|
+
function render$10(inputs) {
|
|
854
|
+
return `{
|
|
855
|
+
"name": "@theholocron/holocron-plugin-${inputs.slug}",
|
|
856
|
+
"version": "2.0.0-alpha.1",
|
|
857
|
+
"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\`.",
|
|
858
|
+
"homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-${inputs.slug}#readme",
|
|
859
|
+
"bugs": "https://github.com/theholocron/holocron/issues",
|
|
860
|
+
"repository": {
|
|
861
|
+
"type": "git",
|
|
862
|
+
"url": "git+https://github.com/theholocron/holocron.git",
|
|
863
|
+
"directory": "packages/holocron-plugin-${inputs.slug}"
|
|
864
|
+
},
|
|
865
|
+
"license": "MIT",
|
|
866
|
+
"author": "Newton Koumantzelis",
|
|
867
|
+
"type": "module",
|
|
868
|
+
"main": "./src/index.ts",
|
|
869
|
+
"exports": {
|
|
870
|
+
".": "./src/index.ts"
|
|
871
|
+
},
|
|
872
|
+
"scripts": {
|
|
873
|
+
"build": "tsdown",
|
|
874
|
+
"lint": "eslint .",
|
|
875
|
+
"typecheck": "tsc --noEmit",
|
|
876
|
+
"test": "vitest run",
|
|
877
|
+
"test:watch": "vitest",
|
|
878
|
+
"test:coverage": "vitest run --coverage",
|
|
879
|
+
"validate": "tsx scripts/validate.mjs"
|
|
880
|
+
},
|
|
881
|
+
"peerDependencies": {
|
|
882
|
+
"@theholocron/cli": "workspace:*"
|
|
883
|
+
},
|
|
884
|
+
"devDependencies": {
|
|
885
|
+
"@theholocron/cli": "workspace:*",
|
|
886
|
+
"@theholocron/tsconfig": "catalog:",
|
|
887
|
+
"@tsconfig/node-lts": "catalog:",
|
|
888
|
+
"@vitest/coverage-v8": "catalog:",
|
|
889
|
+
"eslint": "catalog:",
|
|
890
|
+
"globals": "catalog:",
|
|
891
|
+
"typescript": "catalog:",
|
|
892
|
+
"vitest": "catalog:",
|
|
893
|
+
"tsdown": "catalog:",
|
|
894
|
+
"tsx": "catalog:"
|
|
895
|
+
},
|
|
896
|
+
"publishConfig": {
|
|
897
|
+
"access": "public",
|
|
898
|
+
"main": "./dist/index.mjs",
|
|
899
|
+
"types": "./dist/index.d.mts",
|
|
900
|
+
"exports": {
|
|
901
|
+
".": {
|
|
902
|
+
"types": "./dist/index.d.mts",
|
|
903
|
+
"import": "./dist/index.mjs",
|
|
904
|
+
"default": "./dist/index.mjs"
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
"files": [
|
|
909
|
+
"dist",
|
|
910
|
+
"README.md"
|
|
911
|
+
]
|
|
912
|
+
}
|
|
913
|
+
`;
|
|
914
|
+
}
|
|
915
|
+
//#endregion
|
|
916
|
+
//#region src/commands/plugin-create/templates/plugin-index.ts
|
|
917
|
+
function render$9(inputs) {
|
|
918
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
919
|
+
const capabilityInterface = inputs.capability.charAt(0).toUpperCase() + inputs.capability.slice(1);
|
|
920
|
+
return `/**
|
|
921
|
+
* \`@theholocron/holocron-plugin-${inputs.slug}\` — entrypoint.
|
|
922
|
+
*
|
|
923
|
+
* Implements the \`${inputs.capability}\` capability against ${inputs.vendorName}'s
|
|
924
|
+
* REST API. Also exports \`verifyToken\` + \`AUTH_HINT\` for
|
|
925
|
+
* \`holocron auth\`. See README for auth + config docs.
|
|
926
|
+
*
|
|
927
|
+
* TODO: once you fill in the ${inputs.capabilityClass} methods and add
|
|
928
|
+
* \`implements ${capabilityInterface}\` to the class, add the type import:
|
|
929
|
+
* import type { ${capabilityInterface} } from "@theholocron/cli";
|
|
930
|
+
* and set \`: ${capabilityInterface}\` as the factory return type below.
|
|
931
|
+
*/
|
|
932
|
+
|
|
933
|
+
import { resolveToken, type ResolveTokenInput } from "./auth.js";
|
|
934
|
+
import { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
935
|
+
import { ${clientClass} } from "./rest.js";
|
|
936
|
+
|
|
937
|
+
export interface ${inputs.vendorName}PluginOptions extends ResolveTokenInput {
|
|
938
|
+
/** Override base URL for tests. */
|
|
939
|
+
baseUrl?: string;
|
|
940
|
+
/** Override \`fetch\` for tests. */
|
|
941
|
+
fetch?: typeof fetch;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export interface PluginContext {
|
|
945
|
+
options: ${inputs.vendorName}PluginOptions;
|
|
946
|
+
rest: ${clientClass};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export function createContext(options: ${inputs.vendorName}PluginOptions): PluginContext {
|
|
950
|
+
const token = resolveToken(options);
|
|
951
|
+
const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
|
|
952
|
+
if (options.baseUrl !== undefined) restOpts.baseUrl = options.baseUrl;
|
|
953
|
+
if (options.fetch !== undefined) restOpts.fetch = options.fetch;
|
|
954
|
+
return {
|
|
955
|
+
options,
|
|
956
|
+
rest: new ${clientClass}(restOpts),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export function ${inputs.capability}(ctx: PluginContext) {
|
|
961
|
+
// Return type inferred at scaffold time — the class doesn't yet
|
|
962
|
+
// \`implements ${capabilityInterface}\`. Add the type import + the
|
|
963
|
+
// \`: ${capabilityInterface}\` annotation once methods are stubbed.
|
|
964
|
+
return new ${inputs.capabilityClass}(ctx.rest);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export function createPlugin(options: ${inputs.vendorName}PluginOptions) {
|
|
968
|
+
const ctx = createContext(options);
|
|
969
|
+
return {
|
|
970
|
+
name: "@theholocron/holocron-plugin-${inputs.slug}",
|
|
971
|
+
capabilities: {
|
|
972
|
+
${inputs.capability}: () => ${inputs.capability}(ctx),
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* One-line hint printed by \`holocron auth set ${inputs.slug}\` when no
|
|
979
|
+
* token is supplied or the supplied token is rejected. Edit this to
|
|
980
|
+
* point operators at the specific ${inputs.vendorName} docs path for
|
|
981
|
+
* generating a token.
|
|
982
|
+
*/
|
|
983
|
+
export const AUTH_HINT =
|
|
984
|
+
"generate a ${inputs.vendorName} API token, then run: holocron auth set ${inputs.slug} <TOKEN>";
|
|
985
|
+
|
|
986
|
+
// ── Public re-exports ────────────────────────────────────────────────
|
|
987
|
+
|
|
988
|
+
export * from "./auth.js";
|
|
989
|
+
export { ${clientClass} } from "./rest.js";
|
|
990
|
+
export { ${inputs.capabilityClass} } from "./capabilities/${inputs.capability}.js";
|
|
991
|
+
export { verifyToken } from "./verify-token.js";
|
|
992
|
+
export type { VerifyTokenResult, VerifyTokenSuccess, VerifyTokenFailure } from "./verify-token.js";
|
|
993
|
+
`;
|
|
994
|
+
}
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region src/commands/plugin-create/templates/readme.ts
|
|
997
|
+
function render$8(inputs) {
|
|
998
|
+
return `<!-- editorconfig-checker-disable-file -->
|
|
999
|
+
|
|
1000
|
+
# \`@theholocron/holocron-plugin-${inputs.slug}\`
|
|
1001
|
+
|
|
1002
|
+
${inputs.vendorName} plugin for [Holocron](../cli). Implements the
|
|
1003
|
+
\`${inputs.capability}\` capability against [${inputs.vendorName}'s REST API](${inputs.baseUrl}),
|
|
1004
|
+
plus exports \`verifyToken\` + \`AUTH_HINT\` for use by \`holocron auth\`.
|
|
1005
|
+
|
|
1006
|
+
## Install
|
|
1007
|
+
|
|
1008
|
+
\`\`\`bash
|
|
1009
|
+
pnpm add -D @theholocron/holocron-plugin-${inputs.slug}@alpha
|
|
1010
|
+
\`\`\`
|
|
1011
|
+
|
|
1012
|
+
## Auth
|
|
1013
|
+
|
|
1014
|
+
Token resolution order (matches the standard 4-step precedence set by
|
|
1015
|
+
\`.notes/tech-auth-bootstrap.spec.md\`):
|
|
1016
|
+
|
|
1017
|
+
1. \`--token <TOKEN>\` flag on the holocron invocation
|
|
1018
|
+
2. \`${inputs.tokenEnv}\` env var (preferred — explicit intent)
|
|
1019
|
+
3. \`${inputs.vendorEnv}\` env var (${inputs.vendorName}-native)
|
|
1020
|
+
4. **Keyring** — \`com.theholocron.cli\` service, account \`${inputs.slug}\`
|
|
1021
|
+
5. \`AuthError\` naming all four options + the bootstrap hint
|
|
1022
|
+
|
|
1023
|
+
## Setup
|
|
1024
|
+
|
|
1025
|
+
\`\`\`bash
|
|
1026
|
+
# Generate a ${inputs.vendorName} API token (see vendor docs), then:
|
|
1027
|
+
holocron auth set ${inputs.slug} <TOKEN>
|
|
1028
|
+
holocron auth check ${inputs.slug} # verify
|
|
1029
|
+
\`\`\`
|
|
1030
|
+
|
|
1031
|
+
## Config
|
|
1032
|
+
|
|
1033
|
+
\`\`\`jsonc
|
|
1034
|
+
{
|
|
1035
|
+
"providers": {
|
|
1036
|
+
"${inputs.capability}": "${inputs.slug}",
|
|
1037
|
+
},
|
|
1038
|
+
}
|
|
1039
|
+
\`\`\`
|
|
1040
|
+
|
|
1041
|
+
Plugin options extend \`ResolveTokenInput\` — add whatever ${inputs.vendorName}-
|
|
1042
|
+
specific options you need here (project id, workspace slug, etc.) via
|
|
1043
|
+
the tuple form:
|
|
1044
|
+
|
|
1045
|
+
\`\`\`jsonc
|
|
1046
|
+
{
|
|
1047
|
+
"providers": {
|
|
1048
|
+
"${inputs.capability}": ["${inputs.slug}", { "baseUrl": "${inputs.baseUrl}" }],
|
|
1049
|
+
},
|
|
1050
|
+
}
|
|
1051
|
+
\`\`\`
|
|
1052
|
+
|
|
1053
|
+
## What's implemented
|
|
1054
|
+
|
|
1055
|
+
TODO: fill in as capability methods land.
|
|
1056
|
+
|
|
1057
|
+
## Status
|
|
1058
|
+
|
|
1059
|
+
**\`v2.0.0-alpha.1\`** — scaffolded via \`holocron plugin create\`.
|
|
1060
|
+
Not yet published; capability methods are stubs.
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
//#endregion
|
|
1064
|
+
//#region src/commands/plugin-create/templates/rest.ts
|
|
1065
|
+
function render$7(inputs) {
|
|
1066
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
1067
|
+
return `/**
|
|
1068
|
+
* Thin REST wrapper around ${inputs.baseUrl}.
|
|
1069
|
+
*
|
|
1070
|
+
* Bearer auth, JSON-only bodies, transport-failure wrapping with
|
|
1071
|
+
* \`status: 0\` so orchestrator soft-skip paths see a clear message
|
|
1072
|
+
* instead of a generic \`TypeError: fetch failed\`.
|
|
1073
|
+
*/
|
|
1074
|
+
|
|
1075
|
+
import { ProviderApiError } from "@theholocron/cli";
|
|
1076
|
+
|
|
1077
|
+
export interface RestClientOptions {
|
|
1078
|
+
token: string;
|
|
1079
|
+
fetch?: typeof fetch;
|
|
1080
|
+
baseUrl?: string;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export interface RequestOptions {
|
|
1084
|
+
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
1085
|
+
body?: unknown;
|
|
1086
|
+
query?: Record<string, string>;
|
|
1087
|
+
/** Treat this response as void even if 200 is returned. */
|
|
1088
|
+
expectNoContent?: boolean;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
export class ${clientClass} {
|
|
1092
|
+
private readonly token: string;
|
|
1093
|
+
private readonly fetchImpl: typeof fetch;
|
|
1094
|
+
readonly baseUrl: string;
|
|
1095
|
+
|
|
1096
|
+
constructor(opts: RestClientOptions) {
|
|
1097
|
+
this.token = opts.token;
|
|
1098
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
1099
|
+
// Manual trailing-slash trim — CodeQL flags regex on library
|
|
1100
|
+
// input as polynomial ReDoS. O(n) loop, no backtracking risk.
|
|
1101
|
+
let url = opts.baseUrl ?? "${inputs.baseUrl}";
|
|
1102
|
+
while (url.endsWith("/")) url = url.slice(0, -1);
|
|
1103
|
+
this.baseUrl = url;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
1107
|
+
const url = new URL(\`\${this.baseUrl}\${path.startsWith("/") ? path : "/" + path}\`);
|
|
1108
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, v);
|
|
1109
|
+
const fullUrl = url.toString();
|
|
1110
|
+
|
|
1111
|
+
const headers: Record<string, string> = {
|
|
1112
|
+
authorization: \`Bearer \${this.token}\`,
|
|
1113
|
+
accept: "application/json",
|
|
1114
|
+
};
|
|
1115
|
+
const init: RequestInit = {
|
|
1116
|
+
method: opts.method ?? "GET",
|
|
1117
|
+
headers,
|
|
1118
|
+
};
|
|
1119
|
+
if (opts.body !== undefined) {
|
|
1120
|
+
headers["content-type"] = "application/json";
|
|
1121
|
+
init.body = JSON.stringify(opts.body);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
let res: Response;
|
|
1125
|
+
try {
|
|
1126
|
+
res = await this.fetchImpl(fullUrl, init);
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
const detail = err instanceof Error ? \`\${err.name}: \${err.message}\` : String(err);
|
|
1129
|
+
throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} failed: \${detail}\`, 0, undefined);
|
|
1130
|
+
}
|
|
1131
|
+
if (!res.ok) {
|
|
1132
|
+
const body = await res.text().catch(() => "");
|
|
1133
|
+
throw new ProviderApiError(\`${inputs.vendorName} \${init.method} \${path} → \${res.status}\`, res.status, body);
|
|
1134
|
+
}
|
|
1135
|
+
if (opts.expectNoContent || res.status === 204) return undefined as T;
|
|
1136
|
+
const text = await res.text();
|
|
1137
|
+
if (!text) return undefined as T;
|
|
1138
|
+
return JSON.parse(text) as T;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
`;
|
|
1142
|
+
}
|
|
1143
|
+
//#endregion
|
|
1144
|
+
//#region src/commands/plugin-create/templates/rest-test.ts
|
|
1145
|
+
function render$6(inputs) {
|
|
1146
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
1147
|
+
return `import { ProviderApiError } from "@theholocron/cli";
|
|
1148
|
+
import { describe, expect, it } from "vitest";
|
|
1149
|
+
|
|
1150
|
+
import { ${clientClass} } from "../rest.js";
|
|
1151
|
+
import { stubFetch } from "./helpers.js";
|
|
1152
|
+
|
|
1153
|
+
describe("${clientClass}", () => {
|
|
1154
|
+
it("sends bearer + accept headers and returns the parsed body", async () => {
|
|
1155
|
+
const stub = stubFetch([{ status: 200, body: { ok: true } }]);
|
|
1156
|
+
const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
1157
|
+
const res = await client.request<{ ok: boolean }>("/me");
|
|
1158
|
+
expect(res.ok).toBe(true);
|
|
1159
|
+
expect(stub.calls[0]?.headers["authorization"]).toBe("Bearer t");
|
|
1160
|
+
expect(stub.calls[0]?.headers["accept"]).toBe("application/json");
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
it("serializes body as JSON and sets content-type when present", async () => {
|
|
1164
|
+
const stub = stubFetch([{ status: 200, body: {} }]);
|
|
1165
|
+
const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
1166
|
+
await client.request<unknown>("/resource", { method: "POST", body: { name: "demo" } });
|
|
1167
|
+
expect(stub.calls[0]?.method).toBe("POST");
|
|
1168
|
+
expect(stub.calls[0]?.headers["content-type"]).toBe("application/json");
|
|
1169
|
+
expect(stub.calls[0]?.body).toEqual({ name: "demo" });
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("returns undefined on 204", async () => {
|
|
1173
|
+
const stub = stubFetch([{ status: 204 }]);
|
|
1174
|
+
const client = new ${clientClass}({ token: "t", fetch: stub.fetch });
|
|
1175
|
+
expect(await client.request<unknown>("/whatever")).toBeUndefined();
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("throws ProviderApiError with the HTTP status on non-2xx", async () => {
|
|
1179
|
+
const stub = stubFetch([{ status: 401, body: { messages: ["invalid"] } }]);
|
|
1180
|
+
const client = new ${clientClass}({ token: "bad", fetch: stub.fetch });
|
|
1181
|
+
try {
|
|
1182
|
+
await client.request<unknown>("/me");
|
|
1183
|
+
throw new Error("should have thrown");
|
|
1184
|
+
} catch (err) {
|
|
1185
|
+
expect(err).toBeInstanceOf(ProviderApiError);
|
|
1186
|
+
expect((err as ProviderApiError).status).toBe(401);
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it("wraps transport-level failures with status 0", async () => {
|
|
1191
|
+
const throwing: typeof fetch = async () => {
|
|
1192
|
+
throw new TypeError("fetch failed");
|
|
1193
|
+
};
|
|
1194
|
+
const client = new ${clientClass}({ token: "t", fetch: throwing });
|
|
1195
|
+
try {
|
|
1196
|
+
await client.request<unknown>("/me");
|
|
1197
|
+
throw new Error("should have thrown");
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
expect(err).toBeInstanceOf(ProviderApiError);
|
|
1200
|
+
expect((err as ProviderApiError).status).toBe(0);
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it("trims trailing slashes from the base URL", () => {
|
|
1205
|
+
const client = new ${clientClass}({ token: "t", baseUrl: "${inputs.baseUrl}//" });
|
|
1206
|
+
expect(client.baseUrl).toBe("${inputs.baseUrl}");
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
`;
|
|
1210
|
+
}
|
|
1211
|
+
//#endregion
|
|
1212
|
+
//#region src/commands/plugin-create/templates/tsconfig-json.ts
|
|
1213
|
+
function render$5(inputs) {
|
|
1214
|
+
return `{
|
|
1215
|
+
"display": "Holocron Plugin: ${inputs.vendorName}",
|
|
1216
|
+
"extends": "@tsconfig/node-lts/tsconfig.json",
|
|
1217
|
+
"compilerOptions": {
|
|
1218
|
+
"baseUrl": "./",
|
|
1219
|
+
"outDir": "./dist",
|
|
1220
|
+
"paths": {
|
|
1221
|
+
"@/*": ["./src/*"]
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1224
|
+
"include": ["src/**/*.ts"],
|
|
1225
|
+
"exclude": ["node_modules", "dist"]
|
|
1226
|
+
}
|
|
1227
|
+
`;
|
|
1228
|
+
}
|
|
1229
|
+
//#endregion
|
|
1230
|
+
//#region src/commands/plugin-create/templates/tsdown-config.ts
|
|
1231
|
+
function render$4(_inputs) {
|
|
1232
|
+
return `import { defineConfig } from "tsdown";
|
|
1233
|
+
|
|
1234
|
+
export default defineConfig({
|
|
1235
|
+
entry: ["src/index.ts"],
|
|
1236
|
+
format: "esm",
|
|
1237
|
+
dts: true,
|
|
1238
|
+
clean: true,
|
|
1239
|
+
deps: { neverBundle: [/^@theholocron\\//] },
|
|
1240
|
+
});
|
|
1241
|
+
`;
|
|
1242
|
+
}
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/commands/plugin-create/templates/validate-script.ts
|
|
1245
|
+
/**
|
|
1246
|
+
* Scaffolds `scripts/validate.mjs` — a smoke-test the operator runs
|
|
1247
|
+
* against a live vendor account to verify the plugin's REST endpoints,
|
|
1248
|
+
* auth, and response parsing all work in reality (the unit tests only
|
|
1249
|
+
* exercise stubbed HTTP responses). READ-ONLY BY DESIGN.
|
|
1250
|
+
*
|
|
1251
|
+
* Convention: every plugin ships this script + a matching `validate`
|
|
1252
|
+
* entry in `package.json`'s scripts block, so operators run
|
|
1253
|
+
* `pnpm --filter @theholocron/holocron-plugin-<slug> validate`.
|
|
1254
|
+
* Capability-specific args (project id, workspace, etc.) come from
|
|
1255
|
+
* positional command-line arguments; the plugin author fills in the
|
|
1256
|
+
* exact shape per their vendor.
|
|
1257
|
+
*
|
|
1258
|
+
* Includes a `hintFor(message)` helper the operator customizes with
|
|
1259
|
+
* vendor-specific "here's the likely fix" guidance for common error
|
|
1260
|
+
* shapes (401 / 403 / 404 / network / 5xx). Points users at docs and
|
|
1261
|
+
* setup steps rather than leaving them staring at a raw stack trace.
|
|
1262
|
+
*/
|
|
1263
|
+
function render$3(inputs) {
|
|
1264
|
+
return `#!/usr/bin/env node
|
|
1265
|
+
/**
|
|
1266
|
+
* Read-only smoke test for @theholocron/holocron-plugin-${inputs.slug} against
|
|
1267
|
+
* a live ${inputs.vendorName} account.
|
|
1268
|
+
*
|
|
1269
|
+
* READ-ONLY BY DESIGN. Never calls write(), or bootstrap methods
|
|
1270
|
+
* (\`ensureProject\`, \`ensureEnvironment\`, etc.). Any ERROR line means
|
|
1271
|
+
* the plugin needs adjusting — the \`hintFor\` helper below points at
|
|
1272
|
+
* the most likely fix per error shape.
|
|
1273
|
+
*
|
|
1274
|
+
* Auth: reads the ${inputs.vendorName} token from holocron's keyring —
|
|
1275
|
+
* you must have run \`pnpm holocron auth set ${inputs.slug} <TOKEN>\` first.
|
|
1276
|
+
*
|
|
1277
|
+
* Usage:
|
|
1278
|
+
* pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <arg1> [arg2] ...
|
|
1279
|
+
*
|
|
1280
|
+
* TODO: replace the positional args below with whatever your
|
|
1281
|
+
* capability's methods need (e.g., project id, environment slug,
|
|
1282
|
+
* secret name). Adapt the test steps to your capability's method
|
|
1283
|
+
* surface. Model on \`packages/holocron-plugin-infisical/scripts/validate.mjs\`.
|
|
1284
|
+
*/
|
|
1285
|
+
|
|
1286
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- filled in by operator
|
|
1287
|
+
import { AuthError, createPlugin, resolveToken, verifyToken } from "../src/index.ts";
|
|
1288
|
+
|
|
1289
|
+
const args = process.argv.slice(2);
|
|
1290
|
+
|
|
1291
|
+
if (args.length === 0) {
|
|
1292
|
+
console.error("usage: pnpm --filter @theholocron/holocron-plugin-${inputs.slug} validate <args>");
|
|
1293
|
+
process.exit(2);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Use the plugin's own auth resolution so the validate script honors
|
|
1297
|
+
// the same 4-step precedence as the plugin's runtime:
|
|
1298
|
+
// --token → ${inputs.tokenEnv} → ${inputs.vendorEnv} → keyring
|
|
1299
|
+
// (--token isn't available here since this is a bare Node script;
|
|
1300
|
+
// the other three all work.)
|
|
1301
|
+
let token;
|
|
1302
|
+
try {
|
|
1303
|
+
token = resolveToken();
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
if (err instanceof AuthError) {
|
|
1306
|
+
console.error(err.message);
|
|
1307
|
+
console.error(" see: packages/holocron-plugin-${inputs.slug}/README.md#setup");
|
|
1308
|
+
process.exit(2);
|
|
1309
|
+
}
|
|
1310
|
+
throw err;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
console.log("Validating @theholocron/holocron-plugin-${inputs.slug} (READ-ONLY)");
|
|
1314
|
+
console.log("");
|
|
1315
|
+
|
|
1316
|
+
// ── 1. verifyToken ─────────────────────────────────────────────────
|
|
1317
|
+
console.log("[1/N] verifyToken");
|
|
1318
|
+
const verifyResult = await verifyToken(token);
|
|
1319
|
+
console.log(\` \${verifyResult.ok ? "✓" : "✗"} \${JSON.stringify(verifyResult)}\`);
|
|
1320
|
+
if (!verifyResult.ok) {
|
|
1321
|
+
const hint = hintFor(verifyResult.message);
|
|
1322
|
+
if (hint) console.log(\` hint: \${hint}\`);
|
|
1323
|
+
}
|
|
1324
|
+
console.log("");
|
|
1325
|
+
|
|
1326
|
+
// ── 2..N. capability method calls ──────────────────────────────────
|
|
1327
|
+
// TODO: implement one \`runStep\`-wrapped call per meaningful read-side
|
|
1328
|
+
// capability method. Model on the infisical validate.mjs. Never call
|
|
1329
|
+
// write / ensure* / other mutating paths.
|
|
1330
|
+
//
|
|
1331
|
+
// Example:
|
|
1332
|
+
// console.log("[2/N] vault.list()");
|
|
1333
|
+
// await runStep(async () => {
|
|
1334
|
+
// const keys = await vault.list();
|
|
1335
|
+
// console.log(\` ✓ \${keys.length} secrets\`);
|
|
1336
|
+
// });
|
|
1337
|
+
|
|
1338
|
+
console.log("Done. Fill in capability method calls above before shipping.");
|
|
1339
|
+
|
|
1340
|
+
// ── helpers ────────────────────────────────────────────────────────
|
|
1341
|
+
|
|
1342
|
+
/** Wrap a capability call, print ERROR + hint on failure. */
|
|
1343
|
+
async function runStep(body) {
|
|
1344
|
+
try {
|
|
1345
|
+
await body();
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1348
|
+
console.log(\` ✗ ERROR: \${message}\`);
|
|
1349
|
+
const hint = hintFor(message);
|
|
1350
|
+
if (hint) console.log(\` hint: \${hint}\`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Point the operator at the most likely fix based on the error shape.
|
|
1356
|
+
* Generic defaults below — CUSTOMIZE per your vendor's docs URLs and
|
|
1357
|
+
* common permission/config gotchas.
|
|
1358
|
+
*/
|
|
1359
|
+
function hintFor(message) {
|
|
1360
|
+
if (/→ 401/.test(message)) {
|
|
1361
|
+
return "token invalid — regenerate per ${inputs.vendorName}'s docs (see README §Setup)";
|
|
1362
|
+
}
|
|
1363
|
+
if (/→ 403/.test(message)) {
|
|
1364
|
+
return "token authenticates but lacks scope — check the token/identity has permissions on the resource";
|
|
1365
|
+
}
|
|
1366
|
+
if (/→ 404/.test(message)) {
|
|
1367
|
+
return "endpoint or resource not found — verify your positional args match a real resource";
|
|
1368
|
+
}
|
|
1369
|
+
if (/fetch failed|status: 0|network/i.test(message)) {
|
|
1370
|
+
return "network error — check the base URL (${inputs.baseUrl}) and connectivity";
|
|
1371
|
+
}
|
|
1372
|
+
if (/→ 5\\d\\d/.test(message)) {
|
|
1373
|
+
return "server error — vendor-side. Retry, and check the vendor's status page if persistent";
|
|
1374
|
+
}
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
`;
|
|
1378
|
+
}
|
|
1379
|
+
//#endregion
|
|
1380
|
+
//#region src/commands/plugin-create/templates/verify-token.ts
|
|
1381
|
+
function render$2(inputs) {
|
|
1382
|
+
const clientClass = `${inputs.vendorName}RestClient`;
|
|
1383
|
+
return `/**
|
|
1384
|
+
* \`verifyToken\` — plugin-level export used by \`holocron auth set\` +
|
|
1385
|
+
* \`holocron auth check\`. Hits a lightweight whoami-style endpoint
|
|
1386
|
+
* and translates the response into the normalized \`VerifyTokenResult\`
|
|
1387
|
+
* shape.
|
|
1388
|
+
*
|
|
1389
|
+
* Kept as a standalone function (not a capability method) so the auth
|
|
1390
|
+
* command can call it without initializing the full plugin — plugin
|
|
1391
|
+
* construction requires an already-resolved token, which is exactly
|
|
1392
|
+
* what we don't have yet at bootstrap time.
|
|
1393
|
+
*
|
|
1394
|
+
* TODO: replace \`/me\` with the ${inputs.vendorName} equivalent of a
|
|
1395
|
+
* "check my token" endpoint. Common shapes: \`/user\`, \`/whoami\`,
|
|
1396
|
+
* \`/me\`, \`/account\`.
|
|
1397
|
+
*/
|
|
1398
|
+
|
|
1399
|
+
import { ${clientClass} } from "./rest.js";
|
|
1400
|
+
|
|
1401
|
+
export interface VerifyTokenSuccess {
|
|
1402
|
+
ok: true;
|
|
1403
|
+
subject: string;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
export interface VerifyTokenFailure {
|
|
1407
|
+
ok: false;
|
|
1408
|
+
message: string;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
export type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
|
|
1412
|
+
|
|
1413
|
+
interface MeResponse {
|
|
1414
|
+
/** Adjust to whatever ${inputs.vendorName}'s whoami endpoint returns. */
|
|
1415
|
+
name?: string;
|
|
1416
|
+
email?: string;
|
|
1417
|
+
id?: string;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
export interface VerifyTokenOptions {
|
|
1421
|
+
baseUrl?: string;
|
|
1422
|
+
fetch?: typeof fetch;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
export async function verifyToken(token: string, opts: VerifyTokenOptions = {}): Promise<VerifyTokenResult> {
|
|
1426
|
+
const restOpts: ConstructorParameters<typeof ${clientClass}>[0] = { token };
|
|
1427
|
+
if (opts.baseUrl !== undefined) restOpts.baseUrl = opts.baseUrl;
|
|
1428
|
+
if (opts.fetch !== undefined) restOpts.fetch = opts.fetch;
|
|
1429
|
+
const rest = new ${clientClass}(restOpts);
|
|
1430
|
+
try {
|
|
1431
|
+
const me = await rest.request<MeResponse>("/me");
|
|
1432
|
+
// Optional chaining because \`me\` is \`undefined\` on 204 / empty body.
|
|
1433
|
+
const subject = me?.email ?? me?.name ?? me?.id ?? "unknown";
|
|
1434
|
+
return { ok: true, subject: \`user @ \${subject}\` };
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1437
|
+
return { ok: false, message };
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
`;
|
|
1441
|
+
}
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region src/commands/plugin-create/templates/verify-token-test.ts
|
|
1444
|
+
function render$1(_inputs) {
|
|
1445
|
+
return `import { describe, expect, it } from "vitest";
|
|
1446
|
+
|
|
1447
|
+
import { verifyToken } from "../verify-token.js";
|
|
1448
|
+
import { stubFetch } from "./helpers.js";
|
|
1449
|
+
|
|
1450
|
+
describe("verifyToken", () => {
|
|
1451
|
+
it("returns ok with a subject when /me returns 200", async () => {
|
|
1452
|
+
const stub = stubFetch([{ status: 200, body: { email: "user@example.com" } }]);
|
|
1453
|
+
const result = await verifyToken("token", { fetch: stub.fetch });
|
|
1454
|
+
expect(result.ok).toBe(true);
|
|
1455
|
+
if (result.ok) {
|
|
1456
|
+
expect(result.subject).toMatch(/user@example.com/);
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it("returns ok:false with the error message on 401", async () => {
|
|
1461
|
+
const stub = stubFetch([{ status: 401, body: { messages: ["Invalid token"] } }]);
|
|
1462
|
+
const result = await verifyToken("bad", { fetch: stub.fetch });
|
|
1463
|
+
expect(result.ok).toBe(false);
|
|
1464
|
+
if (!result.ok) {
|
|
1465
|
+
expect(result.message).toMatch(/→ 401/);
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
it("returns ok:false when the network layer throws", async () => {
|
|
1470
|
+
const throwing: typeof fetch = async () => {
|
|
1471
|
+
throw new TypeError("network down");
|
|
1472
|
+
};
|
|
1473
|
+
const result = await verifyToken("t", { fetch: throwing });
|
|
1474
|
+
expect(result.ok).toBe(false);
|
|
1475
|
+
if (!result.ok) {
|
|
1476
|
+
expect(result.message).toMatch(/network down/);
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
1480
|
+
`;
|
|
1481
|
+
}
|
|
1482
|
+
//#endregion
|
|
1483
|
+
//#region src/commands/plugin-create/templates/vitest-config.ts
|
|
1484
|
+
function render(_inputs) {
|
|
1485
|
+
return `import { defineConfig } from "vitest/config";
|
|
1486
|
+
|
|
1487
|
+
export default defineConfig({
|
|
1488
|
+
test: {
|
|
1489
|
+
environment: "node",
|
|
1490
|
+
globals: false,
|
|
1491
|
+
coverage: {
|
|
1492
|
+
provider: "v8",
|
|
1493
|
+
reporter: ["text", "html", "json-summary"],
|
|
1494
|
+
include: ["src/**/*.ts"],
|
|
1495
|
+
exclude: ["src/**/__tests__/**", "src/**/*.test.ts", "src/index.ts"],
|
|
1496
|
+
thresholds: { lines: 0, functions: 0, branches: 0, statements: 0 },
|
|
1497
|
+
},
|
|
1498
|
+
},
|
|
1499
|
+
});
|
|
1500
|
+
`;
|
|
1501
|
+
}
|
|
1502
|
+
//#endregion
|
|
1503
|
+
//#region src/commands/plugin-create/index.ts
|
|
1504
|
+
/**
|
|
1505
|
+
* `holocron plugin create <slug> <vendor>` — scaffold a new plugin
|
|
1506
|
+
* package matching the proven template.
|
|
1507
|
+
*
|
|
1508
|
+
* Design: see `.notes/tool-plugin-create.spec.md`.
|
|
1509
|
+
*
|
|
1510
|
+
* Flow:
|
|
1511
|
+
* 1. Preflight — verify CWD is a workspace root (pnpm-workspace.yaml
|
|
1512
|
+
* + packages/ dir present).
|
|
1513
|
+
* 2. Slug collision — packages/holocron-plugin-<slug>/ must not exist.
|
|
1514
|
+
* 3. Capability sanity — must be one of the 14 known keys; warn for
|
|
1515
|
+
* many-cardinality caps.
|
|
1516
|
+
* 4. Prompt — fill in any missing flags via cli-utils / inquirer
|
|
1517
|
+
* (Phase B; Phase A takes fully-populated input).
|
|
1518
|
+
* 5. Generate — for each template, write to
|
|
1519
|
+
* packages/holocron-plugin-<slug>/<path>.
|
|
1520
|
+
* 6. Verify (unless --no-verify) — Phase B; runs pnpm install +
|
|
1521
|
+
* pnpm --filter <pkg> typecheck lint test.
|
|
1522
|
+
* 7. Print next steps.
|
|
1523
|
+
*/
|
|
1524
|
+
var PluginCreateError = class extends Error {
|
|
1525
|
+
name = "PluginCreateError";
|
|
1526
|
+
};
|
|
1527
|
+
const TEMPLATES = [
|
|
1528
|
+
{
|
|
1529
|
+
path: "package.json",
|
|
1530
|
+
render: render$10
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
path: "tsconfig.json",
|
|
1534
|
+
render: render$5
|
|
1535
|
+
},
|
|
1536
|
+
{
|
|
1537
|
+
path: "vitest.config.ts",
|
|
1538
|
+
render
|
|
1539
|
+
},
|
|
1540
|
+
{
|
|
1541
|
+
path: "eslint.config.js",
|
|
1542
|
+
render: render$13
|
|
1543
|
+
},
|
|
1544
|
+
{
|
|
1545
|
+
path: "tsdown.config.ts",
|
|
1546
|
+
render: render$4
|
|
1547
|
+
},
|
|
1548
|
+
{
|
|
1549
|
+
path: "README.md",
|
|
1550
|
+
render: render$8
|
|
1551
|
+
},
|
|
1552
|
+
{
|
|
1553
|
+
path: "src/auth.ts",
|
|
1554
|
+
render: render$17
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
path: "src/rest.ts",
|
|
1558
|
+
render: render$7
|
|
1559
|
+
},
|
|
1560
|
+
{
|
|
1561
|
+
path: "src/verify-token.ts",
|
|
1562
|
+
render: render$2
|
|
1563
|
+
},
|
|
1564
|
+
{
|
|
1565
|
+
path: "src/index.ts",
|
|
1566
|
+
render: render$9
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
path: "src/capabilities/{{capability}}.ts",
|
|
1570
|
+
render: render$15
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
path: "src/__tests__/helpers.ts",
|
|
1574
|
+
render: render$12
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
path: "src/__tests__/auth.test.ts",
|
|
1578
|
+
render: render$16
|
|
1579
|
+
},
|
|
1580
|
+
{
|
|
1581
|
+
path: "src/__tests__/rest.test.ts",
|
|
1582
|
+
render: render$6
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
path: "src/__tests__/verify-token.test.ts",
|
|
1586
|
+
render: render$1
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
path: "src/__tests__/{{capability}}.test.ts",
|
|
1590
|
+
render: render$14
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
path: "src/__tests__/index.test.ts",
|
|
1594
|
+
render: render$11
|
|
1595
|
+
},
|
|
1596
|
+
{
|
|
1597
|
+
path: "scripts/validate.mjs",
|
|
1598
|
+
render: render$3
|
|
1599
|
+
}
|
|
1600
|
+
];
|
|
1601
|
+
/** Replace `{{capability}}` in a template path with the actual capability key. */
|
|
1602
|
+
function resolvePath(template, inputs) {
|
|
1603
|
+
return template.replace(/\{\{capability\}\}/g, inputs.capability);
|
|
1604
|
+
}
|
|
1605
|
+
function runPluginCreate(input) {
|
|
1606
|
+
const cwd = input.cwd ?? process.cwd();
|
|
1607
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
1608
|
+
const write = input.writeFile ?? defaultWrite;
|
|
1609
|
+
preflight(cwd);
|
|
1610
|
+
validateSlug(input.slug);
|
|
1611
|
+
validateVendorName(input.vendorName);
|
|
1612
|
+
const packageDir = path.join(cwd, "packages", `holocron-plugin-${input.slug}`);
|
|
1613
|
+
if (existsSync(packageDir)) throw new PluginCreateError(`\`${packageDir}\` already exists — edit in place or pick a different slug.`);
|
|
1614
|
+
validateCapability(input.capability, print);
|
|
1615
|
+
const derived = deriveDefaults({
|
|
1616
|
+
slug: input.slug,
|
|
1617
|
+
vendorName: input.vendorName,
|
|
1618
|
+
capability: input.capability
|
|
1619
|
+
});
|
|
1620
|
+
const inputs = {
|
|
1621
|
+
slug: input.slug,
|
|
1622
|
+
vendorName: input.vendorName,
|
|
1623
|
+
vendorUpper: derived.vendorUpper,
|
|
1624
|
+
capability: input.capability,
|
|
1625
|
+
capabilityClass: derived.capabilityClass,
|
|
1626
|
+
tokenEnv: input.tokenEnv ?? derived.tokenEnv,
|
|
1627
|
+
vendorEnv: input.vendorEnv,
|
|
1628
|
+
baseUrl: trimTrailingSlashes(input.baseUrl),
|
|
1629
|
+
transport: derived.transport
|
|
1630
|
+
};
|
|
1631
|
+
const filesWritten = [];
|
|
1632
|
+
print(`Scaffolding @theholocron/holocron-plugin-${inputs.slug}${input.dryRun ? " (dry-run)" : ""}`);
|
|
1633
|
+
print(` → ${packageDir}`);
|
|
1634
|
+
for (const template of TEMPLATES) {
|
|
1635
|
+
const resolvedPath = resolvePath(template.path, inputs);
|
|
1636
|
+
const filepath = path.join(packageDir, resolvedPath);
|
|
1637
|
+
const content = template.render(inputs);
|
|
1638
|
+
if (input.dryRun) print(` … would write ${resolvedPath} (${content.length} bytes)`);
|
|
1639
|
+
else {
|
|
1640
|
+
write(filepath, content);
|
|
1641
|
+
print(` ✓ ${resolvedPath}`);
|
|
1642
|
+
}
|
|
1643
|
+
filesWritten.push(resolvedPath);
|
|
1644
|
+
}
|
|
1645
|
+
if (!input.dryRun) printNextSteps(print, inputs);
|
|
1646
|
+
return {
|
|
1647
|
+
status: "ok",
|
|
1648
|
+
packagePath: packageDir,
|
|
1649
|
+
filesWritten
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
function preflight(cwd) {
|
|
1653
|
+
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.`);
|
|
1654
|
+
if (!existsSync(path.join(cwd, "packages"))) throw new PluginCreateError(`\`packages/\` directory not found in \`${cwd}\`.`);
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* npm-package-name conventions: kebab-case, starts with lowercase
|
|
1658
|
+
* letter. Rejecting numeric-prefixed slugs also sidesteps the
|
|
1659
|
+
* `env.HOLOCRON_1FOO_TOKEN` invalid-identifier trap in the auth
|
|
1660
|
+
* template.
|
|
1661
|
+
*/
|
|
1662
|
+
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
1663
|
+
function validateSlug(slug) {
|
|
1664
|
+
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".`);
|
|
1665
|
+
}
|
|
1666
|
+
/** PascalCase — starts with uppercase, alphanumeric only. */
|
|
1667
|
+
const VENDOR_NAME_RE = /^[A-Z][a-zA-Z0-9]*$/;
|
|
1668
|
+
function validateVendorName(name) {
|
|
1669
|
+
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".`);
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Trim trailing slashes with a plain loop (no regex — matches the
|
|
1673
|
+
* rest.ts template's own trimming to stay ReDoS-safe under CodeQL).
|
|
1674
|
+
*/
|
|
1675
|
+
function trimTrailingSlashes(url) {
|
|
1676
|
+
let out = url;
|
|
1677
|
+
while (out.endsWith("/")) out = out.slice(0, -1);
|
|
1678
|
+
return out;
|
|
1679
|
+
}
|
|
1680
|
+
function validateCapability(capability, print) {
|
|
1681
|
+
if (!(capability in CARDINALITY)) throw new PluginCreateError(`\`${capability}\` is not a known capability. See \`packages/cli/src/capabilities/index.ts\`.`);
|
|
1682
|
+
if (CARDINALITY[capability] === "many") print(` ! ${capability} is a many-cardinality capability — multiple providers can be active at once. Confirm your config wiring accordingly.`);
|
|
1683
|
+
}
|
|
1684
|
+
function defaultWrite(filepath, content) {
|
|
1685
|
+
mkdirSync(path.dirname(filepath), { recursive: true });
|
|
1686
|
+
writeFileSync(filepath, content, "utf8");
|
|
1687
|
+
}
|
|
1688
|
+
function printNextSteps(print, inputs) {
|
|
1689
|
+
print("");
|
|
1690
|
+
print(` Scaffolded @theholocron/holocron-plugin-${inputs.slug} (18 files).`);
|
|
1691
|
+
print("");
|
|
1692
|
+
print(" Next:");
|
|
1693
|
+
print(` 1. pnpm install # picks up the workspace package`);
|
|
1694
|
+
print(` 2. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} typecheck # green`);
|
|
1695
|
+
print(` 3. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} lint # green`);
|
|
1696
|
+
print(` 4. pnpm --filter @theholocron/holocron-plugin-${inputs.slug} test # green (stubs pass, real tests are it.todo)`);
|
|
1697
|
+
print(` 5. Implement ${inputs.capabilityClass} methods in src/capabilities/${inputs.capability}.ts`);
|
|
1698
|
+
print(` 6. Replace it.todo(...) with real tests in src/__tests__/${inputs.capability}.test.ts`);
|
|
1699
|
+
print(" 7. Commit + push when capability is functionally complete.");
|
|
1700
|
+
}
|
|
1701
|
+
//#endregion
|
|
322
1702
|
//#region src/commands/secret-set.ts
|
|
323
1703
|
async function runSecretSet(input) {
|
|
324
1704
|
const print = input.print ?? ((line) => console.log(line));
|
|
@@ -485,7 +1865,389 @@ function vaultProviderName(loader) {
|
|
|
485
1865
|
return loader.get("vault").providerName;
|
|
486
1866
|
}
|
|
487
1867
|
//#endregion
|
|
1868
|
+
//#region src/commands/setup-workflows.ts
|
|
1869
|
+
/**
|
|
1870
|
+
* Thin workflow wrapper templates for `holocron setup`.
|
|
1871
|
+
*
|
|
1872
|
+
* Each entry is a complete `.github/workflows/<name>.yml` that delegates
|
|
1873
|
+
* to the corresponding reusable `ci-<name>.yml` in `theholocron/.github`.
|
|
1874
|
+
* Files are overwritten on each setup run — they are generated artifacts.
|
|
1875
|
+
*/
|
|
1876
|
+
const WORKFLOW_REPO = "theholocron/.github";
|
|
1877
|
+
const WORKFLOW_REF = "main";
|
|
1878
|
+
function ref(name) {
|
|
1879
|
+
return `${WORKFLOW_REPO}/.github/workflows/${name}.yml@${WORKFLOW_REF}`;
|
|
1880
|
+
}
|
|
1881
|
+
/** Header prepended when holocron setup writes a thin caller to a repo. */
|
|
1882
|
+
const WORKFLOW_HEADER = `\
|
|
1883
|
+
# AUTO-GENERATED by holocron — do not edit directly.
|
|
1884
|
+
# Source: theholocron/holocron · packages/cli/src/commands/setup-workflows.ts
|
|
1885
|
+
# Run \`holocron setup\` to regenerate.
|
|
1886
|
+
|
|
1887
|
+
`;
|
|
1888
|
+
const WORKFLOW_TEMPLATES = {
|
|
1889
|
+
lint: `\
|
|
1890
|
+
name: Lint
|
|
1891
|
+
|
|
1892
|
+
on: # yamllint disable-line rule:truthy
|
|
1893
|
+
push:
|
|
1894
|
+
branches: [main, alpha]
|
|
1895
|
+
pull_request:
|
|
1896
|
+
|
|
1897
|
+
concurrency:
|
|
1898
|
+
group: lint-\${{ github.ref }}
|
|
1899
|
+
cancel-in-progress: true
|
|
1900
|
+
|
|
1901
|
+
permissions:
|
|
1902
|
+
contents: write
|
|
1903
|
+
statuses: write
|
|
1904
|
+
|
|
1905
|
+
jobs:
|
|
1906
|
+
lint:
|
|
1907
|
+
name: Lint
|
|
1908
|
+
uses: ${ref("lint")}
|
|
1909
|
+
secrets: inherit
|
|
1910
|
+
with:
|
|
1911
|
+
enable-auto-commit: true
|
|
1912
|
+
`,
|
|
1913
|
+
test: `\
|
|
1914
|
+
name: Test
|
|
1915
|
+
|
|
1916
|
+
on: # yamllint disable-line rule:truthy
|
|
1917
|
+
push:
|
|
1918
|
+
branches: [main, alpha]
|
|
1919
|
+
pull_request:
|
|
1920
|
+
|
|
1921
|
+
concurrency:
|
|
1922
|
+
group: test-\${{ github.ref }}
|
|
1923
|
+
cancel-in-progress: true
|
|
1924
|
+
|
|
1925
|
+
permissions:
|
|
1926
|
+
contents: read
|
|
1927
|
+
|
|
1928
|
+
jobs:
|
|
1929
|
+
test:
|
|
1930
|
+
name: Test
|
|
1931
|
+
uses: ${ref("test")}
|
|
1932
|
+
secrets: inherit
|
|
1933
|
+
`,
|
|
1934
|
+
typecheck: `\
|
|
1935
|
+
name: Typecheck
|
|
1936
|
+
|
|
1937
|
+
on: # yamllint disable-line rule:truthy
|
|
1938
|
+
push:
|
|
1939
|
+
branches: [main, alpha]
|
|
1940
|
+
pull_request:
|
|
1941
|
+
|
|
1942
|
+
concurrency:
|
|
1943
|
+
group: typecheck-\${{ github.ref }}
|
|
1944
|
+
cancel-in-progress: true
|
|
1945
|
+
|
|
1946
|
+
permissions:
|
|
1947
|
+
contents: read
|
|
1948
|
+
|
|
1949
|
+
jobs:
|
|
1950
|
+
typecheck:
|
|
1951
|
+
name: Typecheck
|
|
1952
|
+
uses: ${ref("typecheck")}
|
|
1953
|
+
secrets: inherit
|
|
1954
|
+
`,
|
|
1955
|
+
codeql: `\
|
|
1956
|
+
name: CodeQL
|
|
1957
|
+
|
|
1958
|
+
on: # yamllint disable-line rule:truthy
|
|
1959
|
+
push:
|
|
1960
|
+
branches:
|
|
1961
|
+
- main
|
|
1962
|
+
pull_request:
|
|
1963
|
+
branches:
|
|
1964
|
+
- main
|
|
1965
|
+
schedule:
|
|
1966
|
+
- cron: "0 0 * * 1"
|
|
1967
|
+
|
|
1968
|
+
permissions:
|
|
1969
|
+
actions: read
|
|
1970
|
+
contents: read
|
|
1971
|
+
security-events: write
|
|
1972
|
+
|
|
1973
|
+
jobs:
|
|
1974
|
+
codeql:
|
|
1975
|
+
uses: ${ref("codeql")}
|
|
1976
|
+
secrets: inherit
|
|
1977
|
+
`,
|
|
1978
|
+
review: `\
|
|
1979
|
+
name: Review
|
|
1980
|
+
|
|
1981
|
+
on: # yamllint disable-line rule:truthy
|
|
1982
|
+
pull_request:
|
|
1983
|
+
|
|
1984
|
+
concurrency:
|
|
1985
|
+
group: review-\${{ github.ref }}
|
|
1986
|
+
cancel-in-progress: true
|
|
1987
|
+
|
|
1988
|
+
permissions:
|
|
1989
|
+
contents: read
|
|
1990
|
+
checks: write
|
|
1991
|
+
pull-requests: write
|
|
1992
|
+
|
|
1993
|
+
jobs:
|
|
1994
|
+
review:
|
|
1995
|
+
name: Review
|
|
1996
|
+
uses: ${ref("review")}
|
|
1997
|
+
secrets: inherit
|
|
1998
|
+
`,
|
|
1999
|
+
release: `\
|
|
2000
|
+
name: Release
|
|
2001
|
+
|
|
2002
|
+
on: # yamllint disable-line rule:truthy
|
|
2003
|
+
push:
|
|
2004
|
+
branches:
|
|
2005
|
+
- main
|
|
2006
|
+
|
|
2007
|
+
permissions:
|
|
2008
|
+
contents: write
|
|
2009
|
+
id-token: write
|
|
2010
|
+
issues: write
|
|
2011
|
+
pull-requests: write
|
|
2012
|
+
|
|
2013
|
+
jobs:
|
|
2014
|
+
release:
|
|
2015
|
+
uses: ${ref("release")}
|
|
2016
|
+
secrets: inherit
|
|
2017
|
+
`,
|
|
2018
|
+
stale: `\
|
|
2019
|
+
name: Stale
|
|
2020
|
+
|
|
2021
|
+
on: # yamllint disable-line rule:truthy
|
|
2022
|
+
schedule:
|
|
2023
|
+
- cron: "30 1 * * *"
|
|
2024
|
+
|
|
2025
|
+
permissions:
|
|
2026
|
+
contents: write
|
|
2027
|
+
issues: write
|
|
2028
|
+
pull-requests: write
|
|
2029
|
+
|
|
2030
|
+
jobs:
|
|
2031
|
+
stale:
|
|
2032
|
+
uses: ${ref("stale")}
|
|
2033
|
+
secrets: inherit
|
|
2034
|
+
`,
|
|
2035
|
+
greetings: `\
|
|
2036
|
+
name: Greetings
|
|
2037
|
+
|
|
2038
|
+
on: # yamllint disable-line rule:truthy
|
|
2039
|
+
pull_request:
|
|
2040
|
+
issues:
|
|
2041
|
+
|
|
2042
|
+
permissions:
|
|
2043
|
+
issues: write
|
|
2044
|
+
pull-requests: write
|
|
2045
|
+
|
|
2046
|
+
jobs:
|
|
2047
|
+
greetings:
|
|
2048
|
+
uses: ${ref("greetings")}
|
|
2049
|
+
secrets: inherit
|
|
2050
|
+
`,
|
|
2051
|
+
dependencies: `\
|
|
2052
|
+
name: Dependencies
|
|
2053
|
+
|
|
2054
|
+
on: # yamllint disable-line rule:truthy
|
|
2055
|
+
pull_request:
|
|
2056
|
+
|
|
2057
|
+
permissions:
|
|
2058
|
+
contents: write
|
|
2059
|
+
pull-requests: write
|
|
2060
|
+
|
|
2061
|
+
jobs:
|
|
2062
|
+
dependencies:
|
|
2063
|
+
uses: ${ref("dependencies")}
|
|
2064
|
+
secrets: inherit
|
|
2065
|
+
`,
|
|
2066
|
+
"bookkeeping-pr": `\
|
|
2067
|
+
name: PR Bookkeeping
|
|
2068
|
+
|
|
2069
|
+
on: # yamllint disable-line rule:truthy
|
|
2070
|
+
pull_request:
|
|
2071
|
+
types:
|
|
2072
|
+
- opened
|
|
2073
|
+
- edited
|
|
2074
|
+
|
|
2075
|
+
permissions:
|
|
2076
|
+
contents: read
|
|
2077
|
+
pull-requests: write
|
|
2078
|
+
|
|
2079
|
+
jobs:
|
|
2080
|
+
bookkeeping:
|
|
2081
|
+
uses: ${ref("bookkeeping-pr")}
|
|
2082
|
+
secrets: inherit
|
|
2083
|
+
`,
|
|
2084
|
+
audit: `\
|
|
2085
|
+
name: Audit
|
|
2086
|
+
|
|
2087
|
+
on: # yamllint disable-line rule:truthy
|
|
2088
|
+
push:
|
|
2089
|
+
branches: [main, alpha]
|
|
2090
|
+
pull_request:
|
|
2091
|
+
|
|
2092
|
+
permissions:
|
|
2093
|
+
contents: read
|
|
2094
|
+
|
|
2095
|
+
jobs:
|
|
2096
|
+
audit:
|
|
2097
|
+
uses: ${ref("audit")}
|
|
2098
|
+
secrets: inherit
|
|
2099
|
+
`
|
|
2100
|
+
};
|
|
2101
|
+
const KNOWN_WORKFLOWS = new Set(Object.keys(WORKFLOW_TEMPLATES));
|
|
2102
|
+
//#endregion
|
|
488
2103
|
//#region src/commands/setup.ts
|
|
2104
|
+
const DEPENDABOT_CONFIG = `\
|
|
2105
|
+
# AUTO-GENERATED by holocron — run \`holocron setup\` to regenerate.
|
|
2106
|
+
version: 2
|
|
2107
|
+
updates:
|
|
2108
|
+
- package-ecosystem: npm
|
|
2109
|
+
directory: /
|
|
2110
|
+
schedule:
|
|
2111
|
+
interval: weekly
|
|
2112
|
+
groups:
|
|
2113
|
+
security-patches:
|
|
2114
|
+
applies-to: security-updates
|
|
2115
|
+
patterns:
|
|
2116
|
+
- "*"
|
|
2117
|
+
all-dependencies:
|
|
2118
|
+
update-types:
|
|
2119
|
+
- minor
|
|
2120
|
+
- patch
|
|
2121
|
+
|
|
2122
|
+
- package-ecosystem: github-actions
|
|
2123
|
+
directory: /
|
|
2124
|
+
schedule:
|
|
2125
|
+
interval: weekly
|
|
2126
|
+
groups:
|
|
2127
|
+
all-actions:
|
|
2128
|
+
patterns:
|
|
2129
|
+
- "*"
|
|
2130
|
+
`;
|
|
2131
|
+
const RULESET_NAME = "holocron-default-branch";
|
|
2132
|
+
const BALANCED_REPO_SETTINGS = {
|
|
2133
|
+
allow_squash_merge: true,
|
|
2134
|
+
allow_merge_commit: false,
|
|
2135
|
+
allow_rebase_merge: false,
|
|
2136
|
+
allow_auto_merge: true,
|
|
2137
|
+
allow_update_branch: true,
|
|
2138
|
+
delete_branch_on_merge: true,
|
|
2139
|
+
has_issues: true,
|
|
2140
|
+
has_discussions: true,
|
|
2141
|
+
has_projects: true,
|
|
2142
|
+
has_wiki: false
|
|
2143
|
+
};
|
|
2144
|
+
function buildClassicProtectionPayload(requiredChecks = []) {
|
|
2145
|
+
return {
|
|
2146
|
+
required_status_checks: requiredChecks.length > 0 ? {
|
|
2147
|
+
strict: false,
|
|
2148
|
+
contexts: requiredChecks
|
|
2149
|
+
} : null,
|
|
2150
|
+
enforce_admins: false,
|
|
2151
|
+
required_pull_request_reviews: {
|
|
2152
|
+
required_approving_review_count: 0,
|
|
2153
|
+
dismiss_stale_reviews: false,
|
|
2154
|
+
require_code_owner_reviews: false
|
|
2155
|
+
},
|
|
2156
|
+
restrictions: null,
|
|
2157
|
+
allow_force_pushes: false,
|
|
2158
|
+
allow_deletions: false
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
function buildRulesetPayload(requiredChecks = []) {
|
|
2162
|
+
const rules = [
|
|
2163
|
+
{ type: "deletion" },
|
|
2164
|
+
{ type: "non_fast_forward" },
|
|
2165
|
+
{
|
|
2166
|
+
type: "pull_request",
|
|
2167
|
+
parameters: {
|
|
2168
|
+
required_approving_review_count: 0,
|
|
2169
|
+
dismiss_stale_reviews_on_push: false,
|
|
2170
|
+
require_code_owner_review: false,
|
|
2171
|
+
require_last_push_approval: false,
|
|
2172
|
+
required_review_thread_resolution: false
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
];
|
|
2176
|
+
if (requiredChecks.length > 0) rules.push({
|
|
2177
|
+
type: "required_status_checks",
|
|
2178
|
+
parameters: {
|
|
2179
|
+
required_status_checks: requiredChecks.map((context) => ({ context })),
|
|
2180
|
+
strict_required_status_checks_policy: false
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
return {
|
|
2184
|
+
name: RULESET_NAME,
|
|
2185
|
+
target: "branch",
|
|
2186
|
+
enforcement: "active",
|
|
2187
|
+
conditions: { ref_name: {
|
|
2188
|
+
include: ["~DEFAULT_BRANCH"],
|
|
2189
|
+
exclude: []
|
|
2190
|
+
} },
|
|
2191
|
+
rules
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
async function upsertBranchProtection(source, dryRun, requiredChecks) {
|
|
2195
|
+
const step = `upsert ruleset ${RULESET_NAME}`;
|
|
2196
|
+
if (dryRun) return {
|
|
2197
|
+
capability: "source",
|
|
2198
|
+
step,
|
|
2199
|
+
status: "dry-run"
|
|
2200
|
+
};
|
|
2201
|
+
try {
|
|
2202
|
+
const found = (await source.listRulesets()).find((r) => r.name === RULESET_NAME);
|
|
2203
|
+
if (found) {
|
|
2204
|
+
await source.updateRuleset(found.id, buildRulesetPayload(requiredChecks));
|
|
2205
|
+
return {
|
|
2206
|
+
capability: "source",
|
|
2207
|
+
step,
|
|
2208
|
+
status: "ok",
|
|
2209
|
+
message: "updated"
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
await source.createRuleset(buildRulesetPayload(requiredChecks));
|
|
2213
|
+
return {
|
|
2214
|
+
capability: "source",
|
|
2215
|
+
step,
|
|
2216
|
+
status: "ok",
|
|
2217
|
+
message: "created"
|
|
2218
|
+
};
|
|
2219
|
+
} catch (err) {
|
|
2220
|
+
if (!(err instanceof ProviderApiError) || err.status !== 403) return {
|
|
2221
|
+
capability: "source",
|
|
2222
|
+
step,
|
|
2223
|
+
status: "fail",
|
|
2224
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
try {
|
|
2228
|
+
const repo = await source.getRepo();
|
|
2229
|
+
await source.protectBranch(repo.defaultBranch, buildClassicProtectionPayload(requiredChecks));
|
|
2230
|
+
return {
|
|
2231
|
+
capability: "source",
|
|
2232
|
+
step,
|
|
2233
|
+
status: "ok",
|
|
2234
|
+
message: `classic protection on ${repo.defaultBranch}`
|
|
2235
|
+
};
|
|
2236
|
+
} catch (err) {
|
|
2237
|
+
if (err instanceof ProviderApiError && err.status === 403) return {
|
|
2238
|
+
capability: "source",
|
|
2239
|
+
step,
|
|
2240
|
+
status: "skip",
|
|
2241
|
+
message: "branch protection unavailable on private repos without GitHub Pro/Team"
|
|
2242
|
+
};
|
|
2243
|
+
return {
|
|
2244
|
+
capability: "source",
|
|
2245
|
+
step,
|
|
2246
|
+
status: "fail",
|
|
2247
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
489
2251
|
async function runSetup(input) {
|
|
490
2252
|
const print = input.print ?? ((line) => console.log(line));
|
|
491
2253
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
@@ -503,13 +2265,57 @@ async function runSetup(input) {
|
|
|
503
2265
|
"enableVulnerabilityAlerts",
|
|
504
2266
|
"enableAutomatedSecurityFixes",
|
|
505
2267
|
"enableSecretScanning",
|
|
506
|
-
"enablePrivateVulnerabilityReporting"
|
|
2268
|
+
"enablePrivateVulnerabilityReporting",
|
|
2269
|
+
"enableDependencyGraph"
|
|
507
2270
|
]) {
|
|
508
2271
|
steps.push(await runStep("source", method, dryRun, async () => {
|
|
509
2272
|
await source[method]();
|
|
510
2273
|
}));
|
|
511
2274
|
print(formatStep(steps[steps.length - 1]));
|
|
512
2275
|
}
|
|
2276
|
+
steps.push(await runStep("source", "enableCodeScanning", dryRun, async () => {
|
|
2277
|
+
return await source.enableCodeScanning();
|
|
2278
|
+
}));
|
|
2279
|
+
print(formatStep(steps[steps.length - 1]));
|
|
2280
|
+
const policy = config.project.repoPolicy;
|
|
2281
|
+
if (policy && policy.preset !== "none") {
|
|
2282
|
+
const preset = policy.preset ?? "balanced";
|
|
2283
|
+
steps.push(await runStep("source", "updateRepoSettings", dryRun, async () => {
|
|
2284
|
+
await source.updateRepoSettings(BALANCED_REPO_SETTINGS);
|
|
2285
|
+
}));
|
|
2286
|
+
print(formatStep(steps[steps.length - 1]));
|
|
2287
|
+
const requiredChecks = preset === "strict" ? policy.requiredChecks ?? [] : [];
|
|
2288
|
+
steps.push(await upsertBranchProtection(source, dryRun, requiredChecks));
|
|
2289
|
+
print(formatStep(steps[steps.length - 1]));
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
const workflows = config.project.workflows;
|
|
2293
|
+
if (loader.has("source") && workflows && workflows.length > 0) {
|
|
2294
|
+
const source = loader.get("source");
|
|
2295
|
+
print(" → workflows");
|
|
2296
|
+
for (const name of workflows) {
|
|
2297
|
+
if (!KNOWN_WORKFLOWS.has(name)) {
|
|
2298
|
+
steps.push({
|
|
2299
|
+
capability: "source",
|
|
2300
|
+
step: `write workflow ${name}`,
|
|
2301
|
+
status: "skip",
|
|
2302
|
+
message: `unknown workflow "${name}" — no template available`
|
|
2303
|
+
});
|
|
2304
|
+
print(formatStep(steps[steps.length - 1]));
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
steps.push(await runStep("source", `write workflow ${name}`, dryRun, async () => {
|
|
2308
|
+
await source.writeWorkflowFile(`${name}.yml`, WORKFLOW_HEADER + WORKFLOW_TEMPLATES[name]);
|
|
2309
|
+
}));
|
|
2310
|
+
print(formatStep(steps[steps.length - 1]));
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
if (loader.has("source") && config.project.repoPolicy?.preset !== "none") {
|
|
2314
|
+
const source = loader.get("source");
|
|
2315
|
+
steps.push(await runStep("source", "write .github/dependabot.yml", dryRun, async () => {
|
|
2316
|
+
await source.writeRepoFile(".github/dependabot.yml", DEPENDABOT_CONFIG);
|
|
2317
|
+
}));
|
|
2318
|
+
print(formatStep(steps[steps.length - 1]));
|
|
513
2319
|
}
|
|
514
2320
|
if (loader.has("environments")) {
|
|
515
2321
|
const envs = loader.get("environments");
|
|
@@ -550,6 +2356,22 @@ async function runSetup(input) {
|
|
|
550
2356
|
if (loader.has("vault")) {
|
|
551
2357
|
const vault = loader.get("vault");
|
|
552
2358
|
print(" → vault");
|
|
2359
|
+
if (vault.ensureProject) {
|
|
2360
|
+
steps.push(await runStep("vault", `ensureProject ${config.project.name}`, dryRun, async () => {
|
|
2361
|
+
return `project ${(await vault.ensureProject(config.project.name)).alreadyExists ? "exists" : "created"}`;
|
|
2362
|
+
}));
|
|
2363
|
+
print(formatStep(steps[steps.length - 1]));
|
|
2364
|
+
}
|
|
2365
|
+
if (vault.ensureEnvironment) for (const envName of [
|
|
2366
|
+
"dev",
|
|
2367
|
+
"stg",
|
|
2368
|
+
"prd"
|
|
2369
|
+
]) {
|
|
2370
|
+
steps.push(await runStep("vault", `ensureEnvironment ${envName}`, dryRun, async () => {
|
|
2371
|
+
return `${envName} ${(await vault.ensureEnvironment(config.project.name, envName)).alreadyExists ? "exists" : "created"}`;
|
|
2372
|
+
}));
|
|
2373
|
+
print(formatStep(steps[steps.length - 1]));
|
|
2374
|
+
}
|
|
553
2375
|
try {
|
|
554
2376
|
const keys = await vault.list();
|
|
555
2377
|
steps.push({
|
|
@@ -686,6 +2508,7 @@ async function fileExists(path) {
|
|
|
686
2508
|
}
|
|
687
2509
|
//#endregion
|
|
688
2510
|
//#region src/cli.ts
|
|
2511
|
+
const { version: CLI_VERSION } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
689
2512
|
await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [options]").option("dry-run", {
|
|
690
2513
|
type: "boolean",
|
|
691
2514
|
default: false,
|
|
@@ -698,7 +2521,7 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
|
|
|
698
2521
|
default: process.cwd(),
|
|
699
2522
|
describe: "Directory to search for holocron.config.json"
|
|
700
2523
|
}).command("version", "Print the CLI version", () => {}, () => {
|
|
701
|
-
console.log(
|
|
2524
|
+
console.log(`holocron ${CLI_VERSION}`);
|
|
702
2525
|
}).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
|
|
703
2526
|
type: "string",
|
|
704
2527
|
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
@@ -806,20 +2629,100 @@ await yargs(hideBin(process.argv)).scriptName("holocron").usage("$0 <command> [o
|
|
|
806
2629
|
branch: argv.branch,
|
|
807
2630
|
...argv.target ? { target: argv.target } : {}
|
|
808
2631
|
})).status === "fail") process.exitCode = 1;
|
|
809
|
-
}).command("npm
|
|
2632
|
+
}).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", {
|
|
2633
|
+
type: "string",
|
|
2634
|
+
demandOption: true,
|
|
2635
|
+
describe: "Version to set (e.g., 4.2.0 or 2.0.0-alpha.1)"
|
|
2636
|
+
}), async (argv) => {
|
|
2637
|
+
if ((await runNpmBumpVersions({
|
|
2638
|
+
version: argv.newVersion,
|
|
2639
|
+
cwd: argv.cwd,
|
|
2640
|
+
dryRun: argv.dryRun
|
|
2641
|
+
})).status === "fail") process.exitCode = 1;
|
|
2642
|
+
}).command("publish-initial", "One-shot bootstrap publish for trusted-publishing-eligible packages", (yy) => yy.option("tag", {
|
|
810
2643
|
type: "string",
|
|
811
2644
|
default: "alpha",
|
|
812
2645
|
describe: "npm distribution tag (defaults to alpha)"
|
|
2646
|
+
}).option("otp", {
|
|
2647
|
+
type: "string",
|
|
2648
|
+
describe: "One-time password from your authenticator (required if npm needs 2FA for writes)"
|
|
813
2649
|
}), async (argv) => {
|
|
814
2650
|
if ((await runNpmPublishInitial({
|
|
815
2651
|
cwd: argv.cwd,
|
|
816
2652
|
tag: argv.tag,
|
|
817
|
-
dryRun: argv.dryRun
|
|
2653
|
+
dryRun: argv.dryRun,
|
|
2654
|
+
...argv.otp ? { otp: argv.otp } : {}
|
|
818
2655
|
})).status === "fail") process.exitCode = 1;
|
|
819
|
-
}).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
|
|
2656
|
+
}).demandCommand(1, "Run `holocron npm --help` to see available npm subcommands."), () => {}).command("config show", "Print the resolved holocron config", () => {}, async (argv) => {
|
|
820
2657
|
const loaded = await loadConfig(argv.cwd);
|
|
821
2658
|
console.log(JSON.stringify(loaded.resolved, null, 2));
|
|
822
|
-
}).
|
|
2659
|
+
}).command("plugin create <slug> <vendor>", "Scaffold a new @theholocron/holocron-plugin-<slug> package", (y) => y.positional("slug", {
|
|
2660
|
+
type: "string",
|
|
2661
|
+
demandOption: true,
|
|
2662
|
+
describe: "Package slug (kebab-case)"
|
|
2663
|
+
}).positional("vendor", {
|
|
2664
|
+
type: "string",
|
|
2665
|
+
demandOption: true,
|
|
2666
|
+
describe: "Vendor display name (PascalCase)"
|
|
2667
|
+
}).option("capability", {
|
|
2668
|
+
type: "string",
|
|
2669
|
+
describe: "Capability key: source|ci|secrets|environments|issues|deployment|storage|auth|vault|dns|tooling|notifications|analytics|observability"
|
|
2670
|
+
}).option("token-env", {
|
|
2671
|
+
type: "string",
|
|
2672
|
+
describe: "Holocron env var name (defaults to HOLOCRON_<VENDOR>_TOKEN)"
|
|
2673
|
+
}).option("vendor-env", {
|
|
2674
|
+
type: "string",
|
|
2675
|
+
describe: "Vendor-native env var name"
|
|
2676
|
+
}).option("base-url", {
|
|
2677
|
+
type: "string",
|
|
2678
|
+
describe: "REST base URL"
|
|
2679
|
+
}).option("verify", {
|
|
2680
|
+
type: "boolean",
|
|
2681
|
+
default: true,
|
|
2682
|
+
describe: "Run post-scaffold pnpm install + typecheck + lint + test (default true; --no-verify skips)"
|
|
2683
|
+
}), (argv) => {
|
|
2684
|
+
try {
|
|
2685
|
+
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.");
|
|
2686
|
+
if (runPluginCreate({
|
|
2687
|
+
slug: argv.slug,
|
|
2688
|
+
vendorName: argv.vendor,
|
|
2689
|
+
capability: argv.capability,
|
|
2690
|
+
vendorEnv: argv.vendorEnv,
|
|
2691
|
+
baseUrl: argv.baseUrl,
|
|
2692
|
+
...argv.tokenEnv ? { tokenEnv: argv.tokenEnv } : {},
|
|
2693
|
+
dryRun: argv.dryRun,
|
|
2694
|
+
noVerify: !argv.verify,
|
|
2695
|
+
cwd: argv.cwd
|
|
2696
|
+
}).status === "fail") process.exitCode = 1;
|
|
2697
|
+
} catch (err) {
|
|
2698
|
+
if (err instanceof PluginCreateError) {
|
|
2699
|
+
console.error(`plugin create: ${err.message}`);
|
|
2700
|
+
process.exitCode = 1;
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
throw err;
|
|
2704
|
+
}
|
|
2705
|
+
}).command("auth <subcommand>", "Manage bootstrap credentials in the OS keyring", (y) => y.command("set <provider> [token]", "Verify + store a bootstrap token for a provider", (yy) => yy.positional("provider", {
|
|
2706
|
+
type: "string",
|
|
2707
|
+
demandOption: true
|
|
2708
|
+
}).positional("token", { type: "string" }), async (argv) => {
|
|
2709
|
+
if ((await runAuthSet({
|
|
2710
|
+
provider: argv.provider,
|
|
2711
|
+
...argv.token ? { positional: argv.token } : {}
|
|
2712
|
+
})).status === "fail") process.exitCode = 1;
|
|
2713
|
+
}).command("unset <provider>", "Remove a stored bootstrap token", (yy) => yy.positional("provider", {
|
|
2714
|
+
type: "string",
|
|
2715
|
+
demandOption: true
|
|
2716
|
+
}), (argv) => {
|
|
2717
|
+
runAuthUnset({ provider: argv.provider });
|
|
2718
|
+
}).command("check <provider>", "Re-verify a stored bootstrap token", (yy) => yy.positional("provider", {
|
|
2719
|
+
type: "string",
|
|
2720
|
+
demandOption: true
|
|
2721
|
+
}), async (argv) => {
|
|
2722
|
+
if ((await runAuthCheck({ provider: argv.provider })).status === "fail") process.exitCode = 1;
|
|
2723
|
+
}).command("list", "List every provider with a stored bootstrap token", () => {}, async () => {
|
|
2724
|
+
await runAuthList();
|
|
2725
|
+
}).demandCommand(1, "Run `holocron auth --help` to see available auth subcommands."), () => {}).demandCommand(1, "Run `holocron --help` to see available commands.").strict().help().parse();
|
|
823
2726
|
/**
|
|
824
2727
|
* Parse `--scope` strings: `repo` | `env=NAME` | `org=NAME`.
|
|
825
2728
|
*/
|