@theholocron/cli 5.0.0-alpha.184 → 5.0.0-alpha.186

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
@@ -26,7 +26,7 @@ import ora from "ora";
26
26
  import { findRootSync, isWorkspaceRoot, walkFilesSync } from "@theholocron/fs-utils";
27
27
  import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
28
28
  import { stringifyJson } from "@theholocron/json-utils";
29
- import { createHash, randomUUID } from "node:crypto";
29
+ import { createHash, randomBytes, 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";
32
32
  import { createGitHubClient } from "@theholocron/github-client";
@@ -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,45 +2530,17 @@ 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
- }
2539
+ case "issues": return mk(key, provider, "ok", describeLifecycle(await impl.doctor()));
2531
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
2545
  case "vault": return mk(key, provider, "ok", `keys: ${(await impl.list()).length} visible`);
2537
2546
  case "deployment": return mk(key, provider, "ok", `projects: ${(await impl.listProjects()).length} visible`);
@@ -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,33 @@ 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
+ /**
3253
+ * Prefix of the throwaway secret the preflight writes and removes. Every run adds a
3254
+ * random suffix, so it can never overwrite (and then delete) a secret someone already
3255
+ * has; layouts may not use any name under this prefix for their own secrets.
3256
+ */
3257
+ const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
3258
+ /** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
3259
+ function isReservedSecret(name) {
3260
+ return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
3261
+ }
3262
+ /**
3263
+ * Prove the secrets store can be WRITTEN before creating anything that needs it.
3264
+ * A webhook's URL is shown once; creating the webhook and then failing to store
3265
+ * it leaves an orphan the user has to delete by hand. Listing is not enough --
3266
+ * a token can read secrets it cannot write -- so write a throwaway secret and
3267
+ * remove it.
3268
+ */
3269
+ async function assertSecretsReachable(secrets, scope) {
3270
+ const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
3271
+ try {
3272
+ await secrets.setSecret(scope, probe, "ok");
3273
+ await secrets.deleteSecret(scope, probe);
3274
+ } catch (err) {
3275
+ const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
3276
+ throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks. If a secret named ${probe} was left behind, delete it`, { cause: err });
3277
+ }
3278
+ }
3235
3279
  /** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
3236
3280
  function whyMissing(loader, key, fallback) {
3237
3281
  const failure = loader.loadFailures().find((f) => f.key === key);
@@ -3259,6 +3303,7 @@ async function provisionChannel(deps, spec) {
3259
3303
  const where = spec.category ? ` in ${spec.category}` : "";
3260
3304
  if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
3261
3305
  if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
3306
+ if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
3262
3307
  if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
3263
3308
  if (dryRun) {
3264
3309
  print(` … would ensure channel ${spec.name}${where}`);
@@ -3290,7 +3335,7 @@ async function provisionChannel(deps, spec) {
3290
3335
  try {
3291
3336
  await secrets.setSecret(scope, secretName, hook.url);
3292
3337
  } 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 });
3338
+ 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
3339
  }
3295
3340
  print(` ✓ ${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`);
3296
3341
  outcome.webhook = {
@@ -3334,6 +3379,7 @@ async function runNotificationsChannelAdd(input) {
3334
3379
  name: org
3335
3380
  } : void 0);
3336
3381
  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");
3382
+ if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
3337
3383
  const channel = await provisionChannel({
3338
3384
  notifications,
3339
3385
  ...secrets ? { secrets } : {},
@@ -3369,11 +3415,11 @@ async function runNotificationsChannelAdd(input) {
3369
3415
  channel
3370
3416
  };
3371
3417
  } catch (err) {
3372
- const message = errorMessage(err);
3418
+ const message = describeProviderError(err);
3373
3419
  print(` ✗ ${message}`);
3374
3420
  logger.warn({
3375
3421
  channel: input.name,
3376
- reason: message
3422
+ reason: logReason(err)
3377
3423
  }, `notifications channel add: ${input.name}`);
3378
3424
  return {
3379
3425
  status: "fail",
@@ -3410,9 +3456,9 @@ async function runNotificationsSetup(input) {
3410
3456
  roles: [],
3411
3457
  failures: []
3412
3458
  };
3413
- const refuse = (message) => {
3459
+ const refuse = (message, reason = message) => {
3414
3460
  print(` ✗ ${message}`);
3415
- logger.warn({ reason: message }, "notifications setup");
3461
+ logger.warn({ reason }, "notifications setup");
3416
3462
  return {
3417
3463
  ...report,
3418
3464
  status: "fail",
@@ -3427,10 +3473,10 @@ async function runNotificationsSetup(input) {
3427
3473
  }
3428
3474
  const layout = notifications.layout;
3429
3475
  if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
3430
- const wantsWebhook = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook));
3476
+ const wantsSecrets = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook)) || (layout.roles ?? []).some((r) => r.secret);
3431
3477
  let secrets;
3432
- if (wantsWebhook && !dryRun) {
3433
- if (!loader.has("secrets")) return refuse(whyMissing(loader, "secrets", "the layout asks for webhooks, which need a `secrets` capability — add a `secrets` provider"));
3478
+ if (wantsSecrets && !dryRun) {
3479
+ if (!loader.has("secrets")) return refuse(whyMissing(loader, "secrets", "the layout stores webhook URLs or role ids, which need a `secrets` capability — add a `secrets` provider"));
3434
3480
  secrets = loader.get("secrets");
3435
3481
  }
3436
3482
  const org = input.context.org;
@@ -3438,21 +3484,36 @@ async function runNotificationsSetup(input) {
3438
3484
  kind: "organization",
3439
3485
  name: org
3440
3486
  } : void 0);
3441
- if (wantsWebhook && !scope && !dryRun) return refuse("no org to store webhook secrets under — pass --scope or set `org` in holocron.config.ts");
3487
+ if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
3488
+ if (secrets && scope && !dryRun) try {
3489
+ await assertSecretsReachable(secrets, scope);
3490
+ } catch (err) {
3491
+ return refuse(errorMessage(err), logReason(err));
3492
+ }
3442
3493
  const taken = /* @__PURE__ */ new Map();
3494
+ const claim = (secret, owner, hint) => {
3495
+ if (isReservedSecret(secret)) return `${owner} would store into ${secret}, a name reserved for the setup preflight — choose another`;
3496
+ const key = secret.toUpperCase();
3497
+ const other = taken.get(key);
3498
+ if (other) return `${other} and ${owner} would both store into ${secret} — ${hint}`;
3499
+ taken.set(key, owner);
3500
+ };
3443
3501
  for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
3444
3502
  if (!channel.webhook) continue;
3445
- const secret = channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name);
3446
- const other = taken.get(secret);
3447
- if (other) return refuse(`channels \`${other}\` and \`${channel.name}\` would both store their webhook in ${secret} — give one a distinct \`webhook.secret\``);
3448
- taken.set(secret, channel.name);
3503
+ const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
3504
+ if (clash) return refuse(clash);
3505
+ }
3506
+ for (const role of layout.roles ?? []) {
3507
+ if (!role.secret) continue;
3508
+ const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
3509
+ if (clash) return refuse(clash);
3449
3510
  }
3450
3511
  print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
3451
3512
  const attempt = async (step, fn) => {
3452
3513
  try {
3453
3514
  await fn();
3454
3515
  } catch (err) {
3455
- const message = errorMessage(err);
3516
+ const message = describeProviderError(err);
3456
3517
  print(` ✗ ${step}: ${message}`);
3457
3518
  report.failures.push({
3458
3519
  step,
@@ -3496,10 +3557,11 @@ async function runNotificationsSetup(input) {
3496
3557
  for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3497
3558
  if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
3498
3559
  if (dryRun) {
3499
- print(` … would ensure role ${role.name}`);
3560
+ print(` … would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`);
3500
3561
  report.roles.push({
3501
3562
  name: role.name,
3502
- created: false
3563
+ created: false,
3564
+ ...role.secret ? { secret: role.secret } : {}
3503
3565
  });
3504
3566
  return;
3505
3567
  }
@@ -3514,6 +3576,12 @@ async function runNotificationsSetup(input) {
3514
3576
  };
3515
3577
  report.roles.push(outcome);
3516
3578
  print(` ${made.created ? "✓ created" : "· exists "} role ${role.name} (id ${made.id})`);
3579
+ if (role.secret) {
3580
+ await secrets.setSecret(scope, role.secret, made.id);
3581
+ outcome.secret = role.secret;
3582
+ outcome.stored = true;
3583
+ print(` ✓ stored the id of ${role.name} in ${role.secret}`);
3584
+ }
3517
3585
  });
3518
3586
  if (report.failures.length > 0) {
3519
3587
  report.status = "fail";