@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 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", smokeCheckErrorMessage(err), errorMessage(err));
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
- const report = await impl.doctor();
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 ? "no recent runs" : `most recent: ${runs[0].workflowName} (${runs[0].status})`);
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", `${(await impl.list()).length} keys available`);
2537
- case "deployment": return mk(key, provider, "ok", `${(await impl.listProjects()).length} project(s) visible`);
2538
- case "environments": return mk(key, provider, "ok", `${(await impl.listEnvironments()).length} environment(s) configured`);
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", `${who.zones} zone(s) visible`);
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", `${who.scripts} script(s) visible`);
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
- function countResolvedLifecycle(report) {
2602
- return `${report.lifecycle.filter((l) => l.resolved).length}/${report.lifecycle.length} lifecycle slots ready`;
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 (${errorMessage(err)}) — delete the webhook in the provider and re-run to get a fresh URL`, { cause: err });
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 = errorMessage(err);
3408
+ const message = describeProviderError(err);
3373
3409
  print(` ✗ ${message}`);
3374
3410
  logger.warn({
3375
3411
  channel: input.name,
3376
- reason: message
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: message }, "notifications setup");
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 = errorMessage(err);
3496
+ const message = describeProviderError(err);
3456
3497
  print(` ✗ ${step}: ${message}`);
3457
3498
  report.failures.push({
3458
3499
  step,