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

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
@@ -24,9 +24,9 @@ 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, isWorkspaceRoot, walkFilesSync } from "@theholocron/fs-utils";
27
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
27
28
  import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
28
29
  import { stringifyJson } from "@theholocron/json-utils";
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,7 +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
+ "doctor notifications": "workspace",
413
413
  deploy: "workspace",
414
414
  "deploy cleanup-preview": "workspace",
415
415
  "monitoring check": "workspace",
@@ -2634,219 +2634,748 @@ function mk(capability, provider, status, message, logMessage) {
2634
2634
  };
2635
2635
  }
2636
2636
  //#endregion
2637
- //#region src/commands/monitoring-check/monitoring-check.ts
2637
+ //#region src/commands/notifications-channel-add/notifications-channel-add.ts
2638
2638
  /**
2639
- * `holocron monitoring check <url>` — runs every configured `monitoring`
2640
- * check against an arbitrary URL instead of each check's own configured
2641
- * one (holocron#1010).
2642
- *
2643
- * Built for CI: the `delivery.previewCheck` task runs this against a
2644
- * PR's Vercel preview deployment, reusing the exact same check
2645
- * definitions already in `holocron.config.ts` for production — no
2646
- * vendor API call, no separate checks-as-code file to keep in sync.
2639
+ * `holocron notifications channel add` — create one channel (and, with
2640
+ * `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
2647
2641
  *
2648
- * Split into two pieces (holocron#1030/#1032 review) so a caller that
2649
- * needs to attach a credential to the request (e.g. Vercel's Deployment
2650
- * Protection bypass header) can do so in a process that never loads a
2651
- * PR's own `holocron.config.ts` at all:
2652
- * - {@link resolveMonitoringChecks} — loads config/plugins, returns the
2653
- * configured `MonitoringCheckInput[]`. Untrusted: this executes
2654
- * whatever code a PR's own config file contains. Never give this
2655
- * process a credential worth protecting.
2656
- * - {@link reportChecks} — takes an already-resolved check list (no
2657
- * config loading at all) and runs/report them against a URL. Safe
2658
- * to run with a credential present, since nothing here executes
2659
- * PR-authored code.
2660
- * `runMonitoringCheck` composes both, unchanged, for every caller that
2661
- * doesn't need the split (the plain CLI invocation, tests, anywhere a
2662
- * credential isn't in play).
2642
+ * Provider-neutral: it drives the `notifications` capability's `ensure*`
2643
+ * methods, so any provider that implements them works. Idempotent — an
2644
+ * existing channel or webhook is left alone, and a webhook URL is never
2645
+ * printed, only stored.
2663
2646
  */
2664
2647
  /**
2665
- * Loads config/plugins and returns the configured `monitoring` checks —
2666
- * the untrusted half of `monitoring check`. Executes a PR's own
2667
- * `holocron.config.ts` (and whatever it imports); never run this with a
2668
- * credential in the process environment that a malicious config could
2669
- * read and exfiltrate.
2648
+ * `HOLOCRON_NOTIFY_WEBHOOK_SYNC` for channel `sync`. Named after the job, not the
2649
+ * provider, so changing providers means re-pointing the same secrets at new
2650
+ * webhook URLs, with no workflow edits.
2670
2651
  */
2671
- async function resolveMonitoringChecks(input) {
2672
- const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
2673
- await loader.load();
2674
- assertPluginsResolvable(loader, "monitoring check");
2675
- return [...loader.get("monitoring").checks];
2652
+ function defaultSecretName(channel) {
2653
+ return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
2676
2654
  }
2677
2655
  /**
2678
- * Runs an already-resolved check list against `url` and reports
2679
- * pass/fail — the trusted half of `monitoring check`. Takes plain data,
2680
- * never touches `holocron.config.ts` or any plugin: safe to run with a
2681
- * credential (in `headers` or elsewhere in the process env) present.
2656
+ * Prefix of the throwaway secret the preflight writes and removes. Every run adds a
2657
+ * random suffix, so it can never overwrite (and then delete) a secret someone already
2658
+ * has; layouts may not use any name under this prefix for their own secrets.
2682
2659
  */
2683
- async function reportChecks(input) {
2684
- const print = input.print ?? ((line) => console.log(line));
2685
- const logger = input.logger ?? getLogger();
2686
- const run = input.runCheck ?? runCheckAgainstUrl;
2687
- print(`Holocron monitoring check — against ${input.url}`);
2688
- if (input.checks.length === 0) {
2689
- print(`✗ no monitoring checks configured — nothing to validate`);
2690
- logger.warn({ url: input.url }, "monitoring check: no checks configured");
2691
- return {
2692
- status: "fail",
2693
- checks: []
2694
- };
2695
- }
2696
- const checks = [];
2697
- for (const checkInput of input.checks) {
2698
- const result = await run(checkInput, {
2699
- url: input.url,
2700
- ...input.headers ? { headers: input.headers } : {}
2701
- });
2702
- if (result.requestError) {
2703
- print(` ✗ ${checkInput.name} (request failed: ${result.requestError})`);
2704
- checks.push({
2705
- name: checkInput.name,
2706
- passed: false,
2707
- failures: [],
2708
- requestError: result.requestError
2709
- });
2710
- } else if (result.passed) {
2711
- print(` ✓ ${checkInput.name}`);
2712
- checks.push({
2713
- name: checkInput.name,
2714
- passed: true,
2715
- failures: []
2716
- });
2717
- } else {
2718
- print(` ✗ ${checkInput.name}`);
2719
- for (const failure of result.failures) print(` ${failure}`);
2720
- checks.push({
2721
- name: checkInput.name,
2722
- passed: false,
2723
- failures: result.failures
2724
- });
2725
- }
2726
- }
2727
- const failCount = checks.filter((c) => !c.passed).length;
2728
- const status = failCount === 0 ? "ok" : "fail";
2729
- print(`${status === "ok" ? "✓" : "✗"} ${checks.length - failCount} passed, ${failCount} failed`);
2730
- logger.info({
2731
- url: input.url,
2732
- total: checks.length,
2733
- failed: failCount,
2734
- status
2735
- }, "monitoring check: done");
2736
- return {
2737
- status,
2738
- checks
2739
- };
2660
+ const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
2661
+ /** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
2662
+ function isReservedSecret(name) {
2663
+ return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
2740
2664
  }
2741
2665
  /**
2742
- * `resolveMonitoringChecks` + `reportChecks` in one call — the plain CLI
2743
- * contract, unchanged. Only safe to run with a credential present when
2744
- * the config being loaded is trusted (no PR in the loop); CI's
2745
- * `delivery.previewCheck` uses the two halves separately instead.
2666
+ * Prove the secrets store can be WRITTEN before creating anything that needs it.
2667
+ * A webhook's URL is shown once; creating the webhook and then failing to store
2668
+ * it leaves an orphan the user has to delete by hand. Listing is not enough --
2669
+ * a token can read secrets it cannot write -- so write a throwaway secret and
2670
+ * remove it.
2746
2671
  */
2747
- async function runMonitoringCheck(input) {
2748
- return reportChecks({
2749
- checks: await resolveMonitoringChecks({
2750
- loaded: input.loaded,
2751
- context: input.context,
2752
- loader: input.loader
2753
- }),
2754
- url: input.url,
2755
- ...input.headers ? { headers: input.headers } : {},
2756
- ...input.print ? { print: input.print } : {},
2757
- ...input.logger ? { logger: input.logger } : {},
2758
- ...input.runCheck ? { runCheck: input.runCheck } : {}
2759
- });
2672
+ async function assertSecretsReachable(secrets, scope) {
2673
+ const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
2674
+ try {
2675
+ await secrets.setSecret(scope, probe, "ok");
2676
+ await secrets.deleteSecret(scope, probe);
2677
+ } catch (err) {
2678
+ const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
2679
+ 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 });
2680
+ }
2760
2681
  }
2761
- //#endregion
2762
- //#region src/commands/monitoring-resolve-checks/monitoring-resolve-checks.ts
2763
- /**
2764
- * `holocron monitoring resolve-checks` — loads config/plugins and writes
2765
- * the configured `monitoring` checks to a file as JSON (holocron#1030/
2766
- * #1032 review).
2767
- *
2768
- * The untrusted half of the `delivery.previewCheck` split: this process
2769
- * executes a PR's own `holocron.config.ts`, so it must never run with a
2770
- * credential worth protecting (e.g. a Deployment Protection bypass
2771
- * secret) present in its environment. `monitoring run-resolved` is the
2772
- * trusted half — it reads this command's output file and performs the
2773
- * actual authenticated request, with no config loading at all.
2774
- */
2775
- async function runMonitoringResolveChecks(input) {
2776
- const print = input.print ?? ((line) => console.log(line));
2777
- const checks = await resolveMonitoringChecks({
2778
- loaded: input.loaded,
2779
- context: input.context,
2780
- loader: input.loader
2781
- });
2782
- await writeFile(input.outFile, JSON.stringify(checks), "utf8");
2783
- print(`Resolved ${checks.length} monitoring check(s) → ${input.outFile}`);
2682
+ /** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
2683
+ function whyMissing(loader, key, fallback) {
2684
+ const failure = loader.loadFailures().find((f) => f.key === key);
2685
+ return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${describeProviderError(failure.error)})` : fallback;
2686
+ }
2687
+ /** The configured `notifications` providers, or an error that says why there are none (including a plugin that failed to load). */
2688
+ function notificationProviders(loader) {
2689
+ const providers = loader.has("notifications") ? loader.get("notifications") : [];
2690
+ if (providers.length === 0) throw new Error(whyMissing(loader, "notifications", "no `notifications` provider is available — add one to holocron.config.ts"));
2691
+ return providers;
2784
2692
  }
2785
- //#endregion
2786
- //#region src/commands/monitoring-run-resolved/monitoring-run-resolved.ts
2787
- /**
2788
- * `holocron monitoring run-resolved <url>` — runs an already-resolved
2789
- * check list (no `holocron.config.ts` loading at all) against `url`
2790
- * (holocron#1030/#1032 review).
2791
- *
2792
- * The trusted half of the `delivery.previewCheck` split, paired with
2793
- * `monitoring resolve-checks` (the untrusted half, which loads config).
2794
- * Safe to run with a credential present, since nothing here executes
2795
- * PR-authored code — but a credential is only ever attached after
2796
- * `verifyVercelDeploymentProject` confirms `url` actually belongs to
2797
- * `vercelProject`: a URL merely ending in `.vercel.app` proves nothing,
2798
- * since anyone can register their own Vercel project (sourcery review
2799
- * on holocron#1032). No `vercelProject` given at all → the credential
2800
- * is never attached, regardless of whether one is configured.
2801
- */
2802
2693
  /**
2803
- * The shared gate every caller that might attach the Deployment
2804
- * Protection bypass header goes through — `monitoring check`'s direct
2805
- * CLI invocation included (sourcery review on holocron#1032 called this
2806
- * out explicitly: verifying only in `delivery.previewCheck`'s own split
2807
- * commands and not here would leave the exact same unverified-host
2808
- * problem reachable by hand). No `vercelProject` → never attach
2809
- * anything, regardless of whether a `bypassSecret` happens to be
2810
- * configured. `vercelProject` given but verification fails or no token
2811
- * is available → `undefined` AND a printed reason, never a thrown
2812
- * error — callers decide for themselves whether that's fatal.
2694
+ * Pick the provider that can do what the command needs; with several, `--provider`
2695
+ * decides. `can` says what "able" means (a command that only reads needs `inspect`, not
2696
+ * provisioning), and `verb` words the errors.
2813
2697
  */
2814
- async function resolveVercelBypassHeaders(input) {
2815
- const print = input.print ?? ((line) => console.log(line));
2816
- if (!input.vercelProject) return { verified: true };
2817
- if (!input.vercelToken) {
2818
- print(`✗ --vercel-project was given but no Vercel token is available to verify deployment ownership`);
2819
- return { verified: false };
2820
- }
2821
- if (!await (input.verify ?? verifyVercelDeploymentProject)(input.url, input.vercelProject, input.vercelToken, { ...input.vercelTeamId ? { teamId: input.vercelTeamId } : {} })) {
2822
- 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`);
2823
- return { verified: false };
2698
+ function pickProvider(providers, wanted, can, verb) {
2699
+ const capable = providers.filter(can);
2700
+ 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`);
2701
+ if (wanted) {
2702
+ const match = capable.find((p) => p.providerName === wanted);
2703
+ if (!match) throw new Error(`no ${verb.gerund} \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
2704
+ return match;
2824
2705
  }
2825
- return input.bypassSecret ? {
2826
- verified: true,
2827
- headers: { "x-vercel-protection-bypass": input.bypassSecret }
2828
- } : { verified: true };
2706
+ if (capable.length > 1) throw new Error(`several \`notifications\` providers can ${verb.infinitive} (${names(capable)}) — pass --provider <name>`);
2707
+ return capable[0];
2829
2708
  }
2830
- async function runMonitoringRunResolved(input) {
2831
- const { headers, verified } = await resolveVercelBypassHeaders(input);
2832
- if (!verified) return {
2833
- status: "fail",
2834
- checks: []
2835
- };
2836
- return reportChecks({
2837
- checks: input.checks,
2838
- url: input.url,
2839
- ...headers ? { headers } : {},
2840
- ...input.print ? { print: input.print } : {},
2841
- ...input.logger ? { logger: input.logger } : {},
2842
- ...input.runCheck ? { runCheck: input.runCheck } : {}
2709
+ /** Pick the provider that can provision. Any ensure* method counts: a role-only layout needs no channel support. */
2710
+ function pickProvisioner(providers, wanted) {
2711
+ return pickProvider(providers, wanted, (p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function", {
2712
+ infinitive: "provision",
2713
+ gerund: "provisioning"
2843
2714
  });
2844
2715
  }
2845
- //#endregion
2846
- //#region src/commands/new/new.ts
2716
+ const names = (ps) => ps.map((p) => p.providerName).join(", ");
2847
2717
  /**
2848
- * `holocron new repo <type> <name>` — create a GitHub repo from a template and
2849
- * bootstrap it by replacing all template-slug casing variants with the new
2718
+ * The first reason the layout's secret names cannot work, or undefined. Two things
2719
+ * cannot share one secret: the second write would overwrite the first and callers
2720
+ * would read the wrong value. Punctuation normalising (release-notes vs
2721
+ * release_notes) makes that easy to hit by accident, GitHub secret names are
2722
+ * case-insensitive, and the preflight's own prefix is reserved. Shared by `setup`
2723
+ * (which refuses) and `verify` (which reports it).
2724
+ */
2725
+ function layoutSecretProblem(layout) {
2726
+ const taken = /* @__PURE__ */ new Map();
2727
+ const claim = (secret, owner, hint) => {
2728
+ if (isReservedSecret(secret)) return `${owner} would store into ${secret}, a name reserved for the setup preflight — choose another`;
2729
+ const key = secret.toUpperCase();
2730
+ const other = taken.get(key);
2731
+ if (other) return `${other} and ${owner} would both store into ${secret} — ${hint}`;
2732
+ taken.set(key, owner);
2733
+ };
2734
+ for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
2735
+ if (!channel.webhook) continue;
2736
+ const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
2737
+ if (clash) return clash;
2738
+ }
2739
+ for (const role of layout.roles ?? []) {
2740
+ if (!role.secret) continue;
2741
+ const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
2742
+ if (clash) return clash;
2743
+ }
2744
+ }
2745
+ /** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
2746
+ async function provisionChannel(deps, spec) {
2747
+ const { notifications, secrets, scope, dryRun, print, tally } = deps;
2748
+ const webhookCfg = spec.webhook || void 0;
2749
+ const secretName = webhookCfg?.secret ?? defaultSecretName(spec.name);
2750
+ const webhookName = webhookCfg?.name ?? spec.name;
2751
+ const where = spec.category ? ` in ${spec.category}` : "";
2752
+ if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
2753
+ if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
2754
+ if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
2755
+ if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
2756
+ if (dryRun) {
2757
+ print(` ${style.skip(`would ensure channel ${spec.name}${where}`)}`);
2758
+ if (tally) tally.skip += webhookCfg ? 2 : 1;
2759
+ if (webhookCfg) print(` ${style.skip(`would ensure webhook ${webhookName} and store its URL in ${secretName}`)}`);
2760
+ return {
2761
+ name: spec.name,
2762
+ ...spec.category ? { category: spec.category } : {},
2763
+ created: false
2764
+ };
2765
+ }
2766
+ const channel = await notifications.ensureChannel({
2767
+ name: spec.name,
2768
+ ...spec.category ? { category: spec.category } : {},
2769
+ ...spec.topic ? { topic: spec.topic } : {}
2770
+ });
2771
+ print(` ${(channel.created ? style.success : style.skip)(`${channel.created ? "created" : "exists "} channel ${spec.name}${where}`)}`);
2772
+ if (tally) tally[channel.created ? "ok" : "skip"]++;
2773
+ const outcome = {
2774
+ name: spec.name,
2775
+ ...spec.category ? { category: spec.category } : {},
2776
+ created: channel.created
2777
+ };
2778
+ if (!webhookCfg) return outcome;
2779
+ const hook = await notifications.ensureWebhook({
2780
+ channel: spec.name,
2781
+ name: webhookName,
2782
+ ...spec.category ? { category: spec.category } : {}
2783
+ });
2784
+ if (hook.url) {
2785
+ try {
2786
+ await secrets.setSecret(scope, secretName, hook.url);
2787
+ } catch (err) {
2788
+ 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 });
2789
+ }
2790
+ print(` ${style.success(`${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`)}`);
2791
+ if (tally) tally.ok++;
2792
+ outcome.webhook = {
2793
+ name: webhookName,
2794
+ created: hook.created,
2795
+ secret: secretName,
2796
+ stored: true
2797
+ };
2798
+ } else {
2799
+ const note = `webhook ${webhookName} already exists and its URL cannot be read back — delete it in the provider and re-run, or set ${secretName} yourself`;
2800
+ print(` ${style.warn(note)}`);
2801
+ outcome.webhook = {
2802
+ name: webhookName,
2803
+ created: hook.created,
2804
+ secret: secretName,
2805
+ stored: false,
2806
+ note
2807
+ };
2808
+ }
2809
+ return outcome;
2810
+ }
2811
+ async function runNotificationsChannelAdd(input) {
2812
+ const print = input.print ?? ((line) => console.log(line));
2813
+ const logger = input.logger ?? getLogger();
2814
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
2815
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
2816
+ await loader.load();
2817
+ assertPluginsResolvable(loader, "notifications channel add");
2818
+ const dryRun = input.context.dryRun ?? false;
2819
+ print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
2820
+ try {
2821
+ const notifications = pickProvisioner(notificationProviders(loader), input.provider);
2822
+ let secrets;
2823
+ if (input.withWebhook && !dryRun) {
2824
+ if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
2825
+ secrets = loader.get("secrets");
2826
+ }
2827
+ const org = input.context.org;
2828
+ const scope = input.scope ?? (org ? {
2829
+ kind: "organization",
2830
+ name: org
2831
+ } : void 0);
2832
+ 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");
2833
+ if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
2834
+ const channel = await provisionChannel({
2835
+ notifications,
2836
+ ...secrets ? { secrets } : {},
2837
+ scope: scope ?? { kind: "repo" },
2838
+ dryRun,
2839
+ print
2840
+ }, {
2841
+ name: input.name,
2842
+ ...input.category ? { category: input.category } : {},
2843
+ ...input.topic ? { topic: input.topic } : {},
2844
+ ...input.withWebhook ? { webhook: {
2845
+ ...input.webhookName ? { name: input.webhookName } : {},
2846
+ ...input.secret ? { secret: input.secret } : {}
2847
+ } } : {}
2848
+ });
2849
+ logger.info({
2850
+ channel: input.name,
2851
+ created: channel.created,
2852
+ provider: notifications.providerName,
2853
+ status: "ok"
2854
+ }, `notifications channel add: ${input.name}`);
2855
+ if (channel.webhook && !channel.webhook.stored && !dryRun) {
2856
+ const message = channel.webhook.note;
2857
+ print(` ${style.fail(message)}`);
2858
+ return {
2859
+ status: "fail",
2860
+ channel,
2861
+ message
2862
+ };
2863
+ }
2864
+ return {
2865
+ status: dryRun ? "dry-run" : "ok",
2866
+ channel
2867
+ };
2868
+ } catch (err) {
2869
+ const message = describeProviderError(err);
2870
+ print(` ${style.fail(message)}`);
2871
+ logger.warn({
2872
+ channel: input.name,
2873
+ reason: logReason(err)
2874
+ }, `notifications channel add: ${input.name}`);
2875
+ return {
2876
+ status: "fail",
2877
+ message
2878
+ };
2879
+ }
2880
+ }
2881
+ //#endregion
2882
+ //#region src/commands/doctor-notifications/doctor-notifications.ts
2883
+ /**
2884
+ * `holocron doctor notifications` -- read-only: compare the layout a provider
2885
+ * declares in `holocron.config.ts` with what is actually on the server and in the
2886
+ * secrets store, step by step.
2887
+ *
2888
+ * It is the end-to-end check for `notifications setup`, and stays useful after
2889
+ * as a drift check. Everything the layout and `setup` cannot do yet is listed
2890
+ * too, as plain failing steps, so the command is red until the last feature
2891
+ * lands and each feature built turns exactly one step green. The list
2892
+ * is {@link PLANNED_CHECKS}; the numbers match the table in holocron#1098.
2893
+ */
2894
+ /** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
2895
+ const PLANNED_CHECKS = [
2896
+ {
2897
+ step: 8,
2898
+ id: "permissions",
2899
+ title: "channel and category permissions set"
2900
+ },
2901
+ {
2902
+ step: 10,
2903
+ id: "server-settings",
2904
+ title: "server verification, notifications and system channels set"
2905
+ },
2906
+ {
2907
+ step: 11,
2908
+ id: "welcome",
2909
+ title: "welcome screen and onboarding set"
2910
+ },
2911
+ {
2912
+ step: 12,
2913
+ id: "webhook-appearance",
2914
+ title: "webhook names and avatars set"
2915
+ },
2916
+ {
2917
+ step: 13,
2918
+ id: "app-settings",
2919
+ title: "bot description, tags, install link and URLs set"
2920
+ },
2921
+ {
2922
+ step: 14,
2923
+ id: "role-assignment",
2924
+ title: "role assigned to team members"
2925
+ },
2926
+ {
2927
+ step: 15,
2928
+ id: "post-works",
2929
+ title: "test post reached every channel (platform.notifyCheck)"
2930
+ },
2931
+ {
2932
+ step: 16,
2933
+ id: "server-creation",
2934
+ title: "server created from config"
2935
+ }
2936
+ ];
2937
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
2938
+ const shown = (s) => sanitizeDetail(s);
2939
+ const ROLE_APPEARANCE_TITLE = "role colour, hoist and permissions set";
2940
+ const SECRETS_TITLE = "webhook URLs and role id stored as secrets";
2941
+ async function runDoctorNotifications(input) {
2942
+ const print = input.print ?? ((line) => console.log(line));
2943
+ const logger = input.logger ?? getLogger();
2944
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
2945
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
2946
+ await loader.load();
2947
+ assertPluginsResolvable(loader, "doctor notifications");
2948
+ const checks = [];
2949
+ const add = (id, title, problems, pass) => {
2950
+ checks.push({
2951
+ id,
2952
+ title,
2953
+ status: problems.length === 0 ? "pass" : "fail",
2954
+ detail: problems.length === 0 ? pass : `${problems.length} problem(s)`,
2955
+ problems
2956
+ });
2957
+ };
2958
+ const byId = (id) => checks.find((c) => c.id === id);
2959
+ const failed = (id, title, reason) => {
2960
+ checks.push({
2961
+ id,
2962
+ title,
2963
+ status: "fail",
2964
+ detail: reason,
2965
+ problems: []
2966
+ });
2967
+ };
2968
+ const summarise = () => ({
2969
+ pass: checks.filter((c) => c.status === "pass").length,
2970
+ fail: checks.filter((c) => c.status === "fail").length,
2971
+ skip: input.builtOnly ? PLANNED_CHECKS.length : 0
2972
+ });
2973
+ const finish = () => {
2974
+ const summary = summarise();
2975
+ const report = {
2976
+ status: summary.fail > 0 ? "fail" : "ok",
2977
+ checks,
2978
+ summary
2979
+ };
2980
+ logger.info({
2981
+ ...summary,
2982
+ status: report.status,
2983
+ checks: checks.map((c) => `${c.id}:${c.status}`)
2984
+ }, "doctor notifications");
2985
+ return report;
2986
+ };
2987
+ const refuse = (message, reason = message) => {
2988
+ print(` ${style.fail(message)}`);
2989
+ print("");
2990
+ print(style.header("0 ok, 1 fail, 0 skipped"));
2991
+ logger.warn({ reason }, "doctor notifications");
2992
+ return {
2993
+ status: "fail",
2994
+ checks,
2995
+ summary: summarise(),
2996
+ message
2997
+ };
2998
+ };
2999
+ let notifications;
3000
+ try {
3001
+ notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.inspect === "function", {
3002
+ infinitive: "inspect a server",
3003
+ gerund: "inspecting"
3004
+ });
3005
+ } catch (err) {
3006
+ return refuse(describeProviderError(err));
3007
+ }
3008
+ const layout = notifications.layout;
3009
+ if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
3010
+ let found;
3011
+ let readError = "";
3012
+ try {
3013
+ found = await notifications.inspect(layout);
3014
+ } catch (err) {
3015
+ readError = describeProviderError(err);
3016
+ }
3017
+ if (notifications.whoami) try {
3018
+ const who = await notifications.whoami();
3019
+ if (who) checks.push({
3020
+ id: "server",
3021
+ title: "server reachable",
3022
+ status: "pass",
3023
+ detail: shown(who.target),
3024
+ problems: []
3025
+ });
3026
+ else failed("server", "server reachable", "no server configured (the provider's whoami has nothing to check)");
3027
+ } catch (err) {
3028
+ failed("server", "server reachable", describeProviderError(err));
3029
+ }
3030
+ else if (found) checks.push({
3031
+ id: "server",
3032
+ title: "server reachable",
3033
+ status: "pass",
3034
+ detail: "readable (the provider has no whoami)",
3035
+ problems: []
3036
+ });
3037
+ else failed("server", "server reachable", readError);
3038
+ const declaredChannels = (layout.categories ?? []).flatMap((cat) => (cat.channels ?? []).map((channel) => ({
3039
+ cat: cat.name,
3040
+ channel
3041
+ })));
3042
+ const pick = (name, category) => {
3043
+ const named = found.channels.filter((c) => same(c.name, name));
3044
+ return {
3045
+ named,
3046
+ match: named.find((c) => c.category !== void 0 && same(c.category, category))
3047
+ };
3048
+ };
3049
+ if (!found) {
3050
+ if (byId("server")?.status !== "fail") failed("read", "read server", readError);
3051
+ for (const [id, title] of [
3052
+ ["categories", "categories exist"],
3053
+ ["channels", "channels in their category, with topics"],
3054
+ ["webhooks", "webhooks on their channels"],
3055
+ ["roles", "roles exist and are mentionable"],
3056
+ ["role-appearance", ROLE_APPEARANCE_TITLE]
3057
+ ]) failed(id, title, "not checked: could not read the server");
3058
+ } else {
3059
+ const inspected = found;
3060
+ const categories = layout.categories ?? [];
3061
+ add("categories", "categories exist", 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`);
3062
+ const channelProblems = [];
3063
+ for (const { cat, channel } of declaredChannels) {
3064
+ const { named, match } = pick(channel.name, cat);
3065
+ if (named.length === 0) {
3066
+ channelProblems.push(`#${shown(channel.name)}: missing`);
3067
+ continue;
3068
+ }
3069
+ if (!match) {
3070
+ const where = named.map((c) => c.category ?? "no category").join(", ");
3071
+ channelProblems.push(`#${shown(channel.name)}: exists, but under ${shown(where)} and not ${shown(cat)}`);
3072
+ continue;
3073
+ }
3074
+ if (channel.topic && (match.topic ?? "") !== channel.topic) channelProblems.push(`#${shown(channel.name)}: topic is "${shown(match.topic ?? "")}" but the layout says "${shown(channel.topic)}"`);
3075
+ }
3076
+ add("channels", "channels in their category, with topics", channelProblems, declaredChannels.length === 0 ? "none declared" : `${declaredChannels.length} of ${declaredChannels.length} as declared`);
3077
+ const withWebhook = declaredChannels.filter(({ channel }) => channel.webhook);
3078
+ const webhookProblems = [];
3079
+ for (const { cat, channel } of withWebhook) {
3080
+ const name = (channel.webhook === true ? {} : channel.webhook).name ?? channel.name;
3081
+ const { match } = pick(channel.name, cat);
3082
+ if (!match) webhookProblems.push(`#${shown(channel.name)}: no channel to hold the webhook`);
3083
+ else if (match.webhooksError) webhookProblems.push(`#${shown(channel.name)}: could not list its webhooks (${shown(match.webhooksError)})`);
3084
+ else if (!match.webhooks.some((w) => same(w, name))) webhookProblems.push(`#${shown(channel.name)}: no webhook named ${shown(name)}`);
3085
+ }
3086
+ add("webhooks", "webhooks on their channels", webhookProblems, withWebhook.length === 0 ? "none declared" : `${withWebhook.length} of ${withWebhook.length} exist`);
3087
+ const roles = layout.roles ?? [];
3088
+ const roleProblems = [];
3089
+ if (inspected.rolesError) roleProblems.push(`could not read the roles (${shown(inspected.rolesError)})`);
3090
+ for (const role of inspected.rolesError ? [] : roles) {
3091
+ const named = inspected.roles.filter((r) => same(r.name, role.name));
3092
+ const actual = named[0];
3093
+ if (named.length > 1) roleProblems.push(`${shown(role.name)}: ${named.length} roles have this name; setup cannot tell which to manage`);
3094
+ else if (!actual) roleProblems.push(`${shown(role.name)}: missing`);
3095
+ 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}`);
3096
+ }
3097
+ add("roles", "roles exist and are mentionable", roleProblems, roles.length === 0 ? "none declared" : `${roles.length} of ${roles.length} as declared`);
3098
+ const appearance = [];
3099
+ if (!inspected.rolesError) for (const role of roles) {
3100
+ const actual = inspected.roles.find((r) => same(r.name, role.name));
3101
+ if (!actual) {
3102
+ appearance.push(`${shown(role.name)}: not checked, the role is missing`);
3103
+ continue;
3104
+ }
3105
+ if (role.color !== void 0 && (actual.color ?? "").toLowerCase() !== role.color.toLowerCase()) appearance.push(`${shown(role.name)}: colour is ${shown(actual.color ?? "default")} but the layout says ${shown(role.color)}`);
3106
+ if (role.hoist !== void 0 && (actual.hoist ?? false) !== role.hoist) appearance.push(`${shown(role.name)}: hoist is ${actual.hoist ?? false} but the layout says ${role.hoist}`);
3107
+ const reported = actual.permissions;
3108
+ if (role.permissions !== void 0) {
3109
+ if (reported === void 0) appearance.push(`${shown(role.name)}: the provider did not report its permissions`);
3110
+ else {
3111
+ const have = new Set(reported.map((n) => n.toLowerCase()));
3112
+ const want = new Set(role.permissions.map((n) => n.toLowerCase()));
3113
+ const missing = role.permissions.filter((n) => !have.has(n.toLowerCase()));
3114
+ const extra = reported.filter((n) => !want.has(n.toLowerCase()));
3115
+ if (missing.length > 0) appearance.push(`${shown(role.name)}: missing permissions ${missing.map(shown).join(", ")}`);
3116
+ if (extra.length > 0) appearance.push(`${shown(role.name)}: has extra permissions ${extra.map(shown).join(", ")}`);
3117
+ }
3118
+ }
3119
+ }
3120
+ const styled = roles.filter((r) => r.color !== void 0 || r.hoist !== void 0 || r.permissions !== void 0);
3121
+ add("role-appearance", ROLE_APPEARANCE_TITLE, inspected.rolesError ? [`not checked: could not read the roles`] : appearance, styled.length === 0 ? "none declared" : `${styled.length} of ${styled.length} as declared`);
3122
+ }
3123
+ 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] : [])];
3124
+ if (wanted.length === 0) checks.push({
3125
+ id: "secrets",
3126
+ title: SECRETS_TITLE,
3127
+ status: "pass",
3128
+ detail: "none declared",
3129
+ problems: []
3130
+ });
3131
+ else if (!loader.has("secrets")) failed("secrets", SECRETS_TITLE, whyMissing(loader, "secrets", "no `secrets` provider to check them with"));
3132
+ else {
3133
+ const org = input.context.org;
3134
+ const scope = input.scope ?? (org ? {
3135
+ kind: "organization",
3136
+ name: org
3137
+ } : void 0);
3138
+ if (!scope) failed("secrets", SECRETS_TITLE, "no org to look under — pass --scope or set `org` in holocron.config.ts");
3139
+ else try {
3140
+ const have = new Set((await loader.get("secrets").listSecrets(scope)).map((n) => n.toUpperCase()));
3141
+ const clash = layoutSecretProblem(layout);
3142
+ add("secrets", SECRETS_TITLE, [...clash ? [shown(clash)] : [], ...wanted.filter((n) => !have.has(n.toUpperCase())).map((n) => `${shown(n)}: missing`)], `${wanted.length} of ${wanted.length} exist`);
3143
+ } catch (err) {
3144
+ failed("secrets", SECRETS_TITLE, describeProviderError(err));
3145
+ }
3146
+ }
3147
+ if (!input.builtOnly) for (const planned of PLANNED_CHECKS) checks.push({
3148
+ id: planned.id,
3149
+ title: planned.title,
3150
+ status: "fail",
3151
+ detail: `not built yet (#1098, step ${planned.step})`,
3152
+ problems: [],
3153
+ step: planned.step
3154
+ });
3155
+ const width = Math.max(...checks.map((c) => c.title.length));
3156
+ for (const check of checks) {
3157
+ print(statusRow(check.status === "pass" ? "ok" : "fail", [check.title.padEnd(width), check.detail]));
3158
+ for (const problem of check.problems) print(` - ${problem}`);
3159
+ }
3160
+ const summary = summarise();
3161
+ print("");
3162
+ print(style.header(`${summary.pass} ok, ${summary.fail} fail, ${summary.skip} skipped`));
3163
+ return finish();
3164
+ }
3165
+ //#endregion
3166
+ //#region src/commands/monitoring-check/monitoring-check.ts
3167
+ /**
3168
+ * `holocron monitoring check <url>` — runs every configured `monitoring`
3169
+ * check against an arbitrary URL instead of each check's own configured
3170
+ * one (holocron#1010).
3171
+ *
3172
+ * Built for CI: the `delivery.previewCheck` task runs this against a
3173
+ * PR's Vercel preview deployment, reusing the exact same check
3174
+ * definitions already in `holocron.config.ts` for production — no
3175
+ * vendor API call, no separate checks-as-code file to keep in sync.
3176
+ *
3177
+ * Split into two pieces (holocron#1030/#1032 review) so a caller that
3178
+ * needs to attach a credential to the request (e.g. Vercel's Deployment
3179
+ * Protection bypass header) can do so in a process that never loads a
3180
+ * PR's own `holocron.config.ts` at all:
3181
+ * - {@link resolveMonitoringChecks} — loads config/plugins, returns the
3182
+ * configured `MonitoringCheckInput[]`. Untrusted: this executes
3183
+ * whatever code a PR's own config file contains. Never give this
3184
+ * process a credential worth protecting.
3185
+ * - {@link reportChecks} — takes an already-resolved check list (no
3186
+ * config loading at all) and runs/report them against a URL. Safe
3187
+ * to run with a credential present, since nothing here executes
3188
+ * PR-authored code.
3189
+ * `runMonitoringCheck` composes both, unchanged, for every caller that
3190
+ * doesn't need the split (the plain CLI invocation, tests, anywhere a
3191
+ * credential isn't in play).
3192
+ */
3193
+ /**
3194
+ * Loads config/plugins and returns the configured `monitoring` checks —
3195
+ * the untrusted half of `monitoring check`. Executes a PR's own
3196
+ * `holocron.config.ts` (and whatever it imports); never run this with a
3197
+ * credential in the process environment that a malicious config could
3198
+ * read and exfiltrate.
3199
+ */
3200
+ async function resolveMonitoringChecks(input) {
3201
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3202
+ await loader.load();
3203
+ assertPluginsResolvable(loader, "monitoring check");
3204
+ return [...loader.get("monitoring").checks];
3205
+ }
3206
+ /**
3207
+ * Runs an already-resolved check list against `url` and reports
3208
+ * pass/fail — the trusted half of `monitoring check`. Takes plain data,
3209
+ * never touches `holocron.config.ts` or any plugin: safe to run with a
3210
+ * credential (in `headers` or elsewhere in the process env) present.
3211
+ */
3212
+ async function reportChecks(input) {
3213
+ const print = input.print ?? ((line) => console.log(line));
3214
+ const logger = input.logger ?? getLogger();
3215
+ const run = input.runCheck ?? runCheckAgainstUrl;
3216
+ print(`Holocron monitoring check — against ${input.url}`);
3217
+ if (input.checks.length === 0) {
3218
+ print(`✗ no monitoring checks configured — nothing to validate`);
3219
+ logger.warn({ url: input.url }, "monitoring check: no checks configured");
3220
+ return {
3221
+ status: "fail",
3222
+ checks: []
3223
+ };
3224
+ }
3225
+ const checks = [];
3226
+ for (const checkInput of input.checks) {
3227
+ const result = await run(checkInput, {
3228
+ url: input.url,
3229
+ ...input.headers ? { headers: input.headers } : {}
3230
+ });
3231
+ if (result.requestError) {
3232
+ print(` ✗ ${checkInput.name} (request failed: ${result.requestError})`);
3233
+ checks.push({
3234
+ name: checkInput.name,
3235
+ passed: false,
3236
+ failures: [],
3237
+ requestError: result.requestError
3238
+ });
3239
+ } else if (result.passed) {
3240
+ print(` ✓ ${checkInput.name}`);
3241
+ checks.push({
3242
+ name: checkInput.name,
3243
+ passed: true,
3244
+ failures: []
3245
+ });
3246
+ } else {
3247
+ print(` ✗ ${checkInput.name}`);
3248
+ for (const failure of result.failures) print(` ${failure}`);
3249
+ checks.push({
3250
+ name: checkInput.name,
3251
+ passed: false,
3252
+ failures: result.failures
3253
+ });
3254
+ }
3255
+ }
3256
+ const failCount = checks.filter((c) => !c.passed).length;
3257
+ const status = failCount === 0 ? "ok" : "fail";
3258
+ print(`${status === "ok" ? "✓" : "✗"} ${checks.length - failCount} passed, ${failCount} failed`);
3259
+ logger.info({
3260
+ url: input.url,
3261
+ total: checks.length,
3262
+ failed: failCount,
3263
+ status
3264
+ }, "monitoring check: done");
3265
+ return {
3266
+ status,
3267
+ checks
3268
+ };
3269
+ }
3270
+ /**
3271
+ * `resolveMonitoringChecks` + `reportChecks` in one call — the plain CLI
3272
+ * contract, unchanged. Only safe to run with a credential present when
3273
+ * the config being loaded is trusted (no PR in the loop); CI's
3274
+ * `delivery.previewCheck` uses the two halves separately instead.
3275
+ */
3276
+ async function runMonitoringCheck(input) {
3277
+ return reportChecks({
3278
+ checks: await resolveMonitoringChecks({
3279
+ loaded: input.loaded,
3280
+ context: input.context,
3281
+ loader: input.loader
3282
+ }),
3283
+ url: input.url,
3284
+ ...input.headers ? { headers: input.headers } : {},
3285
+ ...input.print ? { print: input.print } : {},
3286
+ ...input.logger ? { logger: input.logger } : {},
3287
+ ...input.runCheck ? { runCheck: input.runCheck } : {}
3288
+ });
3289
+ }
3290
+ //#endregion
3291
+ //#region src/commands/monitoring-resolve-checks/monitoring-resolve-checks.ts
3292
+ /**
3293
+ * `holocron monitoring resolve-checks` — loads config/plugins and writes
3294
+ * the configured `monitoring` checks to a file as JSON (holocron#1030/
3295
+ * #1032 review).
3296
+ *
3297
+ * The untrusted half of the `delivery.previewCheck` split: this process
3298
+ * executes a PR's own `holocron.config.ts`, so it must never run with a
3299
+ * credential worth protecting (e.g. a Deployment Protection bypass
3300
+ * secret) present in its environment. `monitoring run-resolved` is the
3301
+ * trusted half — it reads this command's output file and performs the
3302
+ * actual authenticated request, with no config loading at all.
3303
+ */
3304
+ async function runMonitoringResolveChecks(input) {
3305
+ const print = input.print ?? ((line) => console.log(line));
3306
+ const checks = await resolveMonitoringChecks({
3307
+ loaded: input.loaded,
3308
+ context: input.context,
3309
+ loader: input.loader
3310
+ });
3311
+ await writeFile(input.outFile, JSON.stringify(checks), "utf8");
3312
+ print(`Resolved ${checks.length} monitoring check(s) → ${input.outFile}`);
3313
+ }
3314
+ //#endregion
3315
+ //#region src/commands/monitoring-run-resolved/monitoring-run-resolved.ts
3316
+ /**
3317
+ * `holocron monitoring run-resolved <url>` — runs an already-resolved
3318
+ * check list (no `holocron.config.ts` loading at all) against `url`
3319
+ * (holocron#1030/#1032 review).
3320
+ *
3321
+ * The trusted half of the `delivery.previewCheck` split, paired with
3322
+ * `monitoring resolve-checks` (the untrusted half, which loads config).
3323
+ * Safe to run with a credential present, since nothing here executes
3324
+ * PR-authored code — but a credential is only ever attached after
3325
+ * `verifyVercelDeploymentProject` confirms `url` actually belongs to
3326
+ * `vercelProject`: a URL merely ending in `.vercel.app` proves nothing,
3327
+ * since anyone can register their own Vercel project (sourcery review
3328
+ * on holocron#1032). No `vercelProject` given at all → the credential
3329
+ * is never attached, regardless of whether one is configured.
3330
+ */
3331
+ /**
3332
+ * The shared gate every caller that might attach the Deployment
3333
+ * Protection bypass header goes through — `monitoring check`'s direct
3334
+ * CLI invocation included (sourcery review on holocron#1032 called this
3335
+ * out explicitly: verifying only in `delivery.previewCheck`'s own split
3336
+ * commands and not here would leave the exact same unverified-host
3337
+ * problem reachable by hand). No `vercelProject` → never attach
3338
+ * anything, regardless of whether a `bypassSecret` happens to be
3339
+ * configured. `vercelProject` given but verification fails or no token
3340
+ * is available → `undefined` AND a printed reason, never a thrown
3341
+ * error — callers decide for themselves whether that's fatal.
3342
+ */
3343
+ async function resolveVercelBypassHeaders(input) {
3344
+ const print = input.print ?? ((line) => console.log(line));
3345
+ if (!input.vercelProject) return { verified: true };
3346
+ if (!input.vercelToken) {
3347
+ print(`✗ --vercel-project was given but no Vercel token is available to verify deployment ownership`);
3348
+ return { verified: false };
3349
+ }
3350
+ if (!await (input.verify ?? verifyVercelDeploymentProject)(input.url, input.vercelProject, input.vercelToken, { ...input.vercelTeamId ? { teamId: input.vercelTeamId } : {} })) {
3351
+ 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`);
3352
+ return { verified: false };
3353
+ }
3354
+ return input.bypassSecret ? {
3355
+ verified: true,
3356
+ headers: { "x-vercel-protection-bypass": input.bypassSecret }
3357
+ } : { verified: true };
3358
+ }
3359
+ async function runMonitoringRunResolved(input) {
3360
+ const { headers, verified } = await resolveVercelBypassHeaders(input);
3361
+ if (!verified) return {
3362
+ status: "fail",
3363
+ checks: []
3364
+ };
3365
+ return reportChecks({
3366
+ checks: input.checks,
3367
+ url: input.url,
3368
+ ...headers ? { headers } : {},
3369
+ ...input.print ? { print: input.print } : {},
3370
+ ...input.logger ? { logger: input.logger } : {},
3371
+ ...input.runCheck ? { runCheck: input.runCheck } : {}
3372
+ });
3373
+ }
3374
+ //#endregion
3375
+ //#region src/commands/new/new.ts
3376
+ /**
3377
+ * `holocron new repo <type> <name>` — create a GitHub repo from a template and
3378
+ * bootstrap it by replacing all template-slug casing variants with the new
2850
3379
  * project name, then generate a `holocron.config.ts` from the answers given
2851
3380
  * during the interactive wizard.
2852
3381
  *
@@ -3203,280 +3732,38 @@ async function runNew(input) {
3203
3732
  }
3204
3733
  if (!env.get("HOLOCRON_DEPLOY_TOKEN")) {
3205
3734
  const deployToken = keychainLookup("github.deploy");
3206
- if (deployToken) setupEnv["HOLOCRON_DEPLOY_TOKEN"] = deployToken;
3207
- }
3208
- execFn("holocron", setupArgs, {
3209
- cwd: repoDir,
3210
- stdio: "inherit",
3211
- env: setupEnv
3212
- });
3213
- } catch {
3214
- print(" ✗ holocron repo setup failed — run it manually after checking your config");
3215
- }
3216
- }
3217
- print("");
3218
- print(` Scaffolded ${newRepo} (${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched).`);
3219
- print("");
3220
- print(" Next:");
3221
- print(` 1. cd ${repoDir}`);
3222
- if (input.noVerify) {
3223
- print(` 2. pnpm install`);
3224
- print(` 3. holocron repo setup # wire up secrets, teams, labels, etc.`);
3225
- print(` 4. git push -u origin HEAD`);
3226
- } else print(` 2. git push -u origin HEAD`);
3227
- logger.info({
3228
- repo: newRepo,
3229
- repoDir,
3230
- filesPatched,
3231
- status: "ok"
3232
- }, "new: done");
3233
- return {
3234
- status: "ok",
3235
- repoDir,
3236
- filesPatched
3237
- };
3238
- }
3239
- //#endregion
3240
- //#region src/commands/notifications-channel-add/notifications-channel-add.ts
3241
- /**
3242
- * `holocron notifications channel add` — create one channel (and, with
3243
- * `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
3244
- *
3245
- * Provider-neutral: it drives the `notifications` capability's `ensure*`
3246
- * methods, so any provider that implements them works. Idempotent — an
3247
- * existing channel or webhook is left alone, and a webhook URL is never
3248
- * printed, only stored.
3249
- */
3250
- /**
3251
- * `HOLOCRON_NOTIFY_WEBHOOK_SYNC` for channel `sync`. Named after the job, not the
3252
- * provider, so changing providers means re-pointing the same secrets at new
3253
- * webhook URLs, with no workflow edits.
3254
- */
3255
- function defaultSecretName(channel) {
3256
- return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
3257
- }
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
- }
3268
- /**
3269
- * Prove the secrets store can be WRITTEN before creating anything that needs it.
3270
- * A webhook's URL is shown once; creating the webhook and then failing to store
3271
- * it leaves an orphan the user has to delete by hand. Listing is not enough --
3272
- * a token can read secrets it cannot write -- so write a throwaway secret and
3273
- * remove it.
3274
- */
3275
- async function assertSecretsReachable(secrets, scope) {
3276
- const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
3277
- try {
3278
- await secrets.setSecret(scope, probe, "ok");
3279
- await secrets.deleteSecret(scope, probe);
3280
- } catch (err) {
3281
- const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
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 });
3283
- }
3284
- }
3285
- /** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
3286
- function whyMissing(loader, key, fallback) {
3287
- const failure = loader.loadFailures().find((f) => f.key === key);
3288
- return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${describeProviderError(failure.error)})` : fallback;
3289
- }
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`);
3304
- if (wanted) {
3305
- const match = capable.find((p) => p.providerName === wanted);
3306
- if (!match) throw new Error(`no ${verb.gerund} \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
3307
- return match;
3308
- }
3309
- if (capable.length > 1) throw new Error(`several \`notifications\` providers can ${verb.infinitive} (${names(capable)}) — pass --provider <name>`);
3310
- return capable[0];
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
- }
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
- }
3348
- /** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
3349
- async function provisionChannel(deps, spec) {
3350
- const { notifications, secrets, scope, dryRun, print } = deps;
3351
- const webhookCfg = spec.webhook || void 0;
3352
- const secretName = webhookCfg?.secret ?? defaultSecretName(spec.name);
3353
- const webhookName = webhookCfg?.name ?? spec.name;
3354
- const where = spec.category ? ` in ${spec.category}` : "";
3355
- if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
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`);
3358
- if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
3359
- if (dryRun) {
3360
- print(` … would ensure channel ${spec.name}${where}`);
3361
- if (webhookCfg) print(` … would ensure webhook ${webhookName} and store its URL in ${secretName}`);
3362
- return {
3363
- name: spec.name,
3364
- ...spec.category ? { category: spec.category } : {},
3365
- created: false
3366
- };
3367
- }
3368
- const channel = await notifications.ensureChannel({
3369
- name: spec.name,
3370
- ...spec.category ? { category: spec.category } : {},
3371
- ...spec.topic ? { topic: spec.topic } : {}
3372
- });
3373
- print(` ${channel.created ? "✓ created" : "· exists "} channel ${spec.name}${where}`);
3374
- const outcome = {
3375
- name: spec.name,
3376
- ...spec.category ? { category: spec.category } : {},
3377
- created: channel.created
3378
- };
3379
- if (!webhookCfg) return outcome;
3380
- const hook = await notifications.ensureWebhook({
3381
- channel: spec.name,
3382
- name: webhookName,
3383
- ...spec.category ? { category: spec.category } : {}
3384
- });
3385
- if (hook.url) {
3386
- try {
3387
- await secrets.setSecret(scope, secretName, hook.url);
3388
- } catch (err) {
3389
- 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 });
3390
- }
3391
- print(` ✓ ${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`);
3392
- outcome.webhook = {
3393
- name: webhookName,
3394
- created: hook.created,
3395
- secret: secretName,
3396
- stored: true
3397
- };
3398
- } else {
3399
- const note = `webhook ${webhookName} already exists and its URL cannot be read back — delete it in the provider and re-run, or set ${secretName} yourself`;
3400
- print(` ! ${note}`);
3401
- outcome.webhook = {
3402
- name: webhookName,
3403
- created: hook.created,
3404
- secret: secretName,
3405
- stored: false,
3406
- note
3407
- };
3408
- }
3409
- return outcome;
3410
- }
3411
- async function runNotificationsChannelAdd(input) {
3412
- const print = input.print ?? ((line) => console.log(line));
3413
- const logger = input.logger ?? getLogger();
3414
- /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3415
- const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3416
- await loader.load();
3417
- assertPluginsResolvable(loader, "notifications channel add");
3418
- const dryRun = input.context.dryRun ?? false;
3419
- print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
3420
- try {
3421
- const notifications = pickProvisioner(notificationProviders(loader), input.provider);
3422
- let secrets;
3423
- if (input.withWebhook && !dryRun) {
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"));
3425
- secrets = loader.get("secrets");
3426
- }
3427
- const org = input.context.org;
3428
- const scope = input.scope ?? (org ? {
3429
- kind: "organization",
3430
- name: org
3431
- } : void 0);
3432
- 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");
3433
- if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
3434
- const channel = await provisionChannel({
3435
- notifications,
3436
- ...secrets ? { secrets } : {},
3437
- scope: scope ?? { kind: "repo" },
3438
- dryRun,
3439
- print
3440
- }, {
3441
- name: input.name,
3442
- ...input.category ? { category: input.category } : {},
3443
- ...input.topic ? { topic: input.topic } : {},
3444
- ...input.withWebhook ? { webhook: {
3445
- ...input.webhookName ? { name: input.webhookName } : {},
3446
- ...input.secret ? { secret: input.secret } : {}
3447
- } } : {}
3448
- });
3449
- logger.info({
3450
- channel: input.name,
3451
- created: channel.created,
3452
- provider: notifications.providerName,
3453
- status: "ok"
3454
- }, `notifications channel add: ${input.name}`);
3455
- if (channel.webhook && !channel.webhook.stored && !dryRun) {
3456
- const message = channel.webhook.note;
3457
- print(` ✗ ${message}`);
3458
- return {
3459
- status: "fail",
3460
- channel,
3461
- message
3462
- };
3735
+ if (deployToken) setupEnv["HOLOCRON_DEPLOY_TOKEN"] = deployToken;
3736
+ }
3737
+ execFn("holocron", setupArgs, {
3738
+ cwd: repoDir,
3739
+ stdio: "inherit",
3740
+ env: setupEnv
3741
+ });
3742
+ } catch {
3743
+ print(" ✗ holocron repo setup failed — run it manually after checking your config");
3463
3744
  }
3464
- return {
3465
- status: dryRun ? "dry-run" : "ok",
3466
- channel
3467
- };
3468
- } catch (err) {
3469
- const message = describeProviderError(err);
3470
- print(` ✗ ${message}`);
3471
- logger.warn({
3472
- channel: input.name,
3473
- reason: logReason(err)
3474
- }, `notifications channel add: ${input.name}`);
3475
- return {
3476
- status: "fail",
3477
- message
3478
- };
3479
3745
  }
3746
+ print("");
3747
+ print(` Scaffolded ${newRepo} (${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched).`);
3748
+ print("");
3749
+ print(" Next:");
3750
+ print(` 1. cd ${repoDir}`);
3751
+ if (input.noVerify) {
3752
+ print(` 2. pnpm install`);
3753
+ print(` 3. holocron repo setup # wire up secrets, teams, labels, etc.`);
3754
+ print(` 4. git push -u origin HEAD`);
3755
+ } else print(` 2. git push -u origin HEAD`);
3756
+ logger.info({
3757
+ repo: newRepo,
3758
+ repoDir,
3759
+ filesPatched,
3760
+ status: "ok"
3761
+ }, "new: done");
3762
+ return {
3763
+ status: "ok",
3764
+ repoDir,
3765
+ filesPatched
3766
+ };
3480
3767
  }
3481
3768
  //#endregion
3482
3769
  //#region src/commands/notifications-setup/notifications-setup.ts
@@ -3489,411 +3776,167 @@ async function runNotificationsChannelAdd(input) {
3489
3776
  * missing, so it is safe to re-run and `--dry-run` previews it. A failing step
3490
3777
  * is reported and the rest still run.
3491
3778
  *
3492
- * Run it from ONE repo per server — a layout is org-level, and putting it in a
3493
- * shared preset would apply it from every repo.
3494
- */
3495
- async function runNotificationsSetup(input) {
3496
- const print = input.print ?? ((line) => console.log(line));
3497
- const logger = input.logger ?? getLogger();
3498
- /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3499
- const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3500
- await loader.load();
3501
- assertPluginsResolvable(loader, "notifications setup");
3502
- const dryRun = input.context.dryRun ?? false;
3503
- const report = {
3504
- status: "ok",
3505
- categories: [],
3506
- channels: [],
3507
- roles: [],
3508
- failures: []
3509
- };
3510
- const refuse = (message, reason = message) => {
3511
- print(` ✗ ${message}`);
3512
- logger.warn({ reason }, "notifications setup");
3513
- return {
3514
- ...report,
3515
- status: "fail",
3516
- message
3517
- };
3518
- };
3519
- let notifications;
3520
- try {
3521
- notifications = pickProvisioner(notificationProviders(loader), input.provider);
3522
- } catch (err) {
3523
- return refuse(errorMessage(err));
3524
- }
3525
- const layout = notifications.layout;
3526
- if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
3527
- const wantsSecrets = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook)) || (layout.roles ?? []).some((r) => r.secret);
3528
- let secrets;
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"));
3531
- secrets = loader.get("secrets");
3532
- }
3533
- const org = input.context.org;
3534
- const scope = input.scope ?? (org ? {
3535
- kind: "organization",
3536
- name: org
3537
- } : void 0);
3538
- if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
3539
- if (secrets && scope && !dryRun) try {
3540
- await assertSecretsReachable(secrets, scope);
3541
- } catch (err) {
3542
- return refuse(errorMessage(err), logReason(err));
3543
- }
3544
- const secretProblem = layoutSecretProblem(layout);
3545
- if (secretProblem) return refuse(secretProblem);
3546
- print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
3547
- const attempt = async (step, fn) => {
3548
- try {
3549
- await fn();
3550
- } catch (err) {
3551
- const message = describeProviderError(err);
3552
- print(` ✗ ${step}: ${message}`);
3553
- report.failures.push({
3554
- step,
3555
- message
3556
- });
3557
- }
3558
- };
3559
- for (const category of layout.categories ?? []) {
3560
- print(`\n${category.name}`);
3561
- report.categories.push(category.name);
3562
- await attempt(`category ${category.name}`, async () => {
3563
- if (!notifications.ensureCategory) return;
3564
- if (dryRun) {
3565
- print(` … would ensure category ${category.name}`);
3566
- return;
3567
- }
3568
- const made = await notifications.ensureCategory(category.name);
3569
- print(` ${made.created ? "✓ created" : "· exists "} category ${category.name}`);
3570
- });
3571
- for (const channel of category.channels ?? []) await attempt(`channel ${channel.name}`, async () => {
3572
- const outcome = await provisionChannel({
3573
- notifications,
3574
- ...secrets ? { secrets } : {},
3575
- scope: scope ?? { kind: "repo" },
3576
- dryRun,
3577
- print
3578
- }, {
3579
- name: channel.name,
3580
- category: category.name,
3581
- ...channel.topic ? { topic: channel.topic } : {},
3582
- ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
3583
- });
3584
- report.channels.push(outcome);
3585
- if (outcome.webhook && !outcome.webhook.stored) report.failures.push({
3586
- step: `webhook ${outcome.webhook.name}`,
3587
- message: outcome.webhook.note
3588
- });
3589
- });
3590
- }
3591
- if ((layout.roles ?? []).length > 0) print("\nRoles");
3592
- for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3593
- if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
3594
- if (dryRun) {
3595
- print(` … would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`);
3596
- report.roles.push({
3597
- name: role.name,
3598
- created: false,
3599
- ...role.secret ? { secret: role.secret } : {}
3600
- });
3601
- return;
3602
- }
3603
- const made = await notifications.ensureRole({
3604
- name: role.name,
3605
- ...role.mentionable !== void 0 ? { mentionable: role.mentionable } : {}
3606
- });
3607
- const outcome = {
3608
- name: role.name,
3609
- id: made.id,
3610
- created: made.created
3611
- };
3612
- report.roles.push(outcome);
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
- }
3620
- });
3621
- if (report.failures.length > 0) {
3622
- report.status = "fail";
3623
- print(`\n${report.failures.length} step(s) need attention`);
3624
- } else if (dryRun) report.status = "dry-run";
3625
- logger.info({
3626
- provider: notifications.providerName,
3627
- channels: report.channels.length,
3628
- roles: report.roles.length,
3629
- failures: report.failures.length,
3630
- status: report.status
3631
- }, "notifications setup");
3632
- return report;
3633
- }
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;
3779
+ * Run it from ONE repo per server — a layout is org-level, and putting it in a
3780
+ * shared preset would apply it from every repo.
3781
+ */
3782
+ async function runNotificationsSetup(input) {
3783
+ const print = input.print ?? ((line) => console.log(line));
3784
+ const logger = input.logger ?? getLogger();
3785
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3786
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3787
+ await loader.load();
3788
+ assertPluginsResolvable(loader, "notifications setup");
3789
+ const dryRun = input.context.dryRun ?? false;
3790
+ const report = {
3791
+ status: "ok",
3792
+ categories: [],
3793
+ channels: [],
3794
+ roles: [],
3795
+ failures: []
3742
3796
  };
3743
3797
  const refuse = (message, reason = message) => {
3744
- print(` ✗ ${message}`);
3745
- logger.warn({ reason }, "notifications verify");
3798
+ print(` ${style.fail(message)}`);
3799
+ print("");
3800
+ print(style.header("0 ok, 1 fail, 0 skipped"));
3801
+ logger.warn({ reason }, "notifications setup");
3746
3802
  return {
3803
+ ...report,
3747
3804
  status: "fail",
3748
- checks,
3749
- summary: summarise(),
3750
3805
  message
3751
3806
  };
3752
3807
  };
3753
3808
  let notifications;
3754
3809
  try {
3755
- notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.inspect === "function", {
3756
- infinitive: "inspect a server",
3757
- gerund: "inspecting"
3758
- });
3810
+ notifications = pickProvisioner(notificationProviders(loader), input.provider);
3759
3811
  } catch (err) {
3760
- return refuse(describeProviderError(err));
3812
+ return refuse(errorMessage(err));
3761
3813
  }
3762
3814
  const layout = notifications.layout;
3763
3815
  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);
3816
+ const wantsSecrets = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook)) || (layout.roles ?? []).some((r) => r.secret);
3817
+ let secrets;
3818
+ if (wantsSecrets && !dryRun) {
3819
+ 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"));
3820
+ secrets = loader.get("secrets");
3771
3821
  }
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)");
3822
+ const org = input.context.org;
3823
+ const scope = input.scope ?? (org ? {
3824
+ kind: "organization",
3825
+ name: org
3826
+ } : void 0);
3827
+ if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
3828
+ if (secrets && scope && !dryRun) try {
3829
+ await assertSecretsReachable(secrets, scope);
3782
3830
  } catch (err) {
3783
- failed("server", "server", describeProviderError(err));
3831
+ return refuse(errorMessage(err), logReason(err));
3784
3832
  }
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
- };
3833
+ const secretProblem = layoutSecretProblem(layout);
3834
+ if (secretProblem) return refuse(secretProblem);
3835
+ const providerProblem = notifications.validateLayout?.(layout);
3836
+ if (providerProblem) return refuse(providerProblem);
3837
+ const tally = {
3838
+ ok: 0,
3839
+ skip: 0
3803
3840
  };
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`);
3841
+ const made = (created) => void (created ? tally.ok++ : tally.skip++);
3842
+ const attempt = async (step, fn) => {
3843
+ try {
3844
+ await fn();
3871
3845
  } catch (err) {
3872
- failed("secrets", "secrets", describeProviderError(err));
3846
+ const message = describeProviderError(err);
3847
+ print(` ${style.fail(`${step}: ${message}`)}`);
3848
+ report.failures.push({
3849
+ step,
3850
+ message
3851
+ });
3873
3852
  }
3853
+ };
3854
+ for (const category of layout.categories ?? []) {
3855
+ print(`\n${style.header(category.name)}`);
3856
+ report.categories.push(category.name);
3857
+ await attempt(`category ${category.name}`, async () => {
3858
+ if (!notifications.ensureCategory) return;
3859
+ if (dryRun) {
3860
+ print(` ${style.skip(`would ensure category ${category.name}`)}`);
3861
+ tally.skip++;
3862
+ return;
3863
+ }
3864
+ const result = await notifications.ensureCategory(category.name);
3865
+ print(` ${(result.created ? style.success : style.skip)(`${result.created ? "created" : "exists "} category ${category.name}`)}`);
3866
+ made(result.created);
3867
+ });
3868
+ for (const channel of category.channels ?? []) await attempt(`channel ${channel.name}`, async () => {
3869
+ const outcome = await provisionChannel({
3870
+ notifications,
3871
+ ...secrets ? { secrets } : {},
3872
+ scope: scope ?? { kind: "repo" },
3873
+ dryRun,
3874
+ print,
3875
+ tally
3876
+ }, {
3877
+ name: channel.name,
3878
+ category: category.name,
3879
+ ...channel.topic ? { topic: channel.topic } : {},
3880
+ ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
3881
+ });
3882
+ report.channels.push(outcome);
3883
+ if (outcome.webhook && !outcome.webhook.stored) report.failures.push({
3884
+ step: `webhook ${outcome.webhook.name}`,
3885
+ message: outcome.webhook.note
3886
+ });
3887
+ });
3874
3888
  }
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
3889
+ if ((layout.roles ?? []).length > 0) print(`\n${style.header("Roles")}`);
3890
+ for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3891
+ if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
3892
+ if (dryRun) {
3893
+ print(` ${style.skip(`would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`)}`);
3894
+ tally.skip++;
3895
+ report.roles.push({
3896
+ name: role.name,
3897
+ created: false,
3898
+ ...role.secret ? { secret: role.secret } : {}
3899
+ });
3900
+ return;
3901
+ }
3902
+ const result = await notifications.ensureRole({
3903
+ name: role.name,
3904
+ ...role.mentionable !== void 0 ? { mentionable: role.mentionable } : {},
3905
+ ...role.color !== void 0 ? { color: role.color } : {},
3906
+ ...role.hoist !== void 0 ? { hoist: role.hoist } : {},
3907
+ ...role.permissions !== void 0 ? { permissions: role.permissions } : {}
3908
+ });
3909
+ const outcome = {
3910
+ name: role.name,
3911
+ id: result.id,
3912
+ created: result.created,
3913
+ ...result.updated?.length ? { updated: result.updated } : {}
3914
+ };
3915
+ report.roles.push(outcome);
3916
+ const changed = result.created || (result.updated?.length ?? 0) > 0;
3917
+ const verb = result.created ? "created" : changed ? "updated" : "exists ";
3918
+ print(` ${(changed ? style.success : style.skip)(`${verb} role ${role.name} (id ${result.id})${result.updated?.length ? `: ${result.updated.join(", ")}` : ""}`)}`);
3919
+ made(changed);
3920
+ if (role.secret) {
3921
+ await secrets.setSecret(scope, role.secret, result.id);
3922
+ outcome.secret = role.secret;
3923
+ outcome.stored = true;
3924
+ print(` ${style.success(`stored the id of ${role.name} in ${role.secret}`)}`);
3925
+ tally.ok++;
3926
+ }
3882
3927
  });
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();
3928
+ if (report.failures.length > 0) report.status = "fail";
3929
+ else if (dryRun) report.status = "dry-run";
3930
+ print("");
3931
+ print(style.header(`${tally.ok} ok, ${report.failures.length} fail, ${tally.skip} skipped`));
3932
+ logger.info({
3933
+ provider: notifications.providerName,
3934
+ channels: report.channels.length,
3935
+ roles: report.roles.length,
3936
+ failures: report.failures.length,
3937
+ status: report.status
3938
+ }, "notifications setup");
3939
+ return report;
3897
3940
  }
3898
3941
  //#endregion
3899
3942
  //#region src/commands/npm-bump-versions/npm-bump-versions.ts
@@ -10033,7 +10076,7 @@ const COMMAND_REGISTRY = [
10033
10076
  supportsDryRun: true
10034
10077
  },
10035
10078
  {
10036
- name: "notifications verify",
10079
+ name: "doctor notifications",
10037
10080
  description: "Check the layout against the server and the secrets store, step by step (read-only)",
10038
10081
  positionals: [],
10039
10082
  supportsDryRun: false
@@ -10865,6 +10908,32 @@ try {
10865
10908
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
10866
10909
  type: "string",
10867
10910
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
10911
+ }).command("notifications", "Check the layout against the server and the secrets store, step by step (read-only)", (y) => y.option("scope", {
10912
+ type: "string",
10913
+ describe: "Where the layout's secrets live: \"repo\", \"env=<name>\" or \"org=<name>\" (default: the org)"
10914
+ }).option("provider", {
10915
+ type: "string",
10916
+ describe: "Which notifications provider, when several can inspect"
10917
+ }).option("built-only", {
10918
+ type: "boolean",
10919
+ default: false,
10920
+ describe: "Leave out the features that are not built yet (a routine drift check)"
10921
+ }), async (argv) => {
10922
+ const tokens = tokenContext(argv.token);
10923
+ if (!tokens) return;
10924
+ const loaded = await loadConfig(argv.cwd);
10925
+ applyResolvedConfig(argv, loaded.resolved);
10926
+ if ((await runDoctorNotifications({
10927
+ loaded,
10928
+ context: {
10929
+ repoRoot: argv.cwd,
10930
+ ...tokens,
10931
+ org: resolveOrg(argv, loaded.resolved)
10932
+ },
10933
+ ...argv.scope ? { scope: parseScope(argv.scope) } : {},
10934
+ ...argv.provider ? { provider: argv.provider } : {},
10935
+ ...argv.builtOnly ? { builtOnly: true } : {}
10936
+ })).status === "fail") process.exitCode = 1;
10868
10937
  }), async (argv) => {
10869
10938
  const tokens = tokenContext(argv.token);
10870
10939
  if (!tokens) return;
@@ -11596,32 +11665,6 @@ try {
11596
11665
  ...argv.scope ? { scope: parseScope(argv.scope) } : {},
11597
11666
  ...argv.provider ? { provider: argv.provider } : {}
11598
11667
  })).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;
11625
11668
  }).command("$0", false, () => {}, async (argv) => {
11626
11669
  await launchGroupMenu("notifications", argv);
11627
11670
  }), () => {}).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", {