@theholocron/cli 5.0.0-alpha.185 → 5.0.0-alpha.187

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";
@@ -409,6 +409,7 @@ const COMMAND_CONTEXTS = {
409
409
  "secrets sync": "workspace",
410
410
  "notifications channel add": "workspace",
411
411
  "notifications setup": "workspace",
412
+ "notifications verify": "workspace",
412
413
  deploy: "workspace",
413
414
  "deploy cleanup-preview": "workspace",
414
415
  "monitoring check": "workspace",
@@ -2402,8 +2403,12 @@ async function runDeployFromFiles(input) {
2402
2403
  * (newlines and the C1 range, whose 0x9b is an 8-bit CSI) so a crafted body
2403
2404
  * can't forge extra report lines or inject terminal escape sequences. */
2404
2405
  const MAX_DETAIL_LENGTH = 200;
2406
+ /** Control characters (newlines and the C1 range included) turned into spaces, whitespace collapsed, no length cap. */
2407
+ function stripControl(text) {
2408
+ return text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
2409
+ }
2405
2410
  function sanitizeDetail(text) {
2406
- const stripped = text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
2411
+ const stripped = stripControl(text);
2407
2412
  return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
2408
2413
  }
2409
2414
  /**
@@ -2416,11 +2421,12 @@ function sanitizeDetail(text) {
2416
2421
  * able to overwhelm or manipulate the terminal.
2417
2422
  */
2418
2423
  function describeProviderError(err) {
2424
+ const base = stripControl(errorMessage(err));
2419
2425
  if (err instanceof ProviderApiError$1 && err.details) {
2420
2426
  const detail = sanitizeDetail(String(err.details));
2421
- if (detail) return `${errorMessage(err)}: ${detail}`;
2427
+ if (detail) return `${base}: ${detail}`;
2422
2428
  }
2423
- return errorMessage(err);
2429
+ return base;
2424
2430
  }
2425
2431
  /**
2426
2432
  * What to put in structured logs: the plain message of the underlying error,
@@ -3249,8 +3255,16 @@ async function runNew(input) {
3249
3255
  function defaultSecretName(channel) {
3250
3256
  return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
3251
3257
  }
3252
- /** A throwaway secret, written and removed to prove write access. */
3253
- const PREFLIGHT_SECRET = "HOLOCRON_NOTIFY_PREFLIGHT";
3258
+ /**
3259
+ * Prefix of the throwaway secret the preflight writes and removes. Every run adds a
3260
+ * random suffix, so it can never overwrite (and then delete) a secret someone already
3261
+ * has; layouts may not use any name under this prefix for their own secrets.
3262
+ */
3263
+ const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
3264
+ /** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
3265
+ function isReservedSecret(name) {
3266
+ return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
3267
+ }
3254
3268
  /**
3255
3269
  * Prove the secrets store can be WRITTEN before creating anything that needs it.
3256
3270
  * A webhook's URL is shown once; creating the webhook and then failing to store
@@ -3259,32 +3273,78 @@ const PREFLIGHT_SECRET = "HOLOCRON_NOTIFY_PREFLIGHT";
3259
3273
  * remove it.
3260
3274
  */
3261
3275
  async function assertSecretsReachable(secrets, scope) {
3276
+ const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
3262
3277
  try {
3263
- await secrets.setSecret(scope, PREFLIGHT_SECRET, "ok");
3264
- await secrets.deleteSecret(scope, PREFLIGHT_SECRET);
3278
+ await secrets.setSecret(scope, probe, "ok");
3279
+ await secrets.deleteSecret(scope, probe);
3265
3280
  } catch (err) {
3266
3281
  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 });
3282
+ 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 });
3268
3283
  }
3269
3284
  }
3270
3285
  /** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
3271
3286
  function whyMissing(loader, key, fallback) {
3272
3287
  const failure = loader.loadFailures().find((f) => f.key === key);
3273
- return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${failure.error.message})` : fallback;
3288
+ return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${describeProviderError(failure.error)})` : fallback;
3274
3289
  }
3275
- /** Pick the provider that can provision; with several, `--provider` decides. */
3276
- function pickProvisioner(providers, wanted) {
3277
- const capable = providers.filter((p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function");
3278
- if (capable.length === 0) throw new Error("no `notifications` provider can provision — add one that implements it (for example discord) to holocron.config.ts");
3290
+ /** The configured `notifications` providers, or an error that says why there are none (including a plugin that failed to load). */
3291
+ function notificationProviders(loader) {
3292
+ const providers = loader.has("notifications") ? loader.get("notifications") : [];
3293
+ if (providers.length === 0) throw new Error(whyMissing(loader, "notifications", "no `notifications` provider is available — add one to holocron.config.ts"));
3294
+ return providers;
3295
+ }
3296
+ /**
3297
+ * Pick the provider that can do what the command needs; with several, `--provider`
3298
+ * decides. `can` says what "able" means (a command that only reads needs `inspect`, not
3299
+ * provisioning), and `verb` words the errors.
3300
+ */
3301
+ function pickProvider(providers, wanted, can, verb) {
3302
+ const capable = providers.filter(can);
3303
+ if (capable.length === 0) throw new Error(`no \`notifications\` provider can ${verb.infinitive} — add one that implements it (for example discord) to holocron.config.ts`);
3279
3304
  if (wanted) {
3280
3305
  const match = capable.find((p) => p.providerName === wanted);
3281
- if (!match) throw new Error(`no provisioning \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
3306
+ if (!match) throw new Error(`no ${verb.gerund} \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
3282
3307
  return match;
3283
3308
  }
3284
- if (capable.length > 1) throw new Error(`several \`notifications\` providers can provision (${names(capable)}) — pass --provider <name>`);
3309
+ if (capable.length > 1) throw new Error(`several \`notifications\` providers can ${verb.infinitive} (${names(capable)}) — pass --provider <name>`);
3285
3310
  return capable[0];
3286
3311
  }
3312
+ /** Pick the provider that can provision. Any ensure* method counts: a role-only layout needs no channel support. */
3313
+ function pickProvisioner(providers, wanted) {
3314
+ return pickProvider(providers, wanted, (p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function", {
3315
+ infinitive: "provision",
3316
+ gerund: "provisioning"
3317
+ });
3318
+ }
3287
3319
  const names = (ps) => ps.map((p) => p.providerName).join(", ");
3320
+ /**
3321
+ * The first reason the layout's secret names cannot work, or undefined. Two things
3322
+ * cannot share one secret: the second write would overwrite the first and callers
3323
+ * would read the wrong value. Punctuation normalising (release-notes vs
3324
+ * release_notes) makes that easy to hit by accident, GitHub secret names are
3325
+ * case-insensitive, and the preflight's own prefix is reserved. Shared by `setup`
3326
+ * (which refuses) and `verify` (which reports it).
3327
+ */
3328
+ function layoutSecretProblem(layout) {
3329
+ const taken = /* @__PURE__ */ new Map();
3330
+ const claim = (secret, owner, hint) => {
3331
+ if (isReservedSecret(secret)) return `${owner} would store into ${secret}, a name reserved for the setup preflight — choose another`;
3332
+ const key = secret.toUpperCase();
3333
+ const other = taken.get(key);
3334
+ if (other) return `${other} and ${owner} would both store into ${secret} — ${hint}`;
3335
+ taken.set(key, owner);
3336
+ };
3337
+ for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
3338
+ if (!channel.webhook) continue;
3339
+ const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
3340
+ if (clash) return clash;
3341
+ }
3342
+ for (const role of layout.roles ?? []) {
3343
+ if (!role.secret) continue;
3344
+ const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
3345
+ if (clash) return clash;
3346
+ }
3347
+ }
3288
3348
  /** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
3289
3349
  async function provisionChannel(deps, spec) {
3290
3350
  const { notifications, secrets, scope, dryRun, print } = deps;
@@ -3294,6 +3354,7 @@ async function provisionChannel(deps, spec) {
3294
3354
  const where = spec.category ? ` in ${spec.category}` : "";
3295
3355
  if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
3296
3356
  if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
3357
+ if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
3297
3358
  if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
3298
3359
  if (dryRun) {
3299
3360
  print(` … would ensure channel ${spec.name}${where}`);
@@ -3357,7 +3418,7 @@ async function runNotificationsChannelAdd(input) {
3357
3418
  const dryRun = input.context.dryRun ?? false;
3358
3419
  print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
3359
3420
  try {
3360
- const notifications = pickProvisioner(loader.has("notifications") ? loader.get("notifications") : [], input.provider);
3421
+ const notifications = pickProvisioner(notificationProviders(loader), input.provider);
3361
3422
  let secrets;
3362
3423
  if (input.withWebhook && !dryRun) {
3363
3424
  if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
@@ -3457,16 +3518,16 @@ async function runNotificationsSetup(input) {
3457
3518
  };
3458
3519
  let notifications;
3459
3520
  try {
3460
- notifications = pickProvisioner(loader.has("notifications") ? loader.get("notifications") : [], input.provider);
3521
+ notifications = pickProvisioner(notificationProviders(loader), input.provider);
3461
3522
  } catch (err) {
3462
3523
  return refuse(errorMessage(err));
3463
3524
  }
3464
3525
  const layout = notifications.layout;
3465
3526
  if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
3466
- const wantsWebhook = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook));
3527
+ const wantsSecrets = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook)) || (layout.roles ?? []).some((r) => r.secret);
3467
3528
  let secrets;
3468
- if (wantsWebhook && !dryRun) {
3469
- if (!loader.has("secrets")) return refuse(whyMissing(loader, "secrets", "the layout asks for webhooks, which need a `secrets` capability — add a `secrets` provider"));
3529
+ if (wantsSecrets && !dryRun) {
3530
+ 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"));
3470
3531
  secrets = loader.get("secrets");
3471
3532
  }
3472
3533
  const org = input.context.org;
@@ -3474,20 +3535,14 @@ async function runNotificationsSetup(input) {
3474
3535
  kind: "organization",
3475
3536
  name: org
3476
3537
  } : void 0);
3477
- if (wantsWebhook && !scope && !dryRun) return refuse("no org to store webhook secrets under — pass --scope or set `org` in holocron.config.ts");
3538
+ if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
3478
3539
  if (secrets && scope && !dryRun) try {
3479
3540
  await assertSecretsReachable(secrets, scope);
3480
3541
  } catch (err) {
3481
3542
  return refuse(errorMessage(err), logReason(err));
3482
3543
  }
3483
- const taken = /* @__PURE__ */ new Map();
3484
- for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
3485
- if (!channel.webhook) continue;
3486
- const secret = channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name);
3487
- const other = taken.get(secret);
3488
- if (other) return refuse(`channels \`${other}\` and \`${channel.name}\` would both store their webhook in ${secret} — give one a distinct \`webhook.secret\``);
3489
- taken.set(secret, channel.name);
3490
- }
3544
+ const secretProblem = layoutSecretProblem(layout);
3545
+ if (secretProblem) return refuse(secretProblem);
3491
3546
  print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
3492
3547
  const attempt = async (step, fn) => {
3493
3548
  try {
@@ -3537,10 +3592,11 @@ async function runNotificationsSetup(input) {
3537
3592
  for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3538
3593
  if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
3539
3594
  if (dryRun) {
3540
- print(` … would ensure role ${role.name}`);
3595
+ print(` … would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`);
3541
3596
  report.roles.push({
3542
3597
  name: role.name,
3543
- created: false
3598
+ created: false,
3599
+ ...role.secret ? { secret: role.secret } : {}
3544
3600
  });
3545
3601
  return;
3546
3602
  }
@@ -3555,6 +3611,12 @@ async function runNotificationsSetup(input) {
3555
3611
  };
3556
3612
  report.roles.push(outcome);
3557
3613
  print(` ${made.created ? "✓ created" : "· exists "} role ${role.name} (id ${made.id})`);
3614
+ if (role.secret) {
3615
+ await secrets.setSecret(scope, role.secret, made.id);
3616
+ outcome.secret = role.secret;
3617
+ outcome.stored = true;
3618
+ print(` ✓ stored the id of ${role.name} in ${role.secret}`);
3619
+ }
3558
3620
  });
3559
3621
  if (report.failures.length > 0) {
3560
3622
  report.status = "fail";
@@ -3570,6 +3632,270 @@ async function runNotificationsSetup(input) {
3570
3632
  return report;
3571
3633
  }
3572
3634
  //#endregion
3635
+ //#region src/commands/notifications-verify/notifications-verify.ts
3636
+ /**
3637
+ * `holocron notifications verify` -- read-only: compare the layout a provider
3638
+ * declares in `holocron.config.ts` with what is actually on the server and in the
3639
+ * secrets store, step by step.
3640
+ *
3641
+ * It is the end-to-end check for `notifications setup`, and stays useful after
3642
+ * as a drift check. Everything the layout and `setup` cannot do yet is listed
3643
+ * too, as a failing "not built" step, so the command is red until the last
3644
+ * feature lands and each feature built turns exactly one step green. The list
3645
+ * is {@link PLANNED_CHECKS}; the numbers match the table in holocron#1098.
3646
+ */
3647
+ /** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
3648
+ const PLANNED_CHECKS = [
3649
+ {
3650
+ step: 8,
3651
+ id: "permissions",
3652
+ title: "channel and category permissions"
3653
+ },
3654
+ {
3655
+ step: 9,
3656
+ id: "role-appearance",
3657
+ title: "role colour, hoist and permissions"
3658
+ },
3659
+ {
3660
+ step: 10,
3661
+ id: "server-settings",
3662
+ title: "server settings (verification, notifications, system channels)"
3663
+ },
3664
+ {
3665
+ step: 11,
3666
+ id: "welcome",
3667
+ title: "welcome and onboarding content"
3668
+ },
3669
+ {
3670
+ step: 12,
3671
+ id: "webhook-appearance",
3672
+ title: "webhook names and avatars"
3673
+ },
3674
+ {
3675
+ step: 13,
3676
+ id: "app-settings",
3677
+ title: "bot application settings (description, tags, install, links)"
3678
+ },
3679
+ {
3680
+ step: 14,
3681
+ id: "role-assignment",
3682
+ title: "role assigned to team members"
3683
+ },
3684
+ {
3685
+ step: 15,
3686
+ id: "post-works",
3687
+ title: "a test post reached every channel (platform.notifyCheck)"
3688
+ },
3689
+ {
3690
+ step: 16,
3691
+ id: "server-creation",
3692
+ title: "the server itself created from config"
3693
+ }
3694
+ ];
3695
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
3696
+ const shown = (s) => sanitizeDetail(s);
3697
+ async function runNotificationsVerify(input) {
3698
+ const print = input.print ?? ((line) => console.log(line));
3699
+ const logger = input.logger ?? getLogger();
3700
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3701
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3702
+ await loader.load();
3703
+ assertPluginsResolvable(loader, "notifications verify");
3704
+ const checks = [];
3705
+ const add = (id, title, problems, pass) => {
3706
+ checks.push({
3707
+ id,
3708
+ title,
3709
+ status: problems.length === 0 ? "pass" : "fail",
3710
+ detail: problems.length === 0 ? pass : `${problems.length} problem(s)`,
3711
+ problems
3712
+ });
3713
+ };
3714
+ const byId = (id) => checks.find((c) => c.id === id);
3715
+ const failed = (id, title, reason) => {
3716
+ checks.push({
3717
+ id,
3718
+ title,
3719
+ status: "fail",
3720
+ detail: reason,
3721
+ problems: []
3722
+ });
3723
+ };
3724
+ const summarise = () => ({
3725
+ pass: checks.filter((c) => c.status === "pass").length,
3726
+ fail: checks.filter((c) => c.status === "fail").length,
3727
+ planned: checks.filter((c) => c.status === "planned").length
3728
+ });
3729
+ const finish = () => {
3730
+ const summary = summarise();
3731
+ const report = {
3732
+ status: summary.fail + summary.planned > 0 ? "fail" : "ok",
3733
+ checks,
3734
+ summary
3735
+ };
3736
+ logger.info({
3737
+ ...summary,
3738
+ status: report.status,
3739
+ checks: checks.map((c) => `${c.id}:${c.status}`)
3740
+ }, "notifications verify");
3741
+ return report;
3742
+ };
3743
+ const refuse = (message, reason = message) => {
3744
+ print(` ✗ ${message}`);
3745
+ logger.warn({ reason }, "notifications verify");
3746
+ return {
3747
+ status: "fail",
3748
+ checks,
3749
+ summary: summarise(),
3750
+ message
3751
+ };
3752
+ };
3753
+ let notifications;
3754
+ try {
3755
+ notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.inspect === "function", {
3756
+ infinitive: "inspect a server",
3757
+ gerund: "inspecting"
3758
+ });
3759
+ } catch (err) {
3760
+ return refuse(describeProviderError(err));
3761
+ }
3762
+ const layout = notifications.layout;
3763
+ if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
3764
+ print(`Holocron notifications verify — ${notifications.providerName}\n`);
3765
+ let found;
3766
+ let readError = "";
3767
+ try {
3768
+ found = await notifications.inspect(layout);
3769
+ } catch (err) {
3770
+ readError = describeProviderError(err);
3771
+ }
3772
+ if (notifications.whoami) try {
3773
+ const who = await notifications.whoami();
3774
+ if (who) checks.push({
3775
+ id: "server",
3776
+ title: "server",
3777
+ status: "pass",
3778
+ detail: shown(who.target),
3779
+ problems: []
3780
+ });
3781
+ else failed("server", "server", "no server configured (the provider's whoami has nothing to check)");
3782
+ } catch (err) {
3783
+ failed("server", "server", describeProviderError(err));
3784
+ }
3785
+ else if (found) checks.push({
3786
+ id: "server",
3787
+ title: "server",
3788
+ status: "pass",
3789
+ detail: "readable (the provider has no whoami)",
3790
+ problems: []
3791
+ });
3792
+ else failed("server", "server", readError);
3793
+ const declaredChannels = (layout.categories ?? []).flatMap((cat) => (cat.channels ?? []).map((channel) => ({
3794
+ cat: cat.name,
3795
+ channel
3796
+ })));
3797
+ const pick = (name, category) => {
3798
+ const named = found.channels.filter((c) => same(c.name, name));
3799
+ return {
3800
+ named,
3801
+ match: named.find((c) => c.category !== void 0 && same(c.category, category))
3802
+ };
3803
+ };
3804
+ if (!found) {
3805
+ if (byId("server")?.status !== "fail") failed("read", "read server", readError);
3806
+ for (const [id, title] of [
3807
+ ["categories", "categories"],
3808
+ ["channels", "channels"],
3809
+ ["webhooks", "webhooks"],
3810
+ ["roles", "roles"]
3811
+ ]) failed(id, title, "not checked: could not read the server");
3812
+ } else {
3813
+ const inspected = found;
3814
+ const categories = layout.categories ?? [];
3815
+ add("categories", "categories", categories.filter((cat) => !inspected.categories.some((name) => same(name, cat.name))).map((cat) => `${shown(cat.name)}: missing`), categories.length === 0 ? "none declared" : `${categories.length} of ${categories.length} exist`);
3816
+ const channelProblems = [];
3817
+ for (const { cat, channel } of declaredChannels) {
3818
+ const { named, match } = pick(channel.name, cat);
3819
+ if (named.length === 0) {
3820
+ channelProblems.push(`#${shown(channel.name)}: missing`);
3821
+ continue;
3822
+ }
3823
+ if (!match) {
3824
+ const where = named.map((c) => c.category ?? "no category").join(", ");
3825
+ channelProblems.push(`#${shown(channel.name)}: exists, but under ${shown(where)} and not ${shown(cat)}`);
3826
+ continue;
3827
+ }
3828
+ if (channel.topic && (match.topic ?? "") !== channel.topic) channelProblems.push(`#${shown(channel.name)}: topic is "${shown(match.topic ?? "")}" but the layout says "${shown(channel.topic)}"`);
3829
+ }
3830
+ add("channels", "channels", channelProblems, declaredChannels.length === 0 ? "none declared" : `${declaredChannels.length} of ${declaredChannels.length} as declared`);
3831
+ const withWebhook = declaredChannels.filter(({ channel }) => channel.webhook);
3832
+ const webhookProblems = [];
3833
+ for (const { cat, channel } of withWebhook) {
3834
+ const name = (channel.webhook === true ? {} : channel.webhook).name ?? channel.name;
3835
+ const { match } = pick(channel.name, cat);
3836
+ if (!match) webhookProblems.push(`#${shown(channel.name)}: no channel to hold the webhook`);
3837
+ else if (match.webhooksError) webhookProblems.push(`#${shown(channel.name)}: could not list its webhooks (${shown(match.webhooksError)})`);
3838
+ else if (!match.webhooks.some((w) => same(w, name))) webhookProblems.push(`#${shown(channel.name)}: no webhook named ${shown(name)}`);
3839
+ }
3840
+ add("webhooks", "webhooks", webhookProblems, withWebhook.length === 0 ? "none declared" : `${withWebhook.length} of ${withWebhook.length} exist`);
3841
+ const roles = layout.roles ?? [];
3842
+ const roleProblems = [];
3843
+ if (inspected.rolesError) roleProblems.push(`could not read the roles (${shown(inspected.rolesError)})`);
3844
+ for (const role of inspected.rolesError ? [] : roles) {
3845
+ const actual = inspected.roles.find((r) => same(r.name, role.name));
3846
+ if (!actual) roleProblems.push(`${shown(role.name)}: missing`);
3847
+ else if (role.mentionable !== void 0 && actual.mentionable !== role.mentionable) roleProblems.push(`${shown(role.name)}: mentionable is ${actual.mentionable} but the layout says ${role.mentionable}`);
3848
+ }
3849
+ add("roles", "roles", roleProblems, roles.length === 0 ? "none declared" : `${roles.length} of ${roles.length} as declared`);
3850
+ }
3851
+ const wanted = [...declaredChannels.filter(({ channel }) => channel.webhook).map(({ channel }) => channel.webhook !== true && channel.webhook && channel.webhook.secret ? channel.webhook.secret : defaultSecretName(channel.name)), ...(layout.roles ?? []).flatMap((r) => r.secret ? [r.secret] : [])];
3852
+ if (wanted.length === 0) checks.push({
3853
+ id: "secrets",
3854
+ title: "secrets",
3855
+ status: "pass",
3856
+ detail: "none declared",
3857
+ problems: []
3858
+ });
3859
+ else if (!loader.has("secrets")) failed("secrets", "secrets", whyMissing(loader, "secrets", "no `secrets` provider to check them with"));
3860
+ else {
3861
+ const org = input.context.org;
3862
+ const scope = input.scope ?? (org ? {
3863
+ kind: "organization",
3864
+ name: org
3865
+ } : void 0);
3866
+ if (!scope) failed("secrets", "secrets", "no org to look under — pass --scope or set `org` in holocron.config.ts");
3867
+ else try {
3868
+ const have = new Set((await loader.get("secrets").listSecrets(scope)).map((n) => n.toUpperCase()));
3869
+ const clash = layoutSecretProblem(layout);
3870
+ add("secrets", "secrets", [...clash ? [shown(clash)] : [], ...wanted.filter((n) => !have.has(n.toUpperCase())).map((n) => `${shown(n)}: missing`)], `${wanted.length} of ${wanted.length} exist`);
3871
+ } catch (err) {
3872
+ failed("secrets", "secrets", describeProviderError(err));
3873
+ }
3874
+ }
3875
+ if (!input.builtOnly) for (const planned of PLANNED_CHECKS) checks.push({
3876
+ id: planned.id,
3877
+ title: planned.title,
3878
+ status: "planned",
3879
+ detail: `not built yet (#1098, step ${planned.step})`,
3880
+ problems: [],
3881
+ step: planned.step
3882
+ });
3883
+ const built = checks.filter((c) => c.status !== "planned");
3884
+ const width = Math.max(...built.map((c) => c.title.length));
3885
+ for (const check of built) {
3886
+ print(` ${check.status === "pass" ? "✓" : "✗"} ${check.title.padEnd(width)} ${check.detail}`);
3887
+ for (const problem of check.problems) print(` - ${problem}`);
3888
+ }
3889
+ const planned = checks.filter((c) => c.status === "planned");
3890
+ if (planned.length > 0) {
3891
+ print("\nNot built yet (each fails until it lands, see #1098):");
3892
+ for (const check of planned) print(` ✗ ${String(check.step).padStart(2)} ${check.title}`);
3893
+ }
3894
+ const summary = summarise();
3895
+ print(`\n${summary.pass} passed, ${summary.fail} failed${input.builtOnly ? "" : `, ${summary.planned} not built`}`);
3896
+ return finish();
3897
+ }
3898
+ //#endregion
3573
3899
  //#region src/commands/npm-bump-versions/npm-bump-versions.ts
3574
3900
  async function runNpmBumpVersions(input) {
3575
3901
  const print = input.print ?? ((line) => console.log(line));
@@ -9706,6 +10032,12 @@ const COMMAND_REGISTRY = [
9706
10032
  }],
9707
10033
  supportsDryRun: true
9708
10034
  },
10035
+ {
10036
+ name: "notifications verify",
10037
+ description: "Check the layout against the server and the secrets store, step by step (read-only)",
10038
+ positionals: [],
10039
+ supportsDryRun: false
10040
+ },
9709
10041
  {
9710
10042
  name: "notifications setup",
9711
10043
  description: "Apply the server layout a notifications provider declares in holocron.config.ts",
@@ -11264,6 +11596,32 @@ try {
11264
11596
  ...argv.scope ? { scope: parseScope(argv.scope) } : {},
11265
11597
  ...argv.provider ? { provider: argv.provider } : {}
11266
11598
  })).status === "fail") process.exitCode = 1;
11599
+ }).command("verify", "Check the layout against the server and the secrets store, step by step (read-only)", (y) => y.option("scope", {
11600
+ type: "string",
11601
+ describe: "Where the layout's secrets live: \"repo\", \"env=<name>\" or \"org=<name>\" (default: the org)"
11602
+ }).option("provider", {
11603
+ type: "string",
11604
+ describe: "Which notifications provider, when several can inspect"
11605
+ }).option("built-only", {
11606
+ type: "boolean",
11607
+ default: false,
11608
+ describe: "Leave out the features that are not built yet (a routine drift check)"
11609
+ }), async (argv) => {
11610
+ const tokens = tokenContext(argv.token);
11611
+ if (!tokens) return;
11612
+ const loaded = await loadConfig(argv.cwd);
11613
+ applyResolvedConfig(argv, loaded.resolved);
11614
+ if ((await runNotificationsVerify({
11615
+ loaded,
11616
+ context: {
11617
+ repoRoot: argv.cwd,
11618
+ ...tokens,
11619
+ org: resolveOrg(argv, loaded.resolved)
11620
+ },
11621
+ ...argv.scope ? { scope: parseScope(argv.scope) } : {},
11622
+ ...argv.provider ? { provider: argv.provider } : {},
11623
+ ...argv.builtOnly ? { builtOnly: true } : {}
11624
+ })).status === "fail") process.exitCode = 1;
11267
11625
  }).command("$0", false, () => {}, async (argv) => {
11268
11626
  await launchGroupMenu("notifications", argv);
11269
11627
  }), () => {}).command("sync", "Sync generated files to other repos", (g) => g.command("github", "Sync workflow templates and composite actions to theholocron/.github", (y) => y.option("repo", {