@theholocron/cli 5.0.0-alpha.183 → 5.0.0-alpha.185
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 +88 -47
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
package/dist/cli.mjs
CHANGED
|
@@ -2396,6 +2396,43 @@ async function runDeployFromFiles(input) {
|
|
|
2396
2396
|
}
|
|
2397
2397
|
}
|
|
2398
2398
|
//#endregion
|
|
2399
|
+
//#region src/ui/provider-error/provider-error.ts
|
|
2400
|
+
/** Vendor response bodies land in the terminal — cap their length so one
|
|
2401
|
+
* oversized error can't flood the report, and strip control characters
|
|
2402
|
+
* (newlines and the C1 range, whose 0x9b is an 8-bit CSI) so a crafted body
|
|
2403
|
+
* can't forge extra report lines or inject terminal escape sequences. */
|
|
2404
|
+
const MAX_DETAIL_LENGTH = 200;
|
|
2405
|
+
function sanitizeDetail(text) {
|
|
2406
|
+
const stripped = text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
|
|
2407
|
+
return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* `errorMessage(err)` alone is just `"<Vendor> <METHOD> <path> → <status>"` —
|
|
2411
|
+
* the actual reason (what the vendor's response body said) lives in
|
|
2412
|
+
* `ProviderApiError.details`, a raw response-body string (not parsed JSON —
|
|
2413
|
+
* see AGENTS.md), and was being silently dropped. Appending it (sanitized
|
|
2414
|
+
* and length-capped — see `sanitizeDetail`) turns e.g. a bare "→ 403" into
|
|
2415
|
+
* something that actually explains itself, without the vendor text being
|
|
2416
|
+
* able to overwhelm or manipulate the terminal.
|
|
2417
|
+
*/
|
|
2418
|
+
function describeProviderError(err) {
|
|
2419
|
+
if (err instanceof ProviderApiError$1 && err.details) {
|
|
2420
|
+
const detail = sanitizeDetail(String(err.details));
|
|
2421
|
+
if (detail) return `${errorMessage(err)}: ${detail}`;
|
|
2422
|
+
}
|
|
2423
|
+
return errorMessage(err);
|
|
2424
|
+
}
|
|
2425
|
+
/**
|
|
2426
|
+
* What to put in structured logs: the plain message of the underlying error,
|
|
2427
|
+
* with no response body. The detail {@link describeProviderError} adds is for the
|
|
2428
|
+
* person at the terminal; a provider's raw body has no business in telemetry.
|
|
2429
|
+
* Wrapper errors carry the original as `cause`, so look through one level.
|
|
2430
|
+
*/
|
|
2431
|
+
function logReason(err) {
|
|
2432
|
+
const root = err instanceof Error && err.cause !== void 0 ? err.cause : err;
|
|
2433
|
+
return errorMessage(root);
|
|
2434
|
+
}
|
|
2435
|
+
//#endregion
|
|
2399
2436
|
//#region src/commands/doctor/doctor.ts
|
|
2400
2437
|
/**
|
|
2401
2438
|
* `holocron doctor` — load the config, instantiate every capability,
|
|
@@ -2493,54 +2530,26 @@ async function smokeCheck(key, provider, impl) {
|
|
|
2493
2530
|
try {
|
|
2494
2531
|
return await withTimeout(runSmokeCheck(key, provider, impl), SMOKE_CHECK_TIMEOUT_MS);
|
|
2495
2532
|
} catch (err) {
|
|
2496
|
-
return mk(key, provider, "fail",
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
/** Vendor response bodies land in the terminal — cap their length so one
|
|
2500
|
-
* oversized error can't flood the report, and strip control characters
|
|
2501
|
-
* (newlines and the C1 range, whose 0x9b is an 8-bit CSI) so a crafted body
|
|
2502
|
-
* can't forge extra report lines or inject terminal escape sequences. */
|
|
2503
|
-
const MAX_DETAIL_LENGTH = 200;
|
|
2504
|
-
function sanitizeDetail(text) {
|
|
2505
|
-
const stripped = text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
|
|
2506
|
-
return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
|
|
2507
|
-
}
|
|
2508
|
-
/**
|
|
2509
|
-
* `errorMessage(err)` alone is just `"<Vendor> <METHOD> <path> → <status>"` —
|
|
2510
|
-
* the actual reason (what the vendor's response body said) lives in
|
|
2511
|
-
* `ProviderApiError.details`, a raw response-body string (not parsed JSON —
|
|
2512
|
-
* see AGENTS.md), and was being silently dropped. Appending it (sanitized
|
|
2513
|
-
* and length-capped — see `sanitizeDetail`) turns e.g. a bare "→ 400" into
|
|
2514
|
-
* something that actually explains itself, without the vendor text being
|
|
2515
|
-
* able to overwhelm or manipulate the terminal report.
|
|
2516
|
-
*/
|
|
2517
|
-
function smokeCheckErrorMessage(err) {
|
|
2518
|
-
if (err instanceof ProviderApiError$1 && err.details) {
|
|
2519
|
-
const detail = sanitizeDetail(String(err.details));
|
|
2520
|
-
if (detail) return `${errorMessage(err)}: ${detail}`;
|
|
2533
|
+
return mk(key, provider, "fail", describeProviderError(err), errorMessage(err));
|
|
2521
2534
|
}
|
|
2522
|
-
return errorMessage(err);
|
|
2523
2535
|
}
|
|
2524
2536
|
async function runSmokeCheck(key, provider, impl) {
|
|
2525
2537
|
switch (key) {
|
|
2526
2538
|
case "source": return mk(key, provider, "ok", `whoami: ${(await impl.whoami()).login}`);
|
|
2527
|
-
case "issues":
|
|
2528
|
-
|
|
2529
|
-
return mk(key, provider, "ok", `${report.authedAs} / ${report.projectLabel} (${countResolvedLifecycle(report)})`);
|
|
2530
|
-
}
|
|
2531
|
-
case "secrets": return mk(key, provider, "ok", `repo secrets: ${(await impl.listSecrets({ kind: "repo" })).length} configured`);
|
|
2539
|
+
case "issues": return mk(key, provider, "ok", describeLifecycle(await impl.doctor()));
|
|
2540
|
+
case "secrets": return mk(key, provider, "ok", `secrets: ${(await impl.listSecrets({ kind: "repo" })).length} configured`);
|
|
2532
2541
|
case "ci": {
|
|
2533
2542
|
const runs = await impl.listRuns({ limit: 1 });
|
|
2534
|
-
return mk(key, provider, "ok", runs.length === 0 ? "
|
|
2543
|
+
return mk(key, provider, "ok", runs.length === 0 ? "runs: none recent" : `most recent: ${runs[0].workflowName} (${runs[0].status})`);
|
|
2535
2544
|
}
|
|
2536
|
-
case "vault": return mk(key, provider, "ok",
|
|
2537
|
-
case "deployment": return mk(key, provider, "ok",
|
|
2538
|
-
case "environments": return mk(key, provider, "ok",
|
|
2545
|
+
case "vault": return mk(key, provider, "ok", `keys: ${(await impl.list()).length} visible`);
|
|
2546
|
+
case "deployment": return mk(key, provider, "ok", `projects: ${(await impl.listProjects()).length} visible`);
|
|
2547
|
+
case "environments": return mk(key, provider, "ok", `environments: ${(await impl.listEnvironments()).length} configured`);
|
|
2539
2548
|
case "dns": {
|
|
2540
2549
|
const dns = impl;
|
|
2541
2550
|
if (dns.whoami) {
|
|
2542
2551
|
const who = await dns.whoami();
|
|
2543
|
-
return mk(key, provider, who.ok ? "ok" : "fail",
|
|
2552
|
+
return mk(key, provider, who.ok ? "ok" : "fail", `zones: ${who.zones} visible`);
|
|
2544
2553
|
}
|
|
2545
2554
|
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2546
2555
|
}
|
|
@@ -2548,7 +2557,7 @@ async function runSmokeCheck(key, provider, impl) {
|
|
|
2548
2557
|
const workers = impl;
|
|
2549
2558
|
if (workers.whoami) {
|
|
2550
2559
|
const who = await workers.whoami();
|
|
2551
|
-
return mk(key, provider, who.ok ? "ok" : "fail",
|
|
2560
|
+
return mk(key, provider, who.ok ? "ok" : "fail", `scripts: ${who.scripts} visible`);
|
|
2552
2561
|
}
|
|
2553
2562
|
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2554
2563
|
}
|
|
@@ -2577,7 +2586,7 @@ async function runSmokeCheck(key, provider, impl) {
|
|
|
2577
2586
|
const monitoring = impl;
|
|
2578
2587
|
if (monitoring.whoami) {
|
|
2579
2588
|
const who = await monitoring.whoami();
|
|
2580
|
-
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token verified" : "token check failed");
|
|
2589
|
+
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token: verified" : "token: check failed");
|
|
2581
2590
|
}
|
|
2582
2591
|
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2583
2592
|
}
|
|
@@ -2591,15 +2600,23 @@ async function runSmokeCheck(key, provider, impl) {
|
|
|
2591
2600
|
const wiki = impl;
|
|
2592
2601
|
if (wiki.whoami) {
|
|
2593
2602
|
const who = await wiki.whoami();
|
|
2594
|
-
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token configured" : "token not configured");
|
|
2603
|
+
return mk(key, provider, who.ok ? "ok" : "fail", who.ok ? "token: configured" : "token: not configured");
|
|
2595
2604
|
}
|
|
2596
2605
|
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2597
2606
|
}
|
|
2598
2607
|
default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2599
2608
|
}
|
|
2600
2609
|
}
|
|
2601
|
-
|
|
2602
|
-
|
|
2610
|
+
/**
|
|
2611
|
+
* What the issues row says: how many lifecycle slots (inProgress, inReview, done)
|
|
2612
|
+
* map to something the tracker recognises, and which do not. The token's user and
|
|
2613
|
+
* the repo are not repeated -- `source` already names the account, and a slot
|
|
2614
|
+
* that is not configured is the part someone has to act on.
|
|
2615
|
+
*/
|
|
2616
|
+
function describeLifecycle(report) {
|
|
2617
|
+
const unresolved = report.lifecycle.filter((l) => !l.resolved).map((l) => l.slot);
|
|
2618
|
+
const base = `lifecycle: ${report.lifecycle.length - unresolved.length}/${report.lifecycle.length} slots ready`;
|
|
2619
|
+
return unresolved.length > 0 ? `${base} (unresolved: ${unresolved.join(", ")})` : base;
|
|
2603
2620
|
}
|
|
2604
2621
|
function mk(capability, provider, status, message, logMessage) {
|
|
2605
2622
|
return {
|
|
@@ -3232,6 +3249,24 @@ async function runNew(input) {
|
|
|
3232
3249
|
function defaultSecretName(channel) {
|
|
3233
3250
|
return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
3234
3251
|
}
|
|
3252
|
+
/** A throwaway secret, written and removed to prove write access. */
|
|
3253
|
+
const PREFLIGHT_SECRET = "HOLOCRON_NOTIFY_PREFLIGHT";
|
|
3254
|
+
/**
|
|
3255
|
+
* Prove the secrets store can be WRITTEN before creating anything that needs it.
|
|
3256
|
+
* A webhook's URL is shown once; creating the webhook and then failing to store
|
|
3257
|
+
* it leaves an orphan the user has to delete by hand. Listing is not enough --
|
|
3258
|
+
* a token can read secrets it cannot write -- so write a throwaway secret and
|
|
3259
|
+
* remove it.
|
|
3260
|
+
*/
|
|
3261
|
+
async function assertSecretsReachable(secrets, scope) {
|
|
3262
|
+
try {
|
|
3263
|
+
await secrets.setSecret(scope, PREFLIGHT_SECRET, "ok");
|
|
3264
|
+
await secrets.deleteSecret(scope, PREFLIGHT_SECRET);
|
|
3265
|
+
} catch (err) {
|
|
3266
|
+
const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
|
|
3267
|
+
throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks`, { cause: err });
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3235
3270
|
/** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
|
|
3236
3271
|
function whyMissing(loader, key, fallback) {
|
|
3237
3272
|
const failure = loader.loadFailures().find((f) => f.key === key);
|
|
@@ -3290,7 +3325,7 @@ async function provisionChannel(deps, spec) {
|
|
|
3290
3325
|
try {
|
|
3291
3326
|
await secrets.setSecret(scope, secretName, hook.url);
|
|
3292
3327
|
} catch (err) {
|
|
3293
|
-
throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${
|
|
3328
|
+
throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${describeProviderError(err)}) — delete the webhook in the provider and re-run to get a fresh URL`, { cause: err });
|
|
3294
3329
|
}
|
|
3295
3330
|
print(` ✓ ${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`);
|
|
3296
3331
|
outcome.webhook = {
|
|
@@ -3334,6 +3369,7 @@ async function runNotificationsChannelAdd(input) {
|
|
|
3334
3369
|
name: org
|
|
3335
3370
|
} : void 0);
|
|
3336
3371
|
if (input.withWebhook && !scope && !dryRun) throw new Error("no org to store the webhook secret under — pass --scope or set `org` in holocron.config.ts");
|
|
3372
|
+
if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
|
|
3337
3373
|
const channel = await provisionChannel({
|
|
3338
3374
|
notifications,
|
|
3339
3375
|
...secrets ? { secrets } : {},
|
|
@@ -3369,11 +3405,11 @@ async function runNotificationsChannelAdd(input) {
|
|
|
3369
3405
|
channel
|
|
3370
3406
|
};
|
|
3371
3407
|
} catch (err) {
|
|
3372
|
-
const message =
|
|
3408
|
+
const message = describeProviderError(err);
|
|
3373
3409
|
print(` ✗ ${message}`);
|
|
3374
3410
|
logger.warn({
|
|
3375
3411
|
channel: input.name,
|
|
3376
|
-
reason:
|
|
3412
|
+
reason: logReason(err)
|
|
3377
3413
|
}, `notifications channel add: ${input.name}`);
|
|
3378
3414
|
return {
|
|
3379
3415
|
status: "fail",
|
|
@@ -3410,9 +3446,9 @@ async function runNotificationsSetup(input) {
|
|
|
3410
3446
|
roles: [],
|
|
3411
3447
|
failures: []
|
|
3412
3448
|
};
|
|
3413
|
-
const refuse = (message) => {
|
|
3449
|
+
const refuse = (message, reason = message) => {
|
|
3414
3450
|
print(` ✗ ${message}`);
|
|
3415
|
-
logger.warn({ reason
|
|
3451
|
+
logger.warn({ reason }, "notifications setup");
|
|
3416
3452
|
return {
|
|
3417
3453
|
...report,
|
|
3418
3454
|
status: "fail",
|
|
@@ -3439,6 +3475,11 @@ async function runNotificationsSetup(input) {
|
|
|
3439
3475
|
name: org
|
|
3440
3476
|
} : void 0);
|
|
3441
3477
|
if (wantsWebhook && !scope && !dryRun) return refuse("no org to store webhook secrets under — pass --scope or set `org` in holocron.config.ts");
|
|
3478
|
+
if (secrets && scope && !dryRun) try {
|
|
3479
|
+
await assertSecretsReachable(secrets, scope);
|
|
3480
|
+
} catch (err) {
|
|
3481
|
+
return refuse(errorMessage(err), logReason(err));
|
|
3482
|
+
}
|
|
3442
3483
|
const taken = /* @__PURE__ */ new Map();
|
|
3443
3484
|
for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
|
|
3444
3485
|
if (!channel.webhook) continue;
|
|
@@ -3452,7 +3493,7 @@ async function runNotificationsSetup(input) {
|
|
|
3452
3493
|
try {
|
|
3453
3494
|
await fn();
|
|
3454
3495
|
} catch (err) {
|
|
3455
|
-
const message =
|
|
3496
|
+
const message = describeProviderError(err);
|
|
3456
3497
|
print(` ✗ ${step}: ${message}`);
|
|
3457
3498
|
report.failures.push({
|
|
3458
3499
|
step,
|