@theholocron/cli 5.0.0-alpha.186 → 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,6 +409,7 @@ const COMMAND_CONTEXTS = {
409
409
  "secrets sync": "workspace",
410
410
  "notifications channel add": "workspace",
411
411
  "notifications setup": "workspace",
412
+ "doctor notifications": "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,
@@ -2604,28 +2610,557 @@ async function runSmokeCheck(key, provider, impl) {
2604
2610
  }
2605
2611
  return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
2606
2612
  }
2607
- default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
2613
+ default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
2614
+ }
2615
+ }
2616
+ /**
2617
+ * What the issues row says: how many lifecycle slots (inProgress, inReview, done)
2618
+ * map to something the tracker recognises, and which do not. The token's user and
2619
+ * the repo are not repeated -- `source` already names the account, and a slot
2620
+ * that is not configured is the part someone has to act on.
2621
+ */
2622
+ function describeLifecycle(report) {
2623
+ const unresolved = report.lifecycle.filter((l) => !l.resolved).map((l) => l.slot);
2624
+ const base = `lifecycle: ${report.lifecycle.length - unresolved.length}/${report.lifecycle.length} slots ready`;
2625
+ return unresolved.length > 0 ? `${base} (unresolved: ${unresolved.join(", ")})` : base;
2626
+ }
2627
+ function mk(capability, provider, status, message, logMessage) {
2628
+ return {
2629
+ capability,
2630
+ provider,
2631
+ status,
2632
+ message,
2633
+ ...logMessage !== void 0 ? { logMessage } : {}
2634
+ };
2635
+ }
2636
+ //#endregion
2637
+ //#region src/commands/notifications-channel-add/notifications-channel-add.ts
2638
+ /**
2639
+ * `holocron notifications channel add` — create one channel (and, with
2640
+ * `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
2641
+ *
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.
2646
+ */
2647
+ /**
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.
2651
+ */
2652
+ function defaultSecretName(channel) {
2653
+ return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
2654
+ }
2655
+ /**
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.
2659
+ */
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);
2664
+ }
2665
+ /**
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.
2671
+ */
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
+ }
2681
+ }
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;
2692
+ }
2693
+ /**
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.
2697
+ */
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;
2705
+ }
2706
+ if (capable.length > 1) throw new Error(`several \`notifications\` providers can ${verb.infinitive} (${names(capable)}) — pass --provider <name>`);
2707
+ return capable[0];
2708
+ }
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"
2714
+ });
2715
+ }
2716
+ const names = (ps) => ps.map((p) => p.providerName).join(", ");
2717
+ /**
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
+ }
2608
3146
  }
2609
- }
2610
- /**
2611
- * What the issues row says: how many lifecycle slots (inProgress, inReview, done)
2612
- * map to something the tracker recognises, and which do not. The token's user and
2613
- * the repo are not repeated -- `source` already names the account, and a slot
2614
- * that is not configured is the part someone has to act on.
2615
- */
2616
- function describeLifecycle(report) {
2617
- const unresolved = report.lifecycle.filter((l) => !l.resolved).map((l) => l.slot);
2618
- const base = `lifecycle: ${report.lifecycle.length - unresolved.length}/${report.lifecycle.length} slots ready`;
2619
- return unresolved.length > 0 ? `${base} (unresolved: ${unresolved.join(", ")})` : base;
2620
- }
2621
- function mk(capability, provider, status, message, logMessage) {
2622
- return {
2623
- capability,
2624
- provider,
2625
- status,
2626
- message,
2627
- ...logMessage !== void 0 ? { logMessage } : {}
2628
- };
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();
2629
3164
  }
2630
3165
  //#endregion
2631
3166
  //#region src/commands/monitoring-check/monitoring-check.ts
@@ -3231,203 +3766,6 @@ async function runNew(input) {
3231
3766
  };
3232
3767
  }
3233
3768
  //#endregion
3234
- //#region src/commands/notifications-channel-add/notifications-channel-add.ts
3235
- /**
3236
- * `holocron notifications channel add` — create one channel (and, with
3237
- * `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
3238
- *
3239
- * Provider-neutral: it drives the `notifications` capability's `ensure*`
3240
- * methods, so any provider that implements them works. Idempotent — an
3241
- * existing channel or webhook is left alone, and a webhook URL is never
3242
- * printed, only stored.
3243
- */
3244
- /**
3245
- * `HOLOCRON_NOTIFY_WEBHOOK_SYNC` for channel `sync`. Named after the job, not the
3246
- * provider, so changing providers means re-pointing the same secrets at new
3247
- * webhook URLs, with no workflow edits.
3248
- */
3249
- function defaultSecretName(channel) {
3250
- return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
3251
- }
3252
- /**
3253
- * Prefix of the throwaway secret the preflight writes and removes. Every run adds a
3254
- * random suffix, so it can never overwrite (and then delete) a secret someone already
3255
- * has; layouts may not use any name under this prefix for their own secrets.
3256
- */
3257
- const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
3258
- /** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
3259
- function isReservedSecret(name) {
3260
- return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
3261
- }
3262
- /**
3263
- * Prove the secrets store can be WRITTEN before creating anything that needs it.
3264
- * A webhook's URL is shown once; creating the webhook and then failing to store
3265
- * it leaves an orphan the user has to delete by hand. Listing is not enough --
3266
- * a token can read secrets it cannot write -- so write a throwaway secret and
3267
- * remove it.
3268
- */
3269
- async function assertSecretsReachable(secrets, scope) {
3270
- const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
3271
- try {
3272
- await secrets.setSecret(scope, probe, "ok");
3273
- await secrets.deleteSecret(scope, probe);
3274
- } catch (err) {
3275
- const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
3276
- throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks. If a secret named ${probe} was left behind, delete it`, { cause: err });
3277
- }
3278
- }
3279
- /** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
3280
- function whyMissing(loader, key, fallback) {
3281
- const failure = loader.loadFailures().find((f) => f.key === key);
3282
- return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${failure.error.message})` : fallback;
3283
- }
3284
- /** Pick the provider that can provision; with several, `--provider` decides. */
3285
- function pickProvisioner(providers, wanted) {
3286
- const capable = providers.filter((p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function");
3287
- if (capable.length === 0) throw new Error("no `notifications` provider can provision — add one that implements it (for example discord) to holocron.config.ts");
3288
- if (wanted) {
3289
- const match = capable.find((p) => p.providerName === wanted);
3290
- if (!match) throw new Error(`no provisioning \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
3291
- return match;
3292
- }
3293
- if (capable.length > 1) throw new Error(`several \`notifications\` providers can provision (${names(capable)}) — pass --provider <name>`);
3294
- return capable[0];
3295
- }
3296
- const names = (ps) => ps.map((p) => p.providerName).join(", ");
3297
- /** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
3298
- async function provisionChannel(deps, spec) {
3299
- const { notifications, secrets, scope, dryRun, print } = deps;
3300
- const webhookCfg = spec.webhook || void 0;
3301
- const secretName = webhookCfg?.secret ?? defaultSecretName(spec.name);
3302
- const webhookName = webhookCfg?.name ?? spec.name;
3303
- const where = spec.category ? ` in ${spec.category}` : "";
3304
- if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
3305
- if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
3306
- if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
3307
- if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
3308
- if (dryRun) {
3309
- print(` … would ensure channel ${spec.name}${where}`);
3310
- if (webhookCfg) print(` … would ensure webhook ${webhookName} and store its URL in ${secretName}`);
3311
- return {
3312
- name: spec.name,
3313
- ...spec.category ? { category: spec.category } : {},
3314
- created: false
3315
- };
3316
- }
3317
- const channel = await notifications.ensureChannel({
3318
- name: spec.name,
3319
- ...spec.category ? { category: spec.category } : {},
3320
- ...spec.topic ? { topic: spec.topic } : {}
3321
- });
3322
- print(` ${channel.created ? "✓ created" : "· exists "} channel ${spec.name}${where}`);
3323
- const outcome = {
3324
- name: spec.name,
3325
- ...spec.category ? { category: spec.category } : {},
3326
- created: channel.created
3327
- };
3328
- if (!webhookCfg) return outcome;
3329
- const hook = await notifications.ensureWebhook({
3330
- channel: spec.name,
3331
- name: webhookName,
3332
- ...spec.category ? { category: spec.category } : {}
3333
- });
3334
- if (hook.url) {
3335
- try {
3336
- await secrets.setSecret(scope, secretName, hook.url);
3337
- } catch (err) {
3338
- throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${describeProviderError(err)}) — delete the webhook in the provider and re-run to get a fresh URL`, { cause: err });
3339
- }
3340
- print(` ✓ ${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`);
3341
- outcome.webhook = {
3342
- name: webhookName,
3343
- created: hook.created,
3344
- secret: secretName,
3345
- stored: true
3346
- };
3347
- } else {
3348
- 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`;
3349
- print(` ! ${note}`);
3350
- outcome.webhook = {
3351
- name: webhookName,
3352
- created: hook.created,
3353
- secret: secretName,
3354
- stored: false,
3355
- note
3356
- };
3357
- }
3358
- return outcome;
3359
- }
3360
- async function runNotificationsChannelAdd(input) {
3361
- const print = input.print ?? ((line) => console.log(line));
3362
- const logger = input.logger ?? getLogger();
3363
- /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3364
- const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3365
- await loader.load();
3366
- assertPluginsResolvable(loader, "notifications channel add");
3367
- const dryRun = input.context.dryRun ?? false;
3368
- print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
3369
- try {
3370
- const notifications = pickProvisioner(loader.has("notifications") ? loader.get("notifications") : [], input.provider);
3371
- let secrets;
3372
- if (input.withWebhook && !dryRun) {
3373
- if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
3374
- secrets = loader.get("secrets");
3375
- }
3376
- const org = input.context.org;
3377
- const scope = input.scope ?? (org ? {
3378
- kind: "organization",
3379
- name: org
3380
- } : void 0);
3381
- if (input.withWebhook && !scope && !dryRun) throw new Error("no org to store the webhook secret under — pass --scope or set `org` in holocron.config.ts");
3382
- if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
3383
- const channel = await provisionChannel({
3384
- notifications,
3385
- ...secrets ? { secrets } : {},
3386
- scope: scope ?? { kind: "repo" },
3387
- dryRun,
3388
- print
3389
- }, {
3390
- name: input.name,
3391
- ...input.category ? { category: input.category } : {},
3392
- ...input.topic ? { topic: input.topic } : {},
3393
- ...input.withWebhook ? { webhook: {
3394
- ...input.webhookName ? { name: input.webhookName } : {},
3395
- ...input.secret ? { secret: input.secret } : {}
3396
- } } : {}
3397
- });
3398
- logger.info({
3399
- channel: input.name,
3400
- created: channel.created,
3401
- provider: notifications.providerName,
3402
- status: "ok"
3403
- }, `notifications channel add: ${input.name}`);
3404
- if (channel.webhook && !channel.webhook.stored && !dryRun) {
3405
- const message = channel.webhook.note;
3406
- print(` ✗ ${message}`);
3407
- return {
3408
- status: "fail",
3409
- channel,
3410
- message
3411
- };
3412
- }
3413
- return {
3414
- status: dryRun ? "dry-run" : "ok",
3415
- channel
3416
- };
3417
- } catch (err) {
3418
- const message = describeProviderError(err);
3419
- print(` ✗ ${message}`);
3420
- logger.warn({
3421
- channel: input.name,
3422
- reason: logReason(err)
3423
- }, `notifications channel add: ${input.name}`);
3424
- return {
3425
- status: "fail",
3426
- message
3427
- };
3428
- }
3429
- }
3430
- //#endregion
3431
3769
  //#region src/commands/notifications-setup/notifications-setup.ts
3432
3770
  /**
3433
3771
  * `holocron notifications setup` — apply the server layout a `notifications`
@@ -3457,7 +3795,9 @@ async function runNotificationsSetup(input) {
3457
3795
  failures: []
3458
3796
  };
3459
3797
  const refuse = (message, reason = message) => {
3460
- print(` ✗ ${message}`);
3798
+ print(` ${style.fail(message)}`);
3799
+ print("");
3800
+ print(style.header("0 ok, 1 fail, 0 skipped"));
3461
3801
  logger.warn({ reason }, "notifications setup");
3462
3802
  return {
3463
3803
  ...report,
@@ -3467,7 +3807,7 @@ async function runNotificationsSetup(input) {
3467
3807
  };
3468
3808
  let notifications;
3469
3809
  try {
3470
- notifications = pickProvisioner(loader.has("notifications") ? loader.get("notifications") : [], input.provider);
3810
+ notifications = pickProvisioner(notificationProviders(loader), input.provider);
3471
3811
  } catch (err) {
3472
3812
  return refuse(errorMessage(err));
3473
3813
  }
@@ -3490,31 +3830,21 @@ async function runNotificationsSetup(input) {
3490
3830
  } catch (err) {
3491
3831
  return refuse(errorMessage(err), logReason(err));
3492
3832
  }
3493
- const taken = /* @__PURE__ */ new Map();
3494
- const claim = (secret, owner, hint) => {
3495
- if (isReservedSecret(secret)) return `${owner} would store into ${secret}, a name reserved for the setup preflight — choose another`;
3496
- const key = secret.toUpperCase();
3497
- const other = taken.get(key);
3498
- if (other) return `${other} and ${owner} would both store into ${secret} — ${hint}`;
3499
- taken.set(key, owner);
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
3500
3840
  };
3501
- for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
3502
- if (!channel.webhook) continue;
3503
- const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
3504
- if (clash) return refuse(clash);
3505
- }
3506
- for (const role of layout.roles ?? []) {
3507
- if (!role.secret) continue;
3508
- const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
3509
- if (clash) return refuse(clash);
3510
- }
3511
- print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
3841
+ const made = (created) => void (created ? tally.ok++ : tally.skip++);
3512
3842
  const attempt = async (step, fn) => {
3513
3843
  try {
3514
3844
  await fn();
3515
3845
  } catch (err) {
3516
3846
  const message = describeProviderError(err);
3517
- print(` ✗ ${step}: ${message}`);
3847
+ print(` ${style.fail(`${step}: ${message}`)}`);
3518
3848
  report.failures.push({
3519
3849
  step,
3520
3850
  message
@@ -3522,16 +3852,18 @@ async function runNotificationsSetup(input) {
3522
3852
  }
3523
3853
  };
3524
3854
  for (const category of layout.categories ?? []) {
3525
- print(`\n${category.name}`);
3855
+ print(`\n${style.header(category.name)}`);
3526
3856
  report.categories.push(category.name);
3527
3857
  await attempt(`category ${category.name}`, async () => {
3528
3858
  if (!notifications.ensureCategory) return;
3529
3859
  if (dryRun) {
3530
- print(` … would ensure category ${category.name}`);
3860
+ print(` ${style.skip(`would ensure category ${category.name}`)}`);
3861
+ tally.skip++;
3531
3862
  return;
3532
3863
  }
3533
- const made = await notifications.ensureCategory(category.name);
3534
- print(` ${made.created ? "✓ created" : "· exists "} category ${category.name}`);
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);
3535
3867
  });
3536
3868
  for (const channel of category.channels ?? []) await attempt(`channel ${channel.name}`, async () => {
3537
3869
  const outcome = await provisionChannel({
@@ -3539,7 +3871,8 @@ async function runNotificationsSetup(input) {
3539
3871
  ...secrets ? { secrets } : {},
3540
3872
  scope: scope ?? { kind: "repo" },
3541
3873
  dryRun,
3542
- print
3874
+ print,
3875
+ tally
3543
3876
  }, {
3544
3877
  name: channel.name,
3545
3878
  category: category.name,
@@ -3553,11 +3886,12 @@ async function runNotificationsSetup(input) {
3553
3886
  });
3554
3887
  });
3555
3888
  }
3556
- if ((layout.roles ?? []).length > 0) print("\nRoles");
3889
+ if ((layout.roles ?? []).length > 0) print(`\n${style.header("Roles")}`);
3557
3890
  for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3558
3891
  if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
3559
3892
  if (dryRun) {
3560
- print(` … would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`);
3893
+ print(` ${style.skip(`would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`)}`);
3894
+ tally.skip++;
3561
3895
  report.roles.push({
3562
3896
  name: role.name,
3563
3897
  created: false,
@@ -3565,28 +3899,36 @@ async function runNotificationsSetup(input) {
3565
3899
  });
3566
3900
  return;
3567
3901
  }
3568
- const made = await notifications.ensureRole({
3902
+ const result = await notifications.ensureRole({
3569
3903
  name: role.name,
3570
- ...role.mentionable !== void 0 ? { mentionable: role.mentionable } : {}
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 } : {}
3571
3908
  });
3572
3909
  const outcome = {
3573
3910
  name: role.name,
3574
- id: made.id,
3575
- created: made.created
3911
+ id: result.id,
3912
+ created: result.created,
3913
+ ...result.updated?.length ? { updated: result.updated } : {}
3576
3914
  };
3577
3915
  report.roles.push(outcome);
3578
- print(` ${made.created ? "✓ created" : "· exists "} role ${role.name} (id ${made.id})`);
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);
3579
3920
  if (role.secret) {
3580
- await secrets.setSecret(scope, role.secret, made.id);
3921
+ await secrets.setSecret(scope, role.secret, result.id);
3581
3922
  outcome.secret = role.secret;
3582
3923
  outcome.stored = true;
3583
- print(` ✓ stored the id of ${role.name} in ${role.secret}`);
3924
+ print(` ${style.success(`stored the id of ${role.name} in ${role.secret}`)}`);
3925
+ tally.ok++;
3584
3926
  }
3585
3927
  });
3586
- if (report.failures.length > 0) {
3587
- report.status = "fail";
3588
- print(`\n${report.failures.length} step(s) need attention`);
3589
- } else if (dryRun) report.status = "dry-run";
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`));
3590
3932
  logger.info({
3591
3933
  provider: notifications.providerName,
3592
3934
  channels: report.channels.length,
@@ -9733,6 +10075,12 @@ const COMMAND_REGISTRY = [
9733
10075
  }],
9734
10076
  supportsDryRun: true
9735
10077
  },
10078
+ {
10079
+ name: "doctor notifications",
10080
+ description: "Check the layout against the server and the secrets store, step by step (read-only)",
10081
+ positionals: [],
10082
+ supportsDryRun: false
10083
+ },
9736
10084
  {
9737
10085
  name: "notifications setup",
9738
10086
  description: "Apply the server layout a notifications provider declares in holocron.config.ts",
@@ -10560,6 +10908,32 @@ try {
10560
10908
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
10561
10909
  type: "string",
10562
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;
10563
10937
  }), async (argv) => {
10564
10938
  const tokens = tokenContext(argv.token);
10565
10939
  if (!tokens) return;