@theholocron/cli 5.0.0-alpha.159 → 5.0.0-alpha.160
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/dist/cli.mjs +245 -9
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
package/dist/cli.mjs
CHANGED
|
@@ -24,8 +24,8 @@ import search from "@inquirer/search";
|
|
|
24
24
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
25
25
|
import ora from "ora";
|
|
26
26
|
import { findRootSync, walkFilesSync } from "@theholocron/fs-utils";
|
|
27
|
-
import { stringifyJson } from "@theholocron/json-utils";
|
|
28
27
|
import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
28
|
+
import { stringifyJson } from "@theholocron/json-utils";
|
|
29
29
|
import { createHash, randomUUID } from "node:crypto";
|
|
30
30
|
import { generateReadme } from "@theholocron/components-doc/markdown";
|
|
31
31
|
import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
|
|
@@ -410,6 +410,8 @@ const COMMAND_CONTEXTS = {
|
|
|
410
410
|
deploy: "workspace",
|
|
411
411
|
"deploy cleanup-preview": "workspace",
|
|
412
412
|
"monitoring check": "workspace",
|
|
413
|
+
"monitoring resolve-checks": "workspace",
|
|
414
|
+
"monitoring run-resolved": "global",
|
|
413
415
|
"repo sync": "workspace",
|
|
414
416
|
"sync github": "workspace"
|
|
415
417
|
};
|
|
@@ -1142,6 +1144,7 @@ async function runCheckAgainstUrl(input, opts = {}) {
|
|
|
1142
1144
|
response = await doFetch(url, {
|
|
1143
1145
|
method,
|
|
1144
1146
|
...hasBody ? { body: input.body } : {},
|
|
1147
|
+
...opts.headers ? { headers: opts.headers } : {},
|
|
1145
1148
|
redirect: "manual",
|
|
1146
1149
|
signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
|
|
1147
1150
|
});
|
|
@@ -1232,6 +1235,42 @@ function evaluate(comparison, actual, target) {
|
|
|
1232
1235
|
}
|
|
1233
1236
|
}
|
|
1234
1237
|
//#endregion
|
|
1238
|
+
//#region src/plugin/verify-vercel-project/verify-vercel-project.ts
|
|
1239
|
+
/**
|
|
1240
|
+
* `true` only when Vercel's API confirms `url` resolves to a deployment
|
|
1241
|
+
* whose `projectId` matches `expectedProjectId` exactly. `false` for any
|
|
1242
|
+
* other outcome — a non-matching project, a non-2xx response, a
|
|
1243
|
+
* malformed body, or a transport failure — never throws, since every one
|
|
1244
|
+
* of those outcomes means the same thing to a caller: don't attach the
|
|
1245
|
+
* credential.
|
|
1246
|
+
*/
|
|
1247
|
+
async function verifyVercelDeploymentProject(url, expectedProjectId, token, opts = {}) {
|
|
1248
|
+
const doFetch = opts.fetch ?? fetch;
|
|
1249
|
+
const baseUrl = opts.baseUrl ?? "https://api.vercel.com";
|
|
1250
|
+
let host;
|
|
1251
|
+
try {
|
|
1252
|
+
host = new URL(url).host;
|
|
1253
|
+
} catch {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
const params = new URLSearchParams({ url: host });
|
|
1257
|
+
if (opts.teamId) params.set("teamId", opts.teamId);
|
|
1258
|
+
let response;
|
|
1259
|
+
try {
|
|
1260
|
+
response = await doFetch(`${baseUrl}/v13/deployments/get?${params}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
1261
|
+
} catch {
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
if (!response.ok) return false;
|
|
1265
|
+
let body;
|
|
1266
|
+
try {
|
|
1267
|
+
body = await response.json();
|
|
1268
|
+
} catch {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
return (typeof body === "object" && body !== null && "projectId" in body ? body.projectId : void 0) === expectedProjectId;
|
|
1272
|
+
}
|
|
1273
|
+
//#endregion
|
|
1235
1274
|
//#region src/plugin/workspace/workspace.ts
|
|
1236
1275
|
/**
|
|
1237
1276
|
* The `workspace`-context guard.
|
|
@@ -2313,16 +2352,57 @@ function mk(capability, provider, status, message) {
|
|
|
2313
2352
|
}
|
|
2314
2353
|
//#endregion
|
|
2315
2354
|
//#region src/commands/monitoring-check/monitoring-check.ts
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2355
|
+
/**
|
|
2356
|
+
* `holocron monitoring check <url>` — runs every configured `monitoring`
|
|
2357
|
+
* check against an arbitrary URL instead of each check's own configured
|
|
2358
|
+
* one (holocron#1010).
|
|
2359
|
+
*
|
|
2360
|
+
* Built for CI: the `delivery.previewCheck` task runs this against a
|
|
2361
|
+
* PR's Vercel preview deployment, reusing the exact same check
|
|
2362
|
+
* definitions already in `holocron.config.ts` for production — no
|
|
2363
|
+
* vendor API call, no separate checks-as-code file to keep in sync.
|
|
2364
|
+
*
|
|
2365
|
+
* Split into two pieces (holocron#1030/#1032 review) so a caller that
|
|
2366
|
+
* needs to attach a credential to the request (e.g. Vercel's Deployment
|
|
2367
|
+
* Protection bypass header) can do so in a process that never loads a
|
|
2368
|
+
* PR's own `holocron.config.ts` at all:
|
|
2369
|
+
* - {@link resolveMonitoringChecks} — loads config/plugins, returns the
|
|
2370
|
+
* configured `MonitoringCheckInput[]`. Untrusted: this executes
|
|
2371
|
+
* whatever code a PR's own config file contains. Never give this
|
|
2372
|
+
* process a credential worth protecting.
|
|
2373
|
+
* - {@link reportChecks} — takes an already-resolved check list (no
|
|
2374
|
+
* config loading at all) and runs/report them against a URL. Safe
|
|
2375
|
+
* to run with a credential present, since nothing here executes
|
|
2376
|
+
* PR-authored code.
|
|
2377
|
+
* `runMonitoringCheck` composes both, unchanged, for every caller that
|
|
2378
|
+
* doesn't need the split (the plain CLI invocation, tests, anywhere a
|
|
2379
|
+
* credential isn't in play).
|
|
2380
|
+
*/
|
|
2381
|
+
/**
|
|
2382
|
+
* Loads config/plugins and returns the configured `monitoring` checks —
|
|
2383
|
+
* the untrusted half of `monitoring check`. Executes a PR's own
|
|
2384
|
+
* `holocron.config.ts` (and whatever it imports); never run this with a
|
|
2385
|
+
* credential in the process environment that a malicious config could
|
|
2386
|
+
* read and exfiltrate.
|
|
2387
|
+
*/
|
|
2388
|
+
async function resolveMonitoringChecks(input) {
|
|
2319
2389
|
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
2320
2390
|
await loader.load();
|
|
2321
2391
|
assertPluginsResolvable(loader, "monitoring check");
|
|
2322
|
-
|
|
2392
|
+
return [...loader.get("monitoring").checks];
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Runs an already-resolved check list against `url` and reports
|
|
2396
|
+
* pass/fail — the trusted half of `monitoring check`. Takes plain data,
|
|
2397
|
+
* never touches `holocron.config.ts` or any plugin: safe to run with a
|
|
2398
|
+
* credential (in `headers` or elsewhere in the process env) present.
|
|
2399
|
+
*/
|
|
2400
|
+
async function reportChecks(input) {
|
|
2401
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
2402
|
+
const logger = input.logger ?? getLogger();
|
|
2323
2403
|
const run = input.runCheck ?? runCheckAgainstUrl;
|
|
2324
2404
|
print(`Holocron monitoring check — against ${input.url}`);
|
|
2325
|
-
if (
|
|
2405
|
+
if (input.checks.length === 0) {
|
|
2326
2406
|
print(`✗ no monitoring checks configured — nothing to validate`);
|
|
2327
2407
|
logger.warn({ url: input.url }, "monitoring check: no checks configured");
|
|
2328
2408
|
return {
|
|
@@ -2331,8 +2411,11 @@ async function runMonitoringCheck(input) {
|
|
|
2331
2411
|
};
|
|
2332
2412
|
}
|
|
2333
2413
|
const checks = [];
|
|
2334
|
-
for (const checkInput of
|
|
2335
|
-
const result = await run(checkInput, {
|
|
2414
|
+
for (const checkInput of input.checks) {
|
|
2415
|
+
const result = await run(checkInput, {
|
|
2416
|
+
url: input.url,
|
|
2417
|
+
...input.headers ? { headers: input.headers } : {}
|
|
2418
|
+
});
|
|
2336
2419
|
if (result.requestError) {
|
|
2337
2420
|
print(` ✗ ${checkInput.name} (request failed: ${result.requestError})`);
|
|
2338
2421
|
checks.push({
|
|
@@ -2372,6 +2455,110 @@ async function runMonitoringCheck(input) {
|
|
|
2372
2455
|
checks
|
|
2373
2456
|
};
|
|
2374
2457
|
}
|
|
2458
|
+
/**
|
|
2459
|
+
* `resolveMonitoringChecks` + `reportChecks` in one call — the plain CLI
|
|
2460
|
+
* contract, unchanged. Only safe to run with a credential present when
|
|
2461
|
+
* the config being loaded is trusted (no PR in the loop); CI's
|
|
2462
|
+
* `delivery.previewCheck` uses the two halves separately instead.
|
|
2463
|
+
*/
|
|
2464
|
+
async function runMonitoringCheck(input) {
|
|
2465
|
+
return reportChecks({
|
|
2466
|
+
checks: await resolveMonitoringChecks({
|
|
2467
|
+
loaded: input.loaded,
|
|
2468
|
+
context: input.context,
|
|
2469
|
+
loader: input.loader
|
|
2470
|
+
}),
|
|
2471
|
+
url: input.url,
|
|
2472
|
+
...input.headers ? { headers: input.headers } : {},
|
|
2473
|
+
...input.print ? { print: input.print } : {},
|
|
2474
|
+
...input.logger ? { logger: input.logger } : {},
|
|
2475
|
+
...input.runCheck ? { runCheck: input.runCheck } : {}
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
//#endregion
|
|
2479
|
+
//#region src/commands/monitoring-resolve-checks/monitoring-resolve-checks.ts
|
|
2480
|
+
/**
|
|
2481
|
+
* `holocron monitoring resolve-checks` — loads config/plugins and writes
|
|
2482
|
+
* the configured `monitoring` checks to a file as JSON (holocron#1030/
|
|
2483
|
+
* #1032 review).
|
|
2484
|
+
*
|
|
2485
|
+
* The untrusted half of the `delivery.previewCheck` split: this process
|
|
2486
|
+
* executes a PR's own `holocron.config.ts`, so it must never run with a
|
|
2487
|
+
* credential worth protecting (e.g. a Deployment Protection bypass
|
|
2488
|
+
* secret) present in its environment. `monitoring run-resolved` is the
|
|
2489
|
+
* trusted half — it reads this command's output file and performs the
|
|
2490
|
+
* actual authenticated request, with no config loading at all.
|
|
2491
|
+
*/
|
|
2492
|
+
async function runMonitoringResolveChecks(input) {
|
|
2493
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
2494
|
+
const checks = await resolveMonitoringChecks({
|
|
2495
|
+
loaded: input.loaded,
|
|
2496
|
+
context: input.context,
|
|
2497
|
+
loader: input.loader
|
|
2498
|
+
});
|
|
2499
|
+
await writeFile(input.outFile, JSON.stringify(checks), "utf8");
|
|
2500
|
+
print(`Resolved ${checks.length} monitoring check(s) → ${input.outFile}`);
|
|
2501
|
+
}
|
|
2502
|
+
//#endregion
|
|
2503
|
+
//#region src/commands/monitoring-run-resolved/monitoring-run-resolved.ts
|
|
2504
|
+
/**
|
|
2505
|
+
* `holocron monitoring run-resolved <url>` — runs an already-resolved
|
|
2506
|
+
* check list (no `holocron.config.ts` loading at all) against `url`
|
|
2507
|
+
* (holocron#1030/#1032 review).
|
|
2508
|
+
*
|
|
2509
|
+
* The trusted half of the `delivery.previewCheck` split, paired with
|
|
2510
|
+
* `monitoring resolve-checks` (the untrusted half, which loads config).
|
|
2511
|
+
* Safe to run with a credential present, since nothing here executes
|
|
2512
|
+
* PR-authored code — but a credential is only ever attached after
|
|
2513
|
+
* `verifyVercelDeploymentProject` confirms `url` actually belongs to
|
|
2514
|
+
* `vercelProject`: a URL merely ending in `.vercel.app` proves nothing,
|
|
2515
|
+
* since anyone can register their own Vercel project (sourcery review
|
|
2516
|
+
* on holocron#1032). No `vercelProject` given at all → the credential
|
|
2517
|
+
* is never attached, regardless of whether one is configured.
|
|
2518
|
+
*/
|
|
2519
|
+
/**
|
|
2520
|
+
* The shared gate every caller that might attach the Deployment
|
|
2521
|
+
* Protection bypass header goes through — `monitoring check`'s direct
|
|
2522
|
+
* CLI invocation included (sourcery review on holocron#1032 called this
|
|
2523
|
+
* out explicitly: verifying only in `delivery.previewCheck`'s own split
|
|
2524
|
+
* commands and not here would leave the exact same unverified-host
|
|
2525
|
+
* problem reachable by hand). No `vercelProject` → never attach
|
|
2526
|
+
* anything, regardless of whether a `bypassSecret` happens to be
|
|
2527
|
+
* configured. `vercelProject` given but verification fails or no token
|
|
2528
|
+
* is available → `undefined` AND a printed reason, never a thrown
|
|
2529
|
+
* error — callers decide for themselves whether that's fatal.
|
|
2530
|
+
*/
|
|
2531
|
+
async function resolveVercelBypassHeaders(input) {
|
|
2532
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
2533
|
+
if (!input.vercelProject) return { verified: true };
|
|
2534
|
+
if (!input.vercelToken) {
|
|
2535
|
+
print(`✗ --vercel-project was given but no Vercel token is available to verify deployment ownership`);
|
|
2536
|
+
return { verified: false };
|
|
2537
|
+
}
|
|
2538
|
+
if (!await (input.verify ?? verifyVercelDeploymentProject)(input.url, input.vercelProject, input.vercelToken, { ...input.vercelTeamId ? { teamId: input.vercelTeamId } : {} })) {
|
|
2539
|
+
print(`✗ ${input.url} does not resolve to a deployment of Vercel project "${input.vercelProject}" — refusing to attach the Deployment Protection bypass credential or run checks against an unverified URL`);
|
|
2540
|
+
return { verified: false };
|
|
2541
|
+
}
|
|
2542
|
+
return input.bypassSecret ? {
|
|
2543
|
+
verified: true,
|
|
2544
|
+
headers: { "x-vercel-protection-bypass": input.bypassSecret }
|
|
2545
|
+
} : { verified: true };
|
|
2546
|
+
}
|
|
2547
|
+
async function runMonitoringRunResolved(input) {
|
|
2548
|
+
const { headers, verified } = await resolveVercelBypassHeaders(input);
|
|
2549
|
+
if (!verified) return {
|
|
2550
|
+
status: "fail",
|
|
2551
|
+
checks: []
|
|
2552
|
+
};
|
|
2553
|
+
return reportChecks({
|
|
2554
|
+
checks: input.checks,
|
|
2555
|
+
url: input.url,
|
|
2556
|
+
...headers ? { headers } : {},
|
|
2557
|
+
...input.print ? { print: input.print } : {},
|
|
2558
|
+
...input.logger ? { logger: input.logger } : {},
|
|
2559
|
+
...input.runCheck ? { runCheck: input.runCheck } : {}
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2375
2562
|
//#endregion
|
|
2376
2563
|
//#region src/commands/new/new.ts
|
|
2377
2564
|
/**
|
|
@@ -10111,12 +10298,22 @@ try {
|
|
|
10111
10298
|
}).command("monitoring", "Run monitoring checks ad hoc, outside the configured provider", (g) => g.command("check <url>", "Run every configured `monitoring` check against a given URL instead of its own configured url", (y) => y.positional("url", {
|
|
10112
10299
|
type: "string",
|
|
10113
10300
|
describe: "URL to test every configured check against (e.g. a PR's preview deployment)"
|
|
10301
|
+
}).option("vercel-project", {
|
|
10302
|
+
type: "string",
|
|
10303
|
+
describe: "Expected Vercel project id/name `url` must resolve to before the Deployment Protection bypass header (VERCEL_PROTECTION_BYPASS_SECRET) is attached. Omitted → the header is never attached, regardless of that env var."
|
|
10114
10304
|
}), async (argv) => {
|
|
10115
10305
|
const tokens = tokenContext(argv.token);
|
|
10116
10306
|
if (!tokens) return;
|
|
10117
10307
|
const [url] = await promptForPositionals(getEntry("monitoring check"), argv);
|
|
10118
10308
|
const loaded = await loadConfig(argv.cwd);
|
|
10119
10309
|
applyResolvedConfig(argv, loaded.resolved);
|
|
10310
|
+
const { headers } = await resolveVercelBypassHeaders({
|
|
10311
|
+
url,
|
|
10312
|
+
...argv.vercelProject ? { vercelProject: argv.vercelProject } : {},
|
|
10313
|
+
...process.env["VERCEL_TOKEN"] ? { vercelToken: process.env["VERCEL_TOKEN"] } : {},
|
|
10314
|
+
...process.env["VERCEL_TEAM_ID"] ? { vercelTeamId: process.env["VERCEL_TEAM_ID"] } : {},
|
|
10315
|
+
...process.env["VERCEL_PROTECTION_BYPASS_SECRET"] ? { bypassSecret: process.env["VERCEL_PROTECTION_BYPASS_SECRET"] } : {}
|
|
10316
|
+
});
|
|
10120
10317
|
if ((await runMonitoringCheck({
|
|
10121
10318
|
loaded,
|
|
10122
10319
|
context: {
|
|
@@ -10125,7 +10322,46 @@ try {
|
|
|
10125
10322
|
...tokens,
|
|
10126
10323
|
org: resolveOrg(argv, loaded.resolved)
|
|
10127
10324
|
},
|
|
10128
|
-
url
|
|
10325
|
+
url,
|
|
10326
|
+
...headers ? { headers } : {}
|
|
10327
|
+
})).status === "fail") process.exitCode = 1;
|
|
10328
|
+
}).command("resolve-checks", "Load config/plugins and write the configured `monitoring` checks to a file as JSON — the untrusted half of the delivery.previewCheck split (holocron#1030/#1032)", (y) => y.option("out", {
|
|
10329
|
+
type: "string",
|
|
10330
|
+
demandOption: true,
|
|
10331
|
+
describe: "File path to write the resolved checks to"
|
|
10332
|
+
}), async (argv) => {
|
|
10333
|
+
const tokens = tokenContext(argv.token);
|
|
10334
|
+
if (!tokens) return;
|
|
10335
|
+
const loaded = await loadConfig(argv.cwd);
|
|
10336
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
10337
|
+
await runMonitoringResolveChecks({
|
|
10338
|
+
loaded,
|
|
10339
|
+
context: {
|
|
10340
|
+
repoRoot: argv.cwd,
|
|
10341
|
+
dryRun: argv.dryRun,
|
|
10342
|
+
...tokens,
|
|
10343
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10344
|
+
},
|
|
10345
|
+
outFile: argv.out
|
|
10346
|
+
});
|
|
10347
|
+
}).command("run-resolved <url>", "Run an already-resolved check list (from `monitoring resolve-checks`) against a URL — no config/plugin loading at all, the trusted half of the delivery.previewCheck split (holocron#1030/#1032)", (y) => y.positional("url", {
|
|
10348
|
+
type: "string",
|
|
10349
|
+
describe: "URL to run every resolved check against"
|
|
10350
|
+
}).option("checks-file", {
|
|
10351
|
+
type: "string",
|
|
10352
|
+
demandOption: true,
|
|
10353
|
+
describe: "File path to read the resolved checks from (written by `monitoring resolve-checks`)"
|
|
10354
|
+
}).option("vercel-project", {
|
|
10355
|
+
type: "string",
|
|
10356
|
+
describe: "Expected Vercel project id/name `url` must resolve to before the Deployment Protection bypass header (VERCEL_PROTECTION_BYPASS_SECRET) is attached. Omitted → the header is never attached."
|
|
10357
|
+
}), async (argv) => {
|
|
10358
|
+
if ((await runMonitoringRunResolved({
|
|
10359
|
+
checks: JSON.parse(readFileSync(argv.checksFile, "utf8")),
|
|
10360
|
+
url: argv.url,
|
|
10361
|
+
...argv.vercelProject ? { vercelProject: argv.vercelProject } : {},
|
|
10362
|
+
...process.env["VERCEL_TOKEN"] ? { vercelToken: process.env["VERCEL_TOKEN"] } : {},
|
|
10363
|
+
...process.env["VERCEL_TEAM_ID"] ? { vercelTeamId: process.env["VERCEL_TEAM_ID"] } : {},
|
|
10364
|
+
...process.env["VERCEL_PROTECTION_BYPASS_SECRET"] ? { bypassSecret: process.env["VERCEL_PROTECTION_BYPASS_SECRET"] } : {}
|
|
10129
10365
|
})).status === "fail") process.exitCode = 1;
|
|
10130
10366
|
}).command("$0", false, () => {}, async (argv) => {
|
|
10131
10367
|
await launchGroupMenu("monitoring", argv);
|