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

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,849 +2634,1179 @@ 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
2850
- * project name, then generate a `holocron.config.ts` from the answers given
2851
- * during the interactive wizard.
2852
- *
2853
- * Flow:
2854
- * 1. Preflight — verify `gh` CLI is available.
2855
- * 2. Resolve type, name, description, homepage, vault/deployment/agent options.
2856
- * 3. `gh repo create <org>/<name> --template <org>/<type>-template --private --clone`
2857
- * → clones to `<cwd>/<name>/`
2858
- * 4. Detect template slug from cloned package.json.
2859
- * 5. Replace all casing variants of the slug across every text file.
2860
- * 6. Replace `<description>` and `<homepage>` placeholders.
2861
- * 7. Generate and write `holocron.config.ts` based on wizard answers.
2862
- * 8. Commit the patched files (-s for DCO).
2863
- * 9. Unless --no-verify: `pnpm install` in the new repo.
2864
- * 10. Print next steps.
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).
2865
2724
  */
2866
- var NewError = class extends Error {
2867
- name = "NewError";
2868
- };
2869
- function cap(word) {
2870
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
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
+ }
2871
2744
  }
2872
- /**
2873
- * Derive all common casing variants of a kebab-case slug (e.g.
2874
- * "cli-template") and map each to the corresponding form of a
2875
- * kebab-case name (e.g. "my-tool").
2876
- *
2877
- * Returns search→replacement pairs, deduplicating single-word slugs
2878
- * that would otherwise produce identical entries.
2879
- */
2880
- function deriveVariants(slug, name) {
2881
- const sw = slug.split("-");
2882
- const nw = name.split("-");
2883
- const pairs = [
2884
- [slug, name],
2885
- [sw.join("_"), nw.join("_")],
2886
- [sw.join("_").toUpperCase(), nw.join("_").toUpperCase()],
2887
- [sw.map(cap).join(""), nw.map(cap).join("")],
2888
- [sw[0].toLowerCase() + sw.slice(1).map(cap).join(""), nw[0].toLowerCase() + nw.slice(1).map(cap).join("")],
2889
- [sw.map(cap).join(" "), nw.map(cap).join(" ")]
2890
- ];
2891
- const seen = /* @__PURE__ */ new Set();
2892
- return pairs.filter(([s]) => {
2893
- if (seen.has(s)) return false;
2894
- seen.add(s);
2895
- return true;
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
+ ...spec.access ? { access: spec.access } : {}
2896
2771
  });
2772
+ const changed = channel.created || (channel.updated?.length ?? 0) > 0;
2773
+ const verb = channel.created ? "created" : changed ? "updated" : "exists ";
2774
+ const what = channel.updated?.length ? `: ${channel.updated.join(", ")}` : "";
2775
+ print(` ${(changed ? style.success : style.skip)(`${verb} channel ${spec.name}${where}${what}`)}`);
2776
+ if (tally) tally[changed ? "ok" : "skip"]++;
2777
+ const outcome = {
2778
+ name: spec.name,
2779
+ ...spec.category ? { category: spec.category } : {},
2780
+ created: channel.created,
2781
+ ...channel.updated?.length ? { updated: channel.updated } : {}
2782
+ };
2783
+ if (!webhookCfg) return outcome;
2784
+ const hook = await notifications.ensureWebhook({
2785
+ channel: spec.name,
2786
+ name: webhookName,
2787
+ ...spec.category ? { category: spec.category } : {}
2788
+ });
2789
+ if (hook.url) {
2790
+ try {
2791
+ await secrets.setSecret(scope, secretName, hook.url);
2792
+ } catch (err) {
2793
+ 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 });
2794
+ }
2795
+ print(` ${style.success(`${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`)}`);
2796
+ if (tally) tally.ok++;
2797
+ outcome.webhook = {
2798
+ name: webhookName,
2799
+ created: hook.created,
2800
+ secret: secretName,
2801
+ stored: true
2802
+ };
2803
+ } else {
2804
+ 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`;
2805
+ print(` ${style.warn(note)}`);
2806
+ outcome.webhook = {
2807
+ name: webhookName,
2808
+ created: hook.created,
2809
+ secret: secretName,
2810
+ stored: false,
2811
+ note
2812
+ };
2813
+ }
2814
+ return outcome;
2897
2815
  }
2898
- /** Validate a repo name: must be lowercase kebab-case. Returns `true` on success or an error string. */
2899
- function validateRepoName(v) {
2900
- return /^[a-z][a-z0-9-]*$/.test(v.trim()) ? true : "Must be lowercase kebab-case (e.g. my-tool)";
2901
- }
2902
- /** Parse a comma-separated topics string into a trimmed, non-empty array. */
2903
- function parseTopics(raw) {
2904
- return raw ? String(raw).split(",").map((t) => t.trim()).filter(Boolean) : [];
2816
+ async function runNotificationsChannelAdd(input) {
2817
+ const print = input.print ?? ((line) => console.log(line));
2818
+ const logger = input.logger ?? getLogger();
2819
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
2820
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
2821
+ await loader.load();
2822
+ assertPluginsResolvable(loader, "notifications channel add");
2823
+ const dryRun = input.context.dryRun ?? false;
2824
+ print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
2825
+ try {
2826
+ const notifications = pickProvisioner(notificationProviders(loader), input.provider);
2827
+ let secrets;
2828
+ if (input.withWebhook && !dryRun) {
2829
+ if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
2830
+ secrets = loader.get("secrets");
2831
+ }
2832
+ const org = input.context.org;
2833
+ const scope = input.scope ?? (org ? {
2834
+ kind: "organization",
2835
+ name: org
2836
+ } : void 0);
2837
+ 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");
2838
+ if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
2839
+ const channel = await provisionChannel({
2840
+ notifications,
2841
+ ...secrets ? { secrets } : {},
2842
+ scope: scope ?? { kind: "repo" },
2843
+ dryRun,
2844
+ print
2845
+ }, {
2846
+ name: input.name,
2847
+ ...input.category ? { category: input.category } : {},
2848
+ ...input.topic ? { topic: input.topic } : {},
2849
+ ...input.withWebhook ? { webhook: {
2850
+ ...input.webhookName ? { name: input.webhookName } : {},
2851
+ ...input.secret ? { secret: input.secret } : {}
2852
+ } } : {}
2853
+ });
2854
+ logger.info({
2855
+ channel: input.name,
2856
+ created: channel.created,
2857
+ provider: notifications.providerName,
2858
+ status: "ok"
2859
+ }, `notifications channel add: ${input.name}`);
2860
+ if (channel.webhook && !channel.webhook.stored && !dryRun) {
2861
+ const message = channel.webhook.note;
2862
+ print(` ${style.fail(message)}`);
2863
+ return {
2864
+ status: "fail",
2865
+ channel,
2866
+ message
2867
+ };
2868
+ }
2869
+ return {
2870
+ status: dryRun ? "dry-run" : "ok",
2871
+ channel
2872
+ };
2873
+ } catch (err) {
2874
+ const message = describeProviderError(err);
2875
+ print(` ${style.fail(message)}`);
2876
+ logger.warn({
2877
+ channel: input.name,
2878
+ reason: logReason(err)
2879
+ }, `notifications channel add: ${input.name}`);
2880
+ return {
2881
+ status: "fail",
2882
+ message
2883
+ };
2884
+ }
2905
2885
  }
2886
+ //#endregion
2887
+ //#region src/commands/doctor-notifications/doctor-notifications.ts
2906
2888
  /**
2907
- * Generate the content of `holocron.config.ts` for a newly-scaffolded repo.
2908
- * Uses the `node()` preset from `@theholocron/holocron-config` as the baseline
2909
- * and layers in the provider/agent choices made during the wizard.
2889
+ * `holocron doctor notifications` -- read-only: compare the layout a provider
2890
+ * declares in `holocron.config.ts` with what is actually on the server and in the
2891
+ * secrets store, step by step.
2892
+ *
2893
+ * It is the end-to-end check for `notifications setup`, and stays useful after
2894
+ * as a drift check. Everything the layout and `setup` cannot do yet is listed
2895
+ * too, as plain failing steps, so the command is red until the last feature
2896
+ * lands and each feature built turns exactly one step green. The list
2897
+ * is {@link PLANNED_CHECKS}; the numbers match the table in holocron#1098.
2910
2898
  */
2911
- function generateHolocronConfig(opts) {
2912
- const lines = [
2913
- `import { defineConfig } from "@theholocron/cli";`,
2914
- `import { compose, node, typecheck } from "@theholocron/holocron-config";`,
2915
- ``,
2916
- `const preset = compose(node(), typecheck());`,
2917
- `export default defineConfig({`,
2918
- `\t...preset,`
2919
- ];
2920
- if (opts.description) lines.push(`\tdescription: ${JSON.stringify(opts.description)},`);
2921
- if (opts.homepage) lines.push(`\thomepage: ${JSON.stringify(opts.homepage)},`);
2922
- const topics = opts.topics?.length ? opts.topics : [];
2923
- const hasRuntimeOverride = opts.runtimeEnvironment != null && opts.runtimeEnvironment !== "node";
2924
- if (opts.org != null) {
2925
- const repoName = `${opts.org}/${opts.name}`;
2926
- lines.push(`\trepo: {`);
2927
- lines.push(`\t\t...preset.repo,`);
2928
- lines.push(`\t\tname: ${JSON.stringify(repoName)},`);
2929
- lines.push(`\t\tteams: [{ slug: "gatekeepers", permission: "maintain" }],`);
2930
- lines.push(`\t\ttopics: ${JSON.stringify(topics)},`);
2931
- if (opts.protection && opts.protection !== "strict") lines.push(`\t\tprotection: ${JSON.stringify(opts.protection)},`);
2932
- const propParts = [];
2933
- if (hasRuntimeOverride) propParts.push(`runtime_environment: ${JSON.stringify(opts.runtimeEnvironment)}`);
2934
- if (opts.openSource === false) propParts.push(`open_source: false`);
2935
- if (opts.usesExternalPackages === false) propParts.push(`uses_external_packages: false`);
2936
- if (propParts.length > 0) lines.push(`\t\tproperties: { ...preset.repo?.properties, ${propParts.join(", ")} },`);
2937
- lines.push(`\t},`);
2938
- } else if (topics.length > 0 || hasRuntimeOverride) {
2939
- lines.push(`\trepo: {`);
2940
- lines.push(`\t\t...preset.repo,`);
2941
- if (topics.length > 0) lines.push(`\t\ttopics: ${JSON.stringify(topics)},`);
2942
- if (hasRuntimeOverride) lines.push(`\t\tproperties: { ...preset.repo?.properties, runtime_environment: ${JSON.stringify(opts.runtimeEnvironment)} },`);
2943
- lines.push(`\t},`);
2899
+ /** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
2900
+ const PLANNED_CHECKS = [
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"
2944
2935
  }
2945
- const hasVault = opts.vaultProvider && opts.vaultProvider !== "none";
2946
- const hasDeployment = opts.deploymentProvider && opts.deploymentProvider !== "none";
2947
- if (hasVault || hasDeployment) {
2948
- lines.push(`\tproviders: {`);
2949
- lines.push(`\t\t...preset.providers,`);
2950
- if (opts.vaultProvider === "doppler") {
2951
- const proj = JSON.stringify(opts.vaultProject ?? opts.name);
2952
- const cfg = JSON.stringify(opts.vaultConfig ?? "dev");
2953
- lines.push(`\t\tvault: ["doppler", { project: ${proj}, config: ${cfg} }],`);
2954
- } else if (opts.vaultProvider === "1password") {
2955
- const vault = JSON.stringify(opts.vaultProject ?? opts.name);
2956
- lines.push(`\t\tvault: ["1password", { vault: ${vault} }],`);
2957
- } else if (opts.vaultProvider === "infisical") {
2958
- const proj = JSON.stringify(opts.vaultProject ?? opts.name);
2959
- lines.push(`\t\tvault: ["infisical", { project: ${proj} }],`);
2936
+ ];
2937
+ const same = (a, b) => a.toLowerCase() === b.toLowerCase();
2938
+ /** `everyone`, or the roles sorted and lower-cased, so order and case do not matter when comparing. */
2939
+ const accessKey = (v) => v === "everyone" ? "everyone" : JSON.stringify([...new Set(v.map((r) => r.toLowerCase()))].sort());
2940
+ const accessText = (v) => v === "everyone" ? "everyone" : v.length === 0 ? "nobody (bots and webhooks only)" : v.join(", ");
2941
+ const shown = (s) => sanitizeDetail(s);
2942
+ const ACCESS_TITLE = "channel and category access set";
2943
+ const ROLE_APPEARANCE_TITLE = "role colour, hoist and permissions set";
2944
+ const SECRETS_TITLE = "webhook URLs and role id stored as secrets";
2945
+ async function runDoctorNotifications(input) {
2946
+ const print = input.print ?? ((line) => console.log(line));
2947
+ const logger = input.logger ?? getLogger();
2948
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
2949
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
2950
+ await loader.load();
2951
+ assertPluginsResolvable(loader, "doctor notifications");
2952
+ const checks = [];
2953
+ const add = (id, title, problems, pass) => {
2954
+ checks.push({
2955
+ id,
2956
+ title,
2957
+ status: problems.length === 0 ? "pass" : "fail",
2958
+ detail: problems.length === 0 ? pass : `${problems.length} problem(s)`,
2959
+ problems
2960
+ });
2961
+ };
2962
+ const byId = (id) => checks.find((c) => c.id === id);
2963
+ const failed = (id, title, reason) => {
2964
+ checks.push({
2965
+ id,
2966
+ title,
2967
+ status: "fail",
2968
+ detail: reason,
2969
+ problems: []
2970
+ });
2971
+ };
2972
+ const summarise = () => ({
2973
+ pass: checks.filter((c) => c.status === "pass").length,
2974
+ fail: checks.filter((c) => c.status === "fail").length,
2975
+ skip: input.builtOnly ? PLANNED_CHECKS.length : 0
2976
+ });
2977
+ const finish = () => {
2978
+ const summary = summarise();
2979
+ const report = {
2980
+ status: summary.fail > 0 ? "fail" : "ok",
2981
+ checks,
2982
+ summary
2983
+ };
2984
+ logger.info({
2985
+ ...summary,
2986
+ status: report.status,
2987
+ checks: checks.map((c) => `${c.id}:${c.status}`)
2988
+ }, "doctor notifications");
2989
+ return report;
2990
+ };
2991
+ const refuse = (message, reason = message) => {
2992
+ print(` ${style.fail(message)}`);
2993
+ print("");
2994
+ print(style.header("0 ok, 1 fail, 0 skipped"));
2995
+ logger.warn({ reason }, "doctor notifications");
2996
+ return {
2997
+ status: "fail",
2998
+ checks,
2999
+ summary: summarise(),
3000
+ message
3001
+ };
3002
+ };
3003
+ let notifications;
3004
+ try {
3005
+ notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.inspect === "function", {
3006
+ infinitive: "inspect a server",
3007
+ gerund: "inspecting"
3008
+ });
3009
+ } catch (err) {
3010
+ return refuse(describeProviderError(err));
3011
+ }
3012
+ const layout = notifications.layout;
3013
+ if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
3014
+ let found;
3015
+ let readError = "";
3016
+ try {
3017
+ found = await notifications.inspect(layout);
3018
+ } catch (err) {
3019
+ readError = describeProviderError(err);
3020
+ }
3021
+ if (notifications.whoami) try {
3022
+ const who = await notifications.whoami();
3023
+ if (who) checks.push({
3024
+ id: "server",
3025
+ title: "server reachable",
3026
+ status: "pass",
3027
+ detail: shown(who.target),
3028
+ problems: []
3029
+ });
3030
+ else failed("server", "server reachable", "no server configured (the provider's whoami has nothing to check)");
3031
+ } catch (err) {
3032
+ failed("server", "server reachable", describeProviderError(err));
3033
+ }
3034
+ else if (found) checks.push({
3035
+ id: "server",
3036
+ title: "server reachable",
3037
+ status: "pass",
3038
+ detail: "readable (the provider has no whoami)",
3039
+ problems: []
3040
+ });
3041
+ else failed("server", "server reachable", readError);
3042
+ const declaredChannels = (layout.categories ?? []).flatMap((cat) => (cat.channels ?? []).map((channel) => ({
3043
+ cat: cat.name,
3044
+ channel
3045
+ })));
3046
+ const pick = (name, category) => {
3047
+ const named = found.channels.filter((c) => same(c.name, name));
3048
+ return {
3049
+ named,
3050
+ match: named.find((c) => c.category !== void 0 && same(c.category, category))
3051
+ };
3052
+ };
3053
+ if (!found) {
3054
+ if (byId("server")?.status !== "fail") failed("read", "read server", readError);
3055
+ for (const [id, title] of [
3056
+ ["categories", "categories exist"],
3057
+ ["channels", "channels in their category, with topics"],
3058
+ ["webhooks", "webhooks on their channels"],
3059
+ ["roles", "roles exist and are mentionable"],
3060
+ ["role-appearance", ROLE_APPEARANCE_TITLE],
3061
+ ["access", ACCESS_TITLE]
3062
+ ]) failed(id, title, "not checked: could not read the server");
3063
+ } else {
3064
+ const inspected = found;
3065
+ const categories = layout.categories ?? [];
3066
+ 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`);
3067
+ const channelProblems = [];
3068
+ for (const { cat, channel } of declaredChannels) {
3069
+ const { named, match } = pick(channel.name, cat);
3070
+ if (named.length === 0) {
3071
+ channelProblems.push(`#${shown(channel.name)}: missing`);
3072
+ continue;
3073
+ }
3074
+ if (!match) {
3075
+ const where = named.map((c) => c.category ?? "no category").join(", ");
3076
+ channelProblems.push(`#${shown(channel.name)}: exists, but under ${shown(where)} and not ${shown(cat)}`);
3077
+ continue;
3078
+ }
3079
+ if (channel.topic && (match.topic ?? "") !== channel.topic) channelProblems.push(`#${shown(channel.name)}: topic is "${shown(match.topic ?? "")}" but the layout says "${shown(channel.topic)}"`);
2960
3080
  }
2961
- if (opts.deploymentProvider === "vercel") lines.push(`\t\tdeployment: "vercel",`);
2962
- lines.push(`\t},`);
2963
- }
2964
- if (opts.agent && opts.agent !== "none") lines.push(`\tagent: ${JSON.stringify(opts.agent)},`);
2965
- if (opts.skills?.length) lines.push(`\tskills: ${JSON.stringify(opts.skills)},`);
2966
- lines.push(`});`);
2967
- lines.push(``);
2968
- return lines.join("\n");
2969
- }
2970
- const SKIP_DIRS$1 = [
2971
- ".git",
2972
- "node_modules",
2973
- "dist",
2974
- ".turbo"
2975
- ];
2976
- const defaultWalkFiles$1 = (dir) => walkFilesSync(dir, { skip: SKIP_DIRS$1 });
2977
- function isBinary(content) {
2978
- for (let i = 0; i < Math.min(content.length, 8e3); i++) if (content.charCodeAt(i) === 0) return true;
2979
- return false;
2980
- }
2981
- function patchFiles(dir, variants, description, homepage, runtimeEnvironment, print, readFn, writeFn, walkFn) {
2982
- const patched = [];
2983
- for (const filepath of walkFn(dir)) {
2984
- let content;
2985
- try {
2986
- content = readFn(filepath);
2987
- } catch {
2988
- continue;
3081
+ add("channels", "channels in their category, with topics", channelProblems, declaredChannels.length === 0 ? "none declared" : `${declaredChannels.length} of ${declaredChannels.length} as declared`);
3082
+ const withWebhook = declaredChannels.filter(({ channel }) => channel.webhook);
3083
+ const webhookProblems = [];
3084
+ for (const { cat, channel } of withWebhook) {
3085
+ const name = (channel.webhook === true ? {} : channel.webhook).name ?? channel.name;
3086
+ const { match } = pick(channel.name, cat);
3087
+ if (!match) webhookProblems.push(`#${shown(channel.name)}: no channel to hold the webhook`);
3088
+ else if (match.webhooksError) webhookProblems.push(`#${shown(channel.name)}: could not list its webhooks (${shown(match.webhooksError)})`);
3089
+ else if (!match.webhooks.some((w) => same(w, name))) webhookProblems.push(`#${shown(channel.name)}: no webhook named ${shown(name)}`);
2989
3090
  }
2990
- if (isBinary(content)) continue;
2991
- const original = content;
2992
- for (const [search, replacement] of variants) content = content.split(search).join(replacement);
2993
- if (description !== void 0) {
2994
- content = content.split("<description>").join(description);
2995
- content = content.replace(/(<!-- holocron:description -->)[\s\S]*?(<!-- \/holocron:description -->)/g, `$1\n${description}\n$2`);
3091
+ add("webhooks", "webhooks on their channels", webhookProblems, withWebhook.length === 0 ? "none declared" : `${withWebhook.length} of ${withWebhook.length} exist`);
3092
+ const roles = layout.roles ?? [];
3093
+ const roleProblems = [];
3094
+ if (inspected.rolesError) roleProblems.push(`could not read the roles (${shown(inspected.rolesError)})`);
3095
+ for (const role of inspected.rolesError ? [] : roles) {
3096
+ const named = inspected.roles.filter((r) => same(r.name, role.name));
3097
+ const actual = named[0];
3098
+ if (named.length > 1) roleProblems.push(`${shown(role.name)}: ${named.length} roles have this name; setup cannot tell which to manage`);
3099
+ else if (!actual) roleProblems.push(`${shown(role.name)}: missing`);
3100
+ 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}`);
2996
3101
  }
2997
- if (homepage !== void 0) content = content.split("<homepage>").join(homepage);
2998
- if (runtimeEnvironment !== void 0) content = content.split("<runtime_environment>").join(runtimeEnvironment);
2999
- content = content.replace(/\n?<!-- holocron:template-only -->[\s\S]*?<!-- \/holocron:template-only -->\n?/g, "");
3000
- if (content !== original) {
3001
- writeFn(filepath, content);
3002
- print(` ✓ ${path.relative(dir, filepath)}`);
3003
- patched.push(filepath);
3102
+ add("roles", "roles exist and are mentionable", roleProblems, roles.length === 0 ? "none declared" : `${roles.length} of ${roles.length} as declared`);
3103
+ const appearance = [];
3104
+ if (!inspected.rolesError) for (const role of roles) {
3105
+ const actual = inspected.roles.find((r) => same(r.name, role.name));
3106
+ if (!actual) {
3107
+ appearance.push(`${shown(role.name)}: not checked, the role is missing`);
3108
+ continue;
3109
+ }
3110
+ 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)}`);
3111
+ 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}`);
3112
+ const reported = actual.permissions;
3113
+ if (role.permissions !== void 0) {
3114
+ if (reported === void 0) appearance.push(`${shown(role.name)}: the provider did not report its permissions`);
3115
+ else {
3116
+ const have = new Set(reported.map((n) => n.toLowerCase()));
3117
+ const want = new Set(role.permissions.map((n) => n.toLowerCase()));
3118
+ const missing = role.permissions.filter((n) => !have.has(n.toLowerCase()));
3119
+ const extra = reported.filter((n) => !want.has(n.toLowerCase()));
3120
+ if (missing.length > 0) appearance.push(`${shown(role.name)}: missing permissions ${missing.map(shown).join(", ")}`);
3121
+ if (extra.length > 0) appearance.push(`${shown(role.name)}: has extra permissions ${extra.map(shown).join(", ")}`);
3122
+ }
3123
+ }
3124
+ }
3125
+ const styled = roles.filter((r) => r.color !== void 0 || r.hoist !== void 0 || r.permissions !== void 0);
3126
+ 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`);
3127
+ const accessProblems = [];
3128
+ const compare = (label, want, have) => {
3129
+ if (!have) {
3130
+ accessProblems.push(`${label}: the provider did not report its access`);
3131
+ return;
3132
+ }
3133
+ if (have === "custom") {
3134
+ accessProblems.push(`${label}: has permission overwrites that are not an access the layout can state; re-run setup to replace them`);
3135
+ return;
3136
+ }
3137
+ for (const field of ["view", "post"]) {
3138
+ const wanted = want[field] ?? "everyone";
3139
+ if (accessKey(wanted) !== accessKey(have[field])) accessProblems.push(`${label}: ${field} is ${accessText(have[field])} but the layout says ${accessText(wanted)}`);
3140
+ }
3141
+ };
3142
+ let accessDeclared = 0;
3143
+ for (const cat of categories) {
3144
+ if (cat.access) {
3145
+ accessDeclared++;
3146
+ if (!inspected.categories.some((name) => same(name, cat.name))) accessProblems.push(`${shown(cat.name)}: not checked, the category is missing`);
3147
+ else {
3148
+ const have = Object.entries(inspected.categoryAccess ?? {}).find(([n]) => same(n, cat.name))?.[1];
3149
+ compare(shown(cat.name), cat.access, have);
3150
+ }
3151
+ }
3152
+ for (const channel of cat.channels ?? []) {
3153
+ if (!cat.access && !channel.access) continue;
3154
+ accessDeclared++;
3155
+ const want = {
3156
+ ...cat.access,
3157
+ ...channel.access
3158
+ };
3159
+ const { match } = pick(channel.name, cat.name);
3160
+ if (!match) accessProblems.push(`#${shown(channel.name)}: not checked, the channel is missing`);
3161
+ else compare(`#${shown(channel.name)}`, want, match.access);
3162
+ }
3004
3163
  }
3164
+ add("access", ACCESS_TITLE, inspected.rolesError && accessDeclared > 0 ? ["not checked: could not read the roles"] : accessProblems, accessDeclared === 0 ? "none declared" : `${accessDeclared} of ${accessDeclared} as declared`);
3005
3165
  }
3006
- return patched;
3007
- }
3008
- function preflight$1() {
3009
- const result = spawnSync("gh", ["--version"], { encoding: "utf8" });
3010
- if (result.error != null || result.status !== 0) throw new NewError("`gh` CLI is not installed or not on PATH. Install it from https://cli.github.com");
3011
- }
3012
- function defaultExec$3(cmd, args, opts) {
3013
- execFileSync(cmd, args, {
3014
- cwd: opts.cwd,
3015
- stdio: opts.stdio,
3016
- ...opts.env && Object.keys(opts.env).length > 0 ? { env: {
3017
- ...process.env,
3018
- ...opts.env
3019
- } } : {}
3166
+ 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] : [])];
3167
+ if (wanted.length === 0) checks.push({
3168
+ id: "secrets",
3169
+ title: SECRETS_TITLE,
3170
+ status: "pass",
3171
+ detail: "none declared",
3172
+ problems: []
3020
3173
  });
3174
+ else if (!loader.has("secrets")) failed("secrets", SECRETS_TITLE, whyMissing(loader, "secrets", "no `secrets` provider to check them with"));
3175
+ else {
3176
+ const org = input.context.org;
3177
+ const scope = input.scope ?? (org ? {
3178
+ kind: "organization",
3179
+ name: org
3180
+ } : void 0);
3181
+ if (!scope) failed("secrets", SECRETS_TITLE, "no org to look under — pass --scope or set `org` in holocron.config.ts");
3182
+ else try {
3183
+ const have = new Set((await loader.get("secrets").listSecrets(scope)).map((n) => n.toUpperCase()));
3184
+ const clash = layoutSecretProblem(layout);
3185
+ 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`);
3186
+ } catch (err) {
3187
+ failed("secrets", SECRETS_TITLE, describeProviderError(err));
3188
+ }
3189
+ }
3190
+ if (!input.builtOnly) for (const planned of PLANNED_CHECKS) checks.push({
3191
+ id: planned.id,
3192
+ title: planned.title,
3193
+ status: "fail",
3194
+ detail: `not built yet (#1098, step ${planned.step})`,
3195
+ problems: [],
3196
+ step: planned.step
3197
+ });
3198
+ const width = Math.max(...checks.map((c) => c.title.length));
3199
+ for (const check of checks) {
3200
+ print(statusRow(check.status === "pass" ? "ok" : "fail", [check.title.padEnd(width), check.detail]));
3201
+ for (const problem of check.problems) print(` - ${problem}`);
3202
+ }
3203
+ const summary = summarise();
3204
+ print("");
3205
+ print(style.header(`${summary.pass} ok, ${summary.fail} fail, ${summary.skip} skipped`));
3206
+ return finish();
3021
3207
  }
3022
- function keychainLookup(key) {
3023
- const result = spawnSync("security", [
3024
- "find-generic-password",
3025
- "-s",
3026
- "com.theholocron.cli",
3027
- "-a",
3028
- key,
3029
- "-w"
3030
- ], { encoding: "utf8" });
3031
- return result.status === 0 ? result.stdout?.trim() || void 0 : void 0;
3032
- }
3033
- function defaultReadFile(filepath) {
3034
- return readFileSync(filepath, "utf-8");
3035
- }
3036
- function defaultWriteFile(filepath, content) {
3037
- mkdirSync(path.dirname(filepath), { recursive: true });
3038
- writeFileSync(filepath, content, "utf-8");
3208
+ //#endregion
3209
+ //#region src/commands/monitoring-check/monitoring-check.ts
3210
+ /**
3211
+ * `holocron monitoring check <url>` — runs every configured `monitoring`
3212
+ * check against an arbitrary URL instead of each check's own configured
3213
+ * one (holocron#1010).
3214
+ *
3215
+ * Built for CI: the `delivery.previewCheck` task runs this against a
3216
+ * PR's Vercel preview deployment, reusing the exact same check
3217
+ * definitions already in `holocron.config.ts` for production — no
3218
+ * vendor API call, no separate checks-as-code file to keep in sync.
3219
+ *
3220
+ * Split into two pieces (holocron#1030/#1032 review) so a caller that
3221
+ * needs to attach a credential to the request (e.g. Vercel's Deployment
3222
+ * Protection bypass header) can do so in a process that never loads a
3223
+ * PR's own `holocron.config.ts` at all:
3224
+ * - {@link resolveMonitoringChecks} — loads config/plugins, returns the
3225
+ * configured `MonitoringCheckInput[]`. Untrusted: this executes
3226
+ * whatever code a PR's own config file contains. Never give this
3227
+ * process a credential worth protecting.
3228
+ * - {@link reportChecks} — takes an already-resolved check list (no
3229
+ * config loading at all) and runs/report them against a URL. Safe
3230
+ * to run with a credential present, since nothing here executes
3231
+ * PR-authored code.
3232
+ * `runMonitoringCheck` composes both, unchanged, for every caller that
3233
+ * doesn't need the split (the plain CLI invocation, tests, anywhere a
3234
+ * credential isn't in play).
3235
+ */
3236
+ /**
3237
+ * Loads config/plugins and returns the configured `monitoring` checks —
3238
+ * the untrusted half of `monitoring check`. Executes a PR's own
3239
+ * `holocron.config.ts` (and whatever it imports); never run this with a
3240
+ * credential in the process environment that a malicious config could
3241
+ * read and exfiltrate.
3242
+ */
3243
+ async function resolveMonitoringChecks(input) {
3244
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3245
+ await loader.load();
3246
+ assertPluginsResolvable(loader, "monitoring check");
3247
+ return [...loader.get("monitoring").checks];
3039
3248
  }
3040
- async function runNew(input) {
3041
- const cwd = input.cwd ?? process.cwd();
3042
- const org = input.org ?? "theholocron";
3249
+ /**
3250
+ * Runs an already-resolved check list against `url` and reports
3251
+ * pass/fail — the trusted half of `monitoring check`. Takes plain data,
3252
+ * never touches `holocron.config.ts` or any plugin: safe to run with a
3253
+ * credential (in `headers` or elsewhere in the process env) present.
3254
+ */
3255
+ async function reportChecks(input) {
3043
3256
  const print = input.print ?? ((line) => console.log(line));
3044
3257
  const logger = input.logger ?? getLogger();
3045
- const execFn = input.exec ?? defaultExec$3;
3046
- const readFn = input.readFile ?? defaultReadFile;
3047
- const writeFn = input.writeFile ?? defaultWriteFile;
3048
- const walkFn = input.walkFiles ?? defaultWalkFiles$1;
3049
- preflight$1();
3050
- const templateRepo = `${org}/${input.type}-template`;
3051
- const newRepo = `${org}/${input.name}`;
3052
- const repoDir = path.join(cwd, input.name);
3053
- logger.info({
3054
- repo: newRepo,
3055
- template: templateRepo,
3056
- dryRun: input.dryRun || void 0
3057
- }, "new: start");
3058
- if (input.dryRun) {
3059
- print(` Would create ${newRepo} from template ${templateRepo}`);
3060
- print(` Would clone to ${repoDir}`);
3061
- print(` Would patch all casing variants of "${input.type}-template" → "${input.name}"`);
3062
- if (input.description) print(` Would replace <description> → "${input.description}"`);
3063
- if (input.homepage) print(` Would replace <homepage> → "${input.homepage}"`);
3064
- if (input.runtimeEnvironment) print(` Would replace <runtime_environment> → "${input.runtimeEnvironment}"`);
3065
- print(` Would generate holocron.config.ts`);
3066
- logger.info({
3067
- repo: newRepo,
3068
- status: "dry-run"
3069
- }, "new: done");
3070
- return { status: "dry-run" };
3071
- }
3072
- if (existsSync(repoDir)) throw new NewError(`\`${repoDir}\` already exists — delete it or pick a different name.`);
3073
- print(` Creating ${newRepo} from template ${templateRepo}…`);
3074
- const visibility = input.openSource ? "--public" : "--private";
3075
- try {
3076
- execFn("gh", [
3077
- "repo",
3078
- "create",
3079
- newRepo,
3080
- `--template=${templateRepo}`,
3081
- visibility,
3082
- "--clone"
3083
- ], {
3084
- cwd,
3085
- stdio: "inherit"
3086
- });
3087
- } catch (err) {
3088
- throw new NewError(`gh repo create failed: ${errorMessage(err)}`);
3089
- }
3090
- if (input.isTemplate) try {
3091
- execFn("gh", [
3092
- "repo",
3093
- "edit",
3094
- newRepo,
3095
- "--template=true"
3096
- ], {
3097
- cwd,
3098
- stdio: "inherit"
3099
- });
3100
- } catch {}
3101
- try {
3102
- execFn("gh", [
3103
- "repo",
3104
- "edit",
3105
- newRepo,
3106
- `--visibility=${input.openSource ? "public" : "private"}`
3107
- ], {
3108
- cwd,
3109
- stdio: "inherit"
3258
+ const run = input.runCheck ?? runCheckAgainstUrl;
3259
+ print(`Holocron monitoring check — against ${input.url}`);
3260
+ if (input.checks.length === 0) {
3261
+ print(`✗ no monitoring checks configured — nothing to validate`);
3262
+ logger.warn({ url: input.url }, "monitoring check: no checks configured");
3263
+ return {
3264
+ status: "fail",
3265
+ checks: []
3266
+ };
3267
+ }
3268
+ const checks = [];
3269
+ for (const checkInput of input.checks) {
3270
+ const result = await run(checkInput, {
3271
+ url: input.url,
3272
+ ...input.headers ? { headers: input.headers } : {}
3110
3273
  });
3111
- } catch {}
3112
- let templateSlug = `${input.type}-template`;
3113
- const pkgJsonPath = path.join(repoDir, "package.json");
3114
- if (existsSync(pkgJsonPath)) try {
3115
- const pkg = JSON.parse(readFn(pkgJsonPath));
3116
- if (typeof pkg.name === "string") templateSlug = pkg.name.split("/").at(-1) || templateSlug;
3117
- } catch {}
3118
- print(` Detected template slug: ${templateSlug}`);
3119
- print(` Patching files…`);
3120
- const variants = deriveVariants(templateSlug, input.name);
3121
- variants.unshift([`theholocron/${templateSlug}`, `${org}/${input.name}`]);
3122
- const filesPatched = patchFiles(repoDir, variants, input.description, input.homepage, input.runtimeEnvironment, print, readFn, writeFn, walkFn);
3123
- print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
3124
- if (input.description !== void 0 || input.homepage !== void 0) try {
3125
- const raw = readFn(pkgJsonPath);
3126
- const pkg = JSON.parse(raw);
3127
- if (input.description !== void 0) pkg["description"] = input.description;
3128
- if (input.homepage !== void 0) pkg["homepage"] = input.homepage;
3129
- writeFn(pkgJsonPath, stringifyJson(pkg, { like: raw }));
3130
- print(` ✓ package.json (description/homepage)`);
3131
- } catch {}
3132
- const displayName = input.name.split("-").map(cap).join(" ");
3133
- const rootTsconfigPath = path.join(repoDir, "tsconfig.json");
3134
- try {
3135
- const rawTsconfig = readFn(rootTsconfigPath);
3136
- const tsconfig = JSON.parse(rawTsconfig);
3137
- if (typeof tsconfig["display"] === "string") {
3138
- tsconfig["display"] = displayName;
3139
- writeFn(rootTsconfigPath, stringifyJson(tsconfig, { like: rawTsconfig }));
3140
- print(` ✓ tsconfig.json (display)`);
3141
- }
3142
- } catch {}
3143
- const configContent = generateHolocronConfig({
3144
- name: input.name,
3145
- type: input.type,
3146
- org,
3147
- description: input.description,
3148
- homepage: input.homepage,
3149
- vaultProvider: input.vaultProvider,
3150
- vaultProject: input.vaultProject,
3151
- vaultConfig: input.vaultConfig,
3152
- deploymentProvider: input.deploymentProvider,
3153
- agent: input.agent,
3154
- runtimeEnvironment: input.runtimeEnvironment,
3155
- protection: input.protection,
3156
- openSource: input.openSource,
3157
- usesExternalPackages: input.usesExternalPackages,
3158
- topics: input.topics,
3159
- skills: input.skills
3160
- });
3161
- writeFn(path.join(repoDir, "holocron.config.ts"), configContent);
3162
- print(` Generated holocron.config.ts`);
3163
- execFn("git", ["add", "-A"], {
3164
- cwd: repoDir,
3165
- stdio: "inherit"
3166
- });
3167
- execFn("git", [
3168
- "commit",
3169
- "-s",
3170
- "-m",
3171
- `chore: bootstrap from ${templateSlug}`
3172
- ], {
3173
- cwd: repoDir,
3174
- stdio: "inherit"
3175
- });
3176
- if (!input.noVerify) {
3177
- print("");
3178
- print(" Installing dependencies…");
3179
- try {
3180
- execFn("pnpm", ["install"], {
3181
- cwd: repoDir,
3182
- stdio: "inherit"
3274
+ if (result.requestError) {
3275
+ print(` ✗ ${checkInput.name} (request failed: ${result.requestError})`);
3276
+ checks.push({
3277
+ name: checkInput.name,
3278
+ passed: false,
3279
+ failures: [],
3280
+ requestError: result.requestError
3183
3281
  });
3184
- } catch (err) {
3185
- print(` ✗ pnpm install failed — ${errorMessage(err)}`);
3186
- return {
3187
- status: "fail",
3188
- repoDir,
3189
- filesPatched,
3190
- message: "pnpm install failed; inspect output above"
3191
- };
3192
- }
3193
- print("");
3194
- print(" Running holocron repo setup…");
3195
- try {
3196
- const setupArgs = ["setup"];
3197
- const setupEnv = {};
3198
- const adminToken = input.token ?? keychainLookup("github.admin");
3199
- if (adminToken) setupArgs.push("--token", adminToken);
3200
- if (!env.get("HOLOCRON_ORG_TOKEN")) {
3201
- const orgToken = keychainLookup("github.org");
3202
- if (orgToken) setupEnv["HOLOCRON_ORG_TOKEN"] = orgToken;
3203
- }
3204
- if (!env.get("HOLOCRON_DEPLOY_TOKEN")) {
3205
- 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
3282
+ } else if (result.passed) {
3283
+ print(` ✓ ${checkInput.name}`);
3284
+ checks.push({
3285
+ name: checkInput.name,
3286
+ passed: true,
3287
+ failures: []
3288
+ });
3289
+ } else {
3290
+ print(` ✗ ${checkInput.name}`);
3291
+ for (const failure of result.failures) print(` ${failure}`);
3292
+ checks.push({
3293
+ name: checkInput.name,
3294
+ passed: false,
3295
+ failures: result.failures
3212
3296
  });
3213
- } catch {
3214
- print(" ✗ holocron repo setup failed — run it manually after checking your config");
3215
3297
  }
3216
3298
  }
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`);
3299
+ const failCount = checks.filter((c) => !c.passed).length;
3300
+ const status = failCount === 0 ? "ok" : "fail";
3301
+ print(`${status === "ok" ? "✓" : "✗"} ${checks.length - failCount} passed, ${failCount} failed`);
3227
3302
  logger.info({
3228
- repo: newRepo,
3229
- repoDir,
3230
- filesPatched,
3231
- status: "ok"
3232
- }, "new: done");
3303
+ url: input.url,
3304
+ total: checks.length,
3305
+ failed: failCount,
3306
+ status
3307
+ }, "monitoring check: done");
3233
3308
  return {
3234
- status: "ok",
3235
- repoDir,
3236
- filesPatched
3309
+ status,
3310
+ checks
3237
3311
  };
3238
3312
  }
3313
+ /**
3314
+ * `resolveMonitoringChecks` + `reportChecks` in one call — the plain CLI
3315
+ * contract, unchanged. Only safe to run with a credential present when
3316
+ * the config being loaded is trusted (no PR in the loop); CI's
3317
+ * `delivery.previewCheck` uses the two halves separately instead.
3318
+ */
3319
+ async function runMonitoringCheck(input) {
3320
+ return reportChecks({
3321
+ checks: await resolveMonitoringChecks({
3322
+ loaded: input.loaded,
3323
+ context: input.context,
3324
+ loader: input.loader
3325
+ }),
3326
+ url: input.url,
3327
+ ...input.headers ? { headers: input.headers } : {},
3328
+ ...input.print ? { print: input.print } : {},
3329
+ ...input.logger ? { logger: input.logger } : {},
3330
+ ...input.runCheck ? { runCheck: input.runCheck } : {}
3331
+ });
3332
+ }
3239
3333
  //#endregion
3240
- //#region src/commands/notifications-channel-add/notifications-channel-add.ts
3334
+ //#region src/commands/monitoring-resolve-checks/monitoring-resolve-checks.ts
3241
3335
  /**
3242
- * `holocron notifications channel add` — create one channel (and, with
3243
- * `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
3336
+ * `holocron monitoring resolve-checks` — loads config/plugins and writes
3337
+ * the configured `monitoring` checks to a file as JSON (holocron#1030/
3338
+ * #1032 review).
3244
3339
  *
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.
3340
+ * The untrusted half of the `delivery.previewCheck` split: this process
3341
+ * executes a PR's own `holocron.config.ts`, so it must never run with a
3342
+ * credential worth protecting (e.g. a Deployment Protection bypass
3343
+ * secret) present in its environment. `monitoring run-resolved` is the
3344
+ * trusted half — it reads this command's output file and performs the
3345
+ * actual authenticated request, with no config loading at all.
3249
3346
  */
3347
+ async function runMonitoringResolveChecks(input) {
3348
+ const print = input.print ?? ((line) => console.log(line));
3349
+ const checks = await resolveMonitoringChecks({
3350
+ loaded: input.loaded,
3351
+ context: input.context,
3352
+ loader: input.loader
3353
+ });
3354
+ await writeFile(input.outFile, JSON.stringify(checks), "utf8");
3355
+ print(`Resolved ${checks.length} monitoring check(s) → ${input.outFile}`);
3356
+ }
3357
+ //#endregion
3358
+ //#region src/commands/monitoring-run-resolved/monitoring-run-resolved.ts
3250
3359
  /**
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.
3360
+ * `holocron monitoring run-resolved <url>` — runs an already-resolved
3361
+ * check list (no `holocron.config.ts` loading at all) against `url`
3362
+ * (holocron#1030/#1032 review).
3363
+ *
3364
+ * The trusted half of the `delivery.previewCheck` split, paired with
3365
+ * `monitoring resolve-checks` (the untrusted half, which loads config).
3366
+ * Safe to run with a credential present, since nothing here executes
3367
+ * PR-authored code — but a credential is only ever attached after
3368
+ * `verifyVercelDeploymentProject` confirms `url` actually belongs to
3369
+ * `vercelProject`: a URL merely ending in `.vercel.app` proves nothing,
3370
+ * since anyone can register their own Vercel project (sourcery review
3371
+ * on holocron#1032). No `vercelProject` given at all → the credential
3372
+ * is never attached, regardless of whether one is configured.
3254
3373
  */
3255
- function defaultSecretName(channel) {
3256
- return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
3374
+ /**
3375
+ * The shared gate every caller that might attach the Deployment
3376
+ * Protection bypass header goes through — `monitoring check`'s direct
3377
+ * CLI invocation included (sourcery review on holocron#1032 called this
3378
+ * out explicitly: verifying only in `delivery.previewCheck`'s own split
3379
+ * commands and not here would leave the exact same unverified-host
3380
+ * problem reachable by hand). No `vercelProject` → never attach
3381
+ * anything, regardless of whether a `bypassSecret` happens to be
3382
+ * configured. `vercelProject` given but verification fails or no token
3383
+ * is available → `undefined` AND a printed reason, never a thrown
3384
+ * error — callers decide for themselves whether that's fatal.
3385
+ */
3386
+ async function resolveVercelBypassHeaders(input) {
3387
+ const print = input.print ?? ((line) => console.log(line));
3388
+ if (!input.vercelProject) return { verified: true };
3389
+ if (!input.vercelToken) {
3390
+ print(`✗ --vercel-project was given but no Vercel token is available to verify deployment ownership`);
3391
+ return { verified: false };
3392
+ }
3393
+ if (!await (input.verify ?? verifyVercelDeploymentProject)(input.url, input.vercelProject, input.vercelToken, { ...input.vercelTeamId ? { teamId: input.vercelTeamId } : {} })) {
3394
+ 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`);
3395
+ return { verified: false };
3396
+ }
3397
+ return input.bypassSecret ? {
3398
+ verified: true,
3399
+ headers: { "x-vercel-protection-bypass": input.bypassSecret }
3400
+ } : { verified: true };
3401
+ }
3402
+ async function runMonitoringRunResolved(input) {
3403
+ const { headers, verified } = await resolveVercelBypassHeaders(input);
3404
+ if (!verified) return {
3405
+ status: "fail",
3406
+ checks: []
3407
+ };
3408
+ return reportChecks({
3409
+ checks: input.checks,
3410
+ url: input.url,
3411
+ ...headers ? { headers } : {},
3412
+ ...input.print ? { print: input.print } : {},
3413
+ ...input.logger ? { logger: input.logger } : {},
3414
+ ...input.runCheck ? { runCheck: input.runCheck } : {}
3415
+ });
3257
3416
  }
3417
+ //#endregion
3418
+ //#region src/commands/new/new.ts
3258
3419
  /**
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.
3420
+ * `holocron new repo <type> <name>` — create a GitHub repo from a template and
3421
+ * bootstrap it by replacing all template-slug casing variants with the new
3422
+ * project name, then generate a `holocron.config.ts` from the answers given
3423
+ * during the interactive wizard.
3424
+ *
3425
+ * Flow:
3426
+ * 1. Preflight — verify `gh` CLI is available.
3427
+ * 2. Resolve type, name, description, homepage, vault/deployment/agent options.
3428
+ * 3. `gh repo create <org>/<name> --template <org>/<type>-template --private --clone`
3429
+ * → clones to `<cwd>/<name>/`
3430
+ * 4. Detect template slug from cloned package.json.
3431
+ * 5. Replace all casing variants of the slug across every text file.
3432
+ * 6. Replace `<description>` and `<homepage>` placeholders.
3433
+ * 7. Generate and write `holocron.config.ts` based on wizard answers.
3434
+ * 8. Commit the patched files (-s for DCO).
3435
+ * 9. Unless --no-verify: `pnpm install` in the new repo.
3436
+ * 10. Print next steps.
3262
3437
  */
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);
3438
+ var NewError = class extends Error {
3439
+ name = "NewError";
3440
+ };
3441
+ function cap(word) {
3442
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
3267
3443
  }
3268
3444
  /**
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.
3445
+ * Derive all common casing variants of a kebab-case slug (e.g.
3446
+ * "cli-template") and map each to the corresponding form of a
3447
+ * kebab-case name (e.g. "my-tool").
3448
+ *
3449
+ * Returns search→replacement pairs, deduplicating single-word slugs
3450
+ * that would otherwise produce identical entries.
3274
3451
  */
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;
3452
+ function deriveVariants(slug, name) {
3453
+ const sw = slug.split("-");
3454
+ const nw = name.split("-");
3455
+ const pairs = [
3456
+ [slug, name],
3457
+ [sw.join("_"), nw.join("_")],
3458
+ [sw.join("_").toUpperCase(), nw.join("_").toUpperCase()],
3459
+ [sw.map(cap).join(""), nw.map(cap).join("")],
3460
+ [sw[0].toLowerCase() + sw.slice(1).map(cap).join(""), nw[0].toLowerCase() + nw.slice(1).map(cap).join("")],
3461
+ [sw.map(cap).join(" "), nw.map(cap).join(" ")]
3462
+ ];
3463
+ const seen = /* @__PURE__ */ new Set();
3464
+ return pairs.filter(([s]) => {
3465
+ if (seen.has(s)) return false;
3466
+ seen.add(s);
3467
+ return true;
3468
+ });
3295
3469
  }
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];
3470
+ /** Validate a repo name: must be lowercase kebab-case. Returns `true` on success or an error string. */
3471
+ function validateRepoName(v) {
3472
+ return /^[a-z][a-z0-9-]*$/.test(v.trim()) ? true : "Must be lowercase kebab-case (e.g. my-tool)";
3311
3473
  }
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
- });
3474
+ /** Parse a comma-separated topics string into a trimmed, non-empty array. */
3475
+ function parseTopics(raw) {
3476
+ return raw ? String(raw).split(",").map((t) => t.trim()).filter(Boolean) : [];
3318
3477
  }
3319
- const names = (ps) => ps.map((p) => p.providerName).join(", ");
3320
3478
  /**
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).
3479
+ * Generate the content of `holocron.config.ts` for a newly-scaffolded repo.
3480
+ * Uses the `node()` preset from `@theholocron/holocron-config` as the baseline
3481
+ * and layers in the provider/agent choices made during the wizard.
3327
3482
  */
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;
3483
+ function generateHolocronConfig(opts) {
3484
+ const lines = [
3485
+ `import { defineConfig } from "@theholocron/cli";`,
3486
+ `import { compose, node, typecheck } from "@theholocron/holocron-config";`,
3487
+ ``,
3488
+ `const preset = compose(node(), typecheck());`,
3489
+ `export default defineConfig({`,
3490
+ `\t...preset,`
3491
+ ];
3492
+ if (opts.description) lines.push(`\tdescription: ${JSON.stringify(opts.description)},`);
3493
+ if (opts.homepage) lines.push(`\thomepage: ${JSON.stringify(opts.homepage)},`);
3494
+ const topics = opts.topics?.length ? opts.topics : [];
3495
+ const hasRuntimeOverride = opts.runtimeEnvironment != null && opts.runtimeEnvironment !== "node";
3496
+ if (opts.org != null) {
3497
+ const repoName = `${opts.org}/${opts.name}`;
3498
+ lines.push(`\trepo: {`);
3499
+ lines.push(`\t\t...preset.repo,`);
3500
+ lines.push(`\t\tname: ${JSON.stringify(repoName)},`);
3501
+ lines.push(`\t\tteams: [{ slug: "gatekeepers", permission: "maintain" }],`);
3502
+ lines.push(`\t\ttopics: ${JSON.stringify(topics)},`);
3503
+ if (opts.protection && opts.protection !== "strict") lines.push(`\t\tprotection: ${JSON.stringify(opts.protection)},`);
3504
+ const propParts = [];
3505
+ if (hasRuntimeOverride) propParts.push(`runtime_environment: ${JSON.stringify(opts.runtimeEnvironment)}`);
3506
+ if (opts.openSource === false) propParts.push(`open_source: false`);
3507
+ if (opts.usesExternalPackages === false) propParts.push(`uses_external_packages: false`);
3508
+ if (propParts.length > 0) lines.push(`\t\tproperties: { ...preset.repo?.properties, ${propParts.join(", ")} },`);
3509
+ lines.push(`\t},`);
3510
+ } else if (topics.length > 0 || hasRuntimeOverride) {
3511
+ lines.push(`\trepo: {`);
3512
+ lines.push(`\t\t...preset.repo,`);
3513
+ if (topics.length > 0) lines.push(`\t\ttopics: ${JSON.stringify(topics)},`);
3514
+ if (hasRuntimeOverride) lines.push(`\t\tproperties: { ...preset.repo?.properties, runtime_environment: ${JSON.stringify(opts.runtimeEnvironment)} },`);
3515
+ lines.push(`\t},`);
3341
3516
  }
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;
3517
+ const hasVault = opts.vaultProvider && opts.vaultProvider !== "none";
3518
+ const hasDeployment = opts.deploymentProvider && opts.deploymentProvider !== "none";
3519
+ if (hasVault || hasDeployment) {
3520
+ lines.push(`\tproviders: {`);
3521
+ lines.push(`\t\t...preset.providers,`);
3522
+ if (opts.vaultProvider === "doppler") {
3523
+ const proj = JSON.stringify(opts.vaultProject ?? opts.name);
3524
+ const cfg = JSON.stringify(opts.vaultConfig ?? "dev");
3525
+ lines.push(`\t\tvault: ["doppler", { project: ${proj}, config: ${cfg} }],`);
3526
+ } else if (opts.vaultProvider === "1password") {
3527
+ const vault = JSON.stringify(opts.vaultProject ?? opts.name);
3528
+ lines.push(`\t\tvault: ["1password", { vault: ${vault} }],`);
3529
+ } else if (opts.vaultProvider === "infisical") {
3530
+ const proj = JSON.stringify(opts.vaultProject ?? opts.name);
3531
+ lines.push(`\t\tvault: ["infisical", { project: ${proj} }],`);
3532
+ }
3533
+ if (opts.deploymentProvider === "vercel") lines.push(`\t\tdeployment: "vercel",`);
3534
+ lines.push(`\t},`);
3346
3535
  }
3536
+ if (opts.agent && opts.agent !== "none") lines.push(`\tagent: ${JSON.stringify(opts.agent)},`);
3537
+ if (opts.skills?.length) lines.push(`\tskills: ${JSON.stringify(opts.skills)},`);
3538
+ lines.push(`});`);
3539
+ lines.push(``);
3540
+ return lines.join("\n");
3347
3541
  }
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) {
3542
+ const SKIP_DIRS$1 = [
3543
+ ".git",
3544
+ "node_modules",
3545
+ "dist",
3546
+ ".turbo"
3547
+ ];
3548
+ const defaultWalkFiles$1 = (dir) => walkFilesSync(dir, { skip: SKIP_DIRS$1 });
3549
+ function isBinary(content) {
3550
+ for (let i = 0; i < Math.min(content.length, 8e3); i++) if (content.charCodeAt(i) === 0) return true;
3551
+ return false;
3552
+ }
3553
+ function patchFiles(dir, variants, description, homepage, runtimeEnvironment, print, readFn, writeFn, walkFn) {
3554
+ const patched = [];
3555
+ for (const filepath of walkFn(dir)) {
3556
+ let content;
3386
3557
  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 });
3558
+ content = readFn(filepath);
3559
+ } catch {
3560
+ continue;
3561
+ }
3562
+ if (isBinary(content)) continue;
3563
+ const original = content;
3564
+ for (const [search, replacement] of variants) content = content.split(search).join(replacement);
3565
+ if (description !== void 0) {
3566
+ content = content.split("<description>").join(description);
3567
+ content = content.replace(/(<!-- holocron:description -->)[\s\S]*?(<!-- \/holocron:description -->)/g, `$1\n${description}\n$2`);
3568
+ }
3569
+ if (homepage !== void 0) content = content.split("<homepage>").join(homepage);
3570
+ if (runtimeEnvironment !== void 0) content = content.split("<runtime_environment>").join(runtimeEnvironment);
3571
+ content = content.replace(/\n?<!-- holocron:template-only -->[\s\S]*?<!-- \/holocron:template-only -->\n?/g, "");
3572
+ if (content !== original) {
3573
+ writeFn(filepath, content);
3574
+ print(` ✓ ${path.relative(dir, filepath)}`);
3575
+ patched.push(filepath);
3390
3576
  }
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
3577
  }
3409
- return outcome;
3578
+ return patched;
3410
3579
  }
3411
- async function runNotificationsChannelAdd(input) {
3580
+ function preflight$1() {
3581
+ const result = spawnSync("gh", ["--version"], { encoding: "utf8" });
3582
+ if (result.error != null || result.status !== 0) throw new NewError("`gh` CLI is not installed or not on PATH. Install it from https://cli.github.com");
3583
+ }
3584
+ function defaultExec$3(cmd, args, opts) {
3585
+ execFileSync(cmd, args, {
3586
+ cwd: opts.cwd,
3587
+ stdio: opts.stdio,
3588
+ ...opts.env && Object.keys(opts.env).length > 0 ? { env: {
3589
+ ...process.env,
3590
+ ...opts.env
3591
+ } } : {}
3592
+ });
3593
+ }
3594
+ function keychainLookup(key) {
3595
+ const result = spawnSync("security", [
3596
+ "find-generic-password",
3597
+ "-s",
3598
+ "com.theholocron.cli",
3599
+ "-a",
3600
+ key,
3601
+ "-w"
3602
+ ], { encoding: "utf8" });
3603
+ return result.status === 0 ? result.stdout?.trim() || void 0 : void 0;
3604
+ }
3605
+ function defaultReadFile(filepath) {
3606
+ return readFileSync(filepath, "utf-8");
3607
+ }
3608
+ function defaultWriteFile(filepath, content) {
3609
+ mkdirSync(path.dirname(filepath), { recursive: true });
3610
+ writeFileSync(filepath, content, "utf-8");
3611
+ }
3612
+ async function runNew(input) {
3613
+ const cwd = input.cwd ?? process.cwd();
3614
+ const org = input.org ?? "theholocron";
3412
3615
  const print = input.print ?? ((line) => console.log(line));
3413
3616
  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)" : ""}`);
3617
+ const execFn = input.exec ?? defaultExec$3;
3618
+ const readFn = input.readFile ?? defaultReadFile;
3619
+ const writeFn = input.writeFile ?? defaultWriteFile;
3620
+ const walkFn = input.walkFiles ?? defaultWalkFiles$1;
3621
+ preflight$1();
3622
+ const templateRepo = `${org}/${input.type}-template`;
3623
+ const newRepo = `${org}/${input.name}`;
3624
+ const repoDir = path.join(cwd, input.name);
3625
+ logger.info({
3626
+ repo: newRepo,
3627
+ template: templateRepo,
3628
+ dryRun: input.dryRun || void 0
3629
+ }, "new: start");
3630
+ if (input.dryRun) {
3631
+ print(` Would create ${newRepo} from template ${templateRepo}`);
3632
+ print(` Would clone to ${repoDir}`);
3633
+ print(` Would patch all casing variants of "${input.type}-template" → "${input.name}"`);
3634
+ if (input.description) print(` Would replace <description> → "${input.description}"`);
3635
+ if (input.homepage) print(` Would replace <homepage> → "${input.homepage}"`);
3636
+ if (input.runtimeEnvironment) print(` Would replace <runtime_environment> → "${input.runtimeEnvironment}"`);
3637
+ print(` Would generate holocron.config.ts`);
3638
+ logger.info({
3639
+ repo: newRepo,
3640
+ status: "dry-run"
3641
+ }, "new: done");
3642
+ return { status: "dry-run" };
3643
+ }
3644
+ if (existsSync(repoDir)) throw new NewError(`\`${repoDir}\` already exists — delete it or pick a different name.`);
3645
+ print(` Creating ${newRepo} from template ${templateRepo}…`);
3646
+ const visibility = input.openSource ? "--public" : "--private";
3420
3647
  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
- } } : {}
3648
+ execFn("gh", [
3649
+ "repo",
3650
+ "create",
3651
+ newRepo,
3652
+ `--template=${templateRepo}`,
3653
+ visibility,
3654
+ "--clone"
3655
+ ], {
3656
+ cwd,
3657
+ stdio: "inherit"
3448
3658
  });
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}`);
3659
+ } catch (err) {
3660
+ throw new NewError(`gh repo create failed: ${errorMessage(err)}`);
3661
+ }
3662
+ if (input.isTemplate) try {
3663
+ execFn("gh", [
3664
+ "repo",
3665
+ "edit",
3666
+ newRepo,
3667
+ "--template=true"
3668
+ ], {
3669
+ cwd,
3670
+ stdio: "inherit"
3671
+ });
3672
+ } catch {}
3673
+ try {
3674
+ execFn("gh", [
3675
+ "repo",
3676
+ "edit",
3677
+ newRepo,
3678
+ `--visibility=${input.openSource ? "public" : "private"}`
3679
+ ], {
3680
+ cwd,
3681
+ stdio: "inherit"
3682
+ });
3683
+ } catch {}
3684
+ let templateSlug = `${input.type}-template`;
3685
+ const pkgJsonPath = path.join(repoDir, "package.json");
3686
+ if (existsSync(pkgJsonPath)) try {
3687
+ const pkg = JSON.parse(readFn(pkgJsonPath));
3688
+ if (typeof pkg.name === "string") templateSlug = pkg.name.split("/").at(-1) || templateSlug;
3689
+ } catch {}
3690
+ print(` Detected template slug: ${templateSlug}`);
3691
+ print(` Patching files…`);
3692
+ const variants = deriveVariants(templateSlug, input.name);
3693
+ variants.unshift([`theholocron/${templateSlug}`, `${org}/${input.name}`]);
3694
+ const filesPatched = patchFiles(repoDir, variants, input.description, input.homepage, input.runtimeEnvironment, print, readFn, writeFn, walkFn);
3695
+ print(` ${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched`);
3696
+ if (input.description !== void 0 || input.homepage !== void 0) try {
3697
+ const raw = readFn(pkgJsonPath);
3698
+ const pkg = JSON.parse(raw);
3699
+ if (input.description !== void 0) pkg["description"] = input.description;
3700
+ if (input.homepage !== void 0) pkg["homepage"] = input.homepage;
3701
+ writeFn(pkgJsonPath, stringifyJson(pkg, { like: raw }));
3702
+ print(` ✓ package.json (description/homepage)`);
3703
+ } catch {}
3704
+ const displayName = input.name.split("-").map(cap).join(" ");
3705
+ const rootTsconfigPath = path.join(repoDir, "tsconfig.json");
3706
+ try {
3707
+ const rawTsconfig = readFn(rootTsconfigPath);
3708
+ const tsconfig = JSON.parse(rawTsconfig);
3709
+ if (typeof tsconfig["display"] === "string") {
3710
+ tsconfig["display"] = displayName;
3711
+ writeFn(rootTsconfigPath, stringifyJson(tsconfig, { like: rawTsconfig }));
3712
+ print(` ✓ tsconfig.json (display)`);
3713
+ }
3714
+ } catch {}
3715
+ const configContent = generateHolocronConfig({
3716
+ name: input.name,
3717
+ type: input.type,
3718
+ org,
3719
+ description: input.description,
3720
+ homepage: input.homepage,
3721
+ vaultProvider: input.vaultProvider,
3722
+ vaultProject: input.vaultProject,
3723
+ vaultConfig: input.vaultConfig,
3724
+ deploymentProvider: input.deploymentProvider,
3725
+ agent: input.agent,
3726
+ runtimeEnvironment: input.runtimeEnvironment,
3727
+ protection: input.protection,
3728
+ openSource: input.openSource,
3729
+ usesExternalPackages: input.usesExternalPackages,
3730
+ topics: input.topics,
3731
+ skills: input.skills
3732
+ });
3733
+ writeFn(path.join(repoDir, "holocron.config.ts"), configContent);
3734
+ print(` Generated holocron.config.ts`);
3735
+ execFn("git", ["add", "-A"], {
3736
+ cwd: repoDir,
3737
+ stdio: "inherit"
3738
+ });
3739
+ execFn("git", [
3740
+ "commit",
3741
+ "-s",
3742
+ "-m",
3743
+ `chore: bootstrap from ${templateSlug}`
3744
+ ], {
3745
+ cwd: repoDir,
3746
+ stdio: "inherit"
3747
+ });
3748
+ if (!input.noVerify) {
3749
+ print("");
3750
+ print(" Installing dependencies…");
3751
+ try {
3752
+ execFn("pnpm", ["install"], {
3753
+ cwd: repoDir,
3754
+ stdio: "inherit"
3755
+ });
3756
+ } catch (err) {
3757
+ print(` ✗ pnpm install failed — ${errorMessage(err)}`);
3458
3758
  return {
3459
3759
  status: "fail",
3460
- channel,
3461
- message
3760
+ repoDir,
3761
+ filesPatched,
3762
+ message: "pnpm install failed; inspect output above"
3462
3763
  };
3463
3764
  }
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
- };
3765
+ print("");
3766
+ print(" Running holocron repo setup…");
3767
+ try {
3768
+ const setupArgs = ["setup"];
3769
+ const setupEnv = {};
3770
+ const adminToken = input.token ?? keychainLookup("github.admin");
3771
+ if (adminToken) setupArgs.push("--token", adminToken);
3772
+ if (!env.get("HOLOCRON_ORG_TOKEN")) {
3773
+ const orgToken = keychainLookup("github.org");
3774
+ if (orgToken) setupEnv["HOLOCRON_ORG_TOKEN"] = orgToken;
3775
+ }
3776
+ if (!env.get("HOLOCRON_DEPLOY_TOKEN")) {
3777
+ const deployToken = keychainLookup("github.deploy");
3778
+ if (deployToken) setupEnv["HOLOCRON_DEPLOY_TOKEN"] = deployToken;
3779
+ }
3780
+ execFn("holocron", setupArgs, {
3781
+ cwd: repoDir,
3782
+ stdio: "inherit",
3783
+ env: setupEnv
3784
+ });
3785
+ } catch {
3786
+ print(" ✗ holocron repo setup failed — run it manually after checking your config");
3787
+ }
3479
3788
  }
3789
+ print("");
3790
+ print(` Scaffolded ${newRepo} (${filesPatched.length} file${filesPatched.length === 1 ? "" : "s"} patched).`);
3791
+ print("");
3792
+ print(" Next:");
3793
+ print(` 1. cd ${repoDir}`);
3794
+ if (input.noVerify) {
3795
+ print(` 2. pnpm install`);
3796
+ print(` 3. holocron repo setup # wire up secrets, teams, labels, etc.`);
3797
+ print(` 4. git push -u origin HEAD`);
3798
+ } else print(` 2. git push -u origin HEAD`);
3799
+ logger.info({
3800
+ repo: newRepo,
3801
+ repoDir,
3802
+ filesPatched,
3803
+ status: "ok"
3804
+ }, "new: done");
3805
+ return {
3806
+ status: "ok",
3807
+ repoDir,
3808
+ filesPatched
3809
+ };
3480
3810
  }
3481
3811
  //#endregion
3482
3812
  //#region src/commands/notifications-setup/notifications-setup.ts
@@ -3492,408 +3822,218 @@ async function runNotificationsChannelAdd(input) {
3492
3822
  * Run it from ONE repo per server — a layout is org-level, and putting it in a
3493
3823
  * shared preset would apply it from every repo.
3494
3824
  */
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
- }
3825
+ /** A channel's access: its category's, with any field the channel states itself laid over it. */
3826
+ function effectiveAccess(category, channel) {
3827
+ if (!category && !channel) return void 0;
3828
+ return {
3829
+ ...category,
3830
+ ...channel
3558
3831
  };
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
3832
  }
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;
3833
+ /** The first role an `access` names that the layout does not declare, or undefined. */
3834
+ function accessRoleProblem(layout) {
3835
+ const declared = new Set((layout.roles ?? []).map((r) => r.name.toLowerCase()));
3836
+ const check = (where, access) => {
3837
+ for (const field of ["view", "post"]) {
3838
+ const value = access?.[field];
3839
+ if (!Array.isArray(value)) continue;
3840
+ const unknown = value.find((name) => !declared.has(name.toLowerCase()));
3841
+ if (unknown !== void 0) return `${where}: \`access.${field}\` names the role \`${unknown}\`, which is not in the layout's roles`;
3842
+ }
3843
+ };
3844
+ const cannotSee = (where, access) => {
3845
+ if (!Array.isArray(access?.view) || !Array.isArray(access?.post)) return void 0;
3846
+ const seen = new Set(access.view.map((r) => r.toLowerCase()));
3847
+ const stuck = access.post.find((r) => !seen.has(r.toLowerCase()));
3848
+ return stuck === void 0 ? void 0 : `${where}: the role \`${stuck}\` may post but is not in \`view\`, so it could not see the channel`;
3849
+ };
3850
+ for (const category of layout.categories ?? []) {
3851
+ const problem = check(`category \`${category.name}\``, category.access);
3852
+ if (problem) return problem;
3853
+ const stuckCategory = cannotSee(`category \`${category.name}\``, category.access);
3854
+ if (stuckCategory) return stuckCategory;
3855
+ for (const channel of category.channels ?? []) {
3856
+ const found = check(`channel \`${channel.name}\``, channel.access);
3857
+ if (found) return found;
3858
+ const stuck = cannotSee(`channel \`${channel.name}\``, effectiveAccess(category.access, channel.access));
3859
+ if (stuck) return stuck;
3860
+ }
3861
+ }
3862
+ }
3863
+ async function runNotificationsSetup(input) {
3864
+ const print = input.print ?? ((line) => console.log(line));
3865
+ const logger = input.logger ?? getLogger();
3866
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3867
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3868
+ await loader.load();
3869
+ assertPluginsResolvable(loader, "notifications setup");
3870
+ const dryRun = input.context.dryRun ?? false;
3871
+ const report = {
3872
+ status: "ok",
3873
+ categories: [],
3874
+ channels: [],
3875
+ roles: [],
3876
+ failures: []
3742
3877
  };
3743
3878
  const refuse = (message, reason = message) => {
3744
- print(` ✗ ${message}`);
3745
- logger.warn({ reason }, "notifications verify");
3879
+ print(` ${style.fail(message)}`);
3880
+ print("");
3881
+ print(style.header("0 ok, 1 fail, 0 skipped"));
3882
+ logger.warn({ reason }, "notifications setup");
3746
3883
  return {
3884
+ ...report,
3747
3885
  status: "fail",
3748
- checks,
3749
- summary: summarise(),
3750
3886
  message
3751
3887
  };
3752
3888
  };
3753
3889
  let notifications;
3754
3890
  try {
3755
- notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.inspect === "function", {
3756
- infinitive: "inspect a server",
3757
- gerund: "inspecting"
3758
- });
3891
+ notifications = pickProvisioner(notificationProviders(loader), input.provider);
3759
3892
  } catch (err) {
3760
- return refuse(describeProviderError(err));
3893
+ return refuse(errorMessage(err));
3761
3894
  }
3762
3895
  const layout = notifications.layout;
3763
3896
  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);
3897
+ const wantsSecrets = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook)) || (layout.roles ?? []).some((r) => r.secret);
3898
+ let secrets;
3899
+ if (wantsSecrets && !dryRun) {
3900
+ 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"));
3901
+ secrets = loader.get("secrets");
3771
3902
  }
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)");
3903
+ const org = input.context.org;
3904
+ const scope = input.scope ?? (org ? {
3905
+ kind: "organization",
3906
+ name: org
3907
+ } : void 0);
3908
+ if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
3909
+ if (secrets && scope && !dryRun) try {
3910
+ await assertSecretsReachable(secrets, scope);
3782
3911
  } catch (err) {
3783
- failed("server", "server", describeProviderError(err));
3912
+ return refuse(errorMessage(err), logReason(err));
3784
3913
  }
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
- };
3914
+ const secretProblem = layoutSecretProblem(layout);
3915
+ if (secretProblem) return refuse(secretProblem);
3916
+ const accessProblem = accessRoleProblem(layout);
3917
+ if (accessProblem) return refuse(accessProblem);
3918
+ const needsCategories = (layout.categories ?? []).find((c) => c.access);
3919
+ if (needsCategories && !notifications.ensureCategory) return refuse(`category \`${needsCategories.name}\` declares \`access\`, but the \`${notifications.providerName}\` provider cannot manage categories`);
3920
+ const providerProblem = notifications.validateLayout?.(layout);
3921
+ if (providerProblem) return refuse(providerProblem);
3922
+ const tally = {
3923
+ ok: 0,
3924
+ skip: 0
3803
3925
  };
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)}"`);
3926
+ const made = (created) => void (created ? tally.ok++ : tally.skip++);
3927
+ const attempt = async (step, fn) => {
3928
+ try {
3929
+ await fn();
3930
+ return true;
3931
+ } catch (err) {
3932
+ const message = describeProviderError(err);
3933
+ print(` ${style.fail(`${step}: ${message}`)}`);
3934
+ report.failures.push({
3935
+ step,
3936
+ message
3937
+ });
3938
+ return false;
3829
3939
  }
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)}`);
3940
+ };
3941
+ if ((layout.roles ?? []).length > 0) print(`\n${style.header("Roles")}`);
3942
+ for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3943
+ if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
3944
+ if (dryRun) {
3945
+ print(` ${style.skip(`would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`)}`);
3946
+ tally.skip++;
3947
+ report.roles.push({
3948
+ name: role.name,
3949
+ created: false,
3950
+ ...role.secret ? { secret: role.secret } : {}
3951
+ });
3952
+ return;
3839
3953
  }
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}`);
3954
+ const result = await notifications.ensureRole({
3955
+ name: role.name,
3956
+ ...role.mentionable !== void 0 ? { mentionable: role.mentionable } : {},
3957
+ ...role.color !== void 0 ? { color: role.color } : {},
3958
+ ...role.hoist !== void 0 ? { hoist: role.hoist } : {},
3959
+ ...role.permissions !== void 0 ? { permissions: role.permissions } : {}
3960
+ });
3961
+ const outcome = {
3962
+ name: role.name,
3963
+ id: result.id,
3964
+ created: result.created,
3965
+ ...result.updated?.length ? { updated: result.updated } : {}
3966
+ };
3967
+ report.roles.push(outcome);
3968
+ const changed = result.created || (result.updated?.length ?? 0) > 0;
3969
+ const verb = result.created ? "created" : changed ? "updated" : "exists ";
3970
+ print(` ${(changed ? style.success : style.skip)(`${verb} role ${role.name} (id ${result.id})${result.updated?.length ? `: ${result.updated.join(", ")}` : ""}`)}`);
3971
+ made(changed);
3972
+ if (role.secret) {
3973
+ await secrets.setSecret(scope, role.secret, result.id);
3974
+ outcome.secret = role.secret;
3975
+ outcome.stored = true;
3976
+ print(` ${style.success(`stored the id of ${role.name} in ${role.secret}`)}`);
3977
+ tally.ok++;
3848
3978
  }
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
3979
  });
3859
- else if (!loader.has("secrets")) failed("secrets", "secrets", whyMissing(loader, "secrets", "no `secrets` provider to check them with"));
3860
- else {
3861
- const org = input.context.org;
3862
- const scope = input.scope ?? (org ? {
3863
- kind: "organization",
3864
- name: org
3865
- } : void 0);
3866
- if (!scope) failed("secrets", "secrets", "no org to look under — pass --scope or set `org` in holocron.config.ts");
3867
- else try {
3868
- const have = new Set((await loader.get("secrets").listSecrets(scope)).map((n) => n.toUpperCase()));
3869
- const clash = layoutSecretProblem(layout);
3870
- add("secrets", "secrets", [...clash ? [shown(clash)] : [], ...wanted.filter((n) => !have.has(n.toUpperCase())).map((n) => `${shown(n)}: missing`)], `${wanted.length} of ${wanted.length} exist`);
3871
- } catch (err) {
3872
- failed("secrets", "secrets", describeProviderError(err));
3980
+ for (const category of layout.categories ?? []) {
3981
+ print(`\n${style.header(category.name)}`);
3982
+ report.categories.push(category.name);
3983
+ const categoryOk = await attempt(`category ${category.name}`, async () => {
3984
+ if (!notifications.ensureCategory) return;
3985
+ if (dryRun) {
3986
+ print(` ${style.skip(`would ensure category ${category.name}`)}`);
3987
+ tally.skip++;
3988
+ return;
3989
+ }
3990
+ const result = await notifications.ensureCategory(category.name, { ...category.access ? { access: category.access } : {} });
3991
+ const changed = result.created || (result.updated?.length ?? 0) > 0;
3992
+ const verb = result.created ? "created" : changed ? "updated" : "exists ";
3993
+ const what = result.updated?.length ? `: ${result.updated.join(", ")}` : "";
3994
+ print(` ${(changed ? style.success : style.skip)(`${verb} category ${category.name}${what}`)}`);
3995
+ made(changed);
3996
+ });
3997
+ const channels = category.channels ?? [];
3998
+ if (!categoryOk && channels.length > 0) {
3999
+ print(` ${style.skip(`skipped ${channels.length} channel${channels.length === 1 ? "" : "s"}: the category failed`)}`);
4000
+ tally.skip += channels.length;
4001
+ continue;
3873
4002
  }
4003
+ for (const channel of channels) await attempt(`channel ${channel.name}`, async () => {
4004
+ const outcome = await provisionChannel({
4005
+ notifications,
4006
+ ...secrets ? { secrets } : {},
4007
+ scope: scope ?? { kind: "repo" },
4008
+ dryRun,
4009
+ print,
4010
+ tally
4011
+ }, {
4012
+ name: channel.name,
4013
+ category: category.name,
4014
+ ...channel.topic ? { topic: channel.topic } : {},
4015
+ ...effectiveAccess(category.access, channel.access) ? { access: effectiveAccess(category.access, channel.access) } : {},
4016
+ ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
4017
+ });
4018
+ report.channels.push(outcome);
4019
+ if (outcome.webhook && !outcome.webhook.stored) report.failures.push({
4020
+ step: `webhook ${outcome.webhook.name}`,
4021
+ message: outcome.webhook.note
4022
+ });
4023
+ });
3874
4024
  }
3875
- if (!input.builtOnly) for (const planned of PLANNED_CHECKS) checks.push({
3876
- id: planned.id,
3877
- title: planned.title,
3878
- status: "planned",
3879
- detail: `not built yet (#1098, step ${planned.step})`,
3880
- problems: [],
3881
- step: planned.step
3882
- });
3883
- const built = checks.filter((c) => c.status !== "planned");
3884
- const width = Math.max(...built.map((c) => c.title.length));
3885
- for (const check of built) {
3886
- print(` ${check.status === "pass" ? "✓" : "✗"} ${check.title.padEnd(width)} ${check.detail}`);
3887
- for (const problem of check.problems) print(` - ${problem}`);
3888
- }
3889
- const planned = checks.filter((c) => c.status === "planned");
3890
- if (planned.length > 0) {
3891
- print("\nNot built yet (each fails until it lands, see #1098):");
3892
- for (const check of planned) print(` ✗ ${String(check.step).padStart(2)} ${check.title}`);
3893
- }
3894
- const summary = summarise();
3895
- print(`\n${summary.pass} passed, ${summary.fail} failed${input.builtOnly ? "" : `, ${summary.planned} not built`}`);
3896
- return finish();
4025
+ if (report.failures.length > 0) report.status = "fail";
4026
+ else if (dryRun) report.status = "dry-run";
4027
+ print("");
4028
+ print(style.header(`${tally.ok} ok, ${report.failures.length} fail, ${tally.skip} skipped`));
4029
+ logger.info({
4030
+ provider: notifications.providerName,
4031
+ channels: report.channels.length,
4032
+ roles: report.roles.length,
4033
+ failures: report.failures.length,
4034
+ status: report.status
4035
+ }, "notifications setup");
4036
+ return report;
3897
4037
  }
3898
4038
  //#endregion
3899
4039
  //#region src/commands/npm-bump-versions/npm-bump-versions.ts
@@ -10033,7 +10173,7 @@ const COMMAND_REGISTRY = [
10033
10173
  supportsDryRun: true
10034
10174
  },
10035
10175
  {
10036
- name: "notifications verify",
10176
+ name: "doctor notifications",
10037
10177
  description: "Check the layout against the server and the secrets store, step by step (read-only)",
10038
10178
  positionals: [],
10039
10179
  supportsDryRun: false
@@ -10865,6 +11005,32 @@ try {
10865
11005
  }).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
10866
11006
  type: "string",
10867
11007
  describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
11008
+ }).command("notifications", "Check the layout against the server and the secrets store, step by step (read-only)", (y) => y.option("scope", {
11009
+ type: "string",
11010
+ describe: "Where the layout's secrets live: \"repo\", \"env=<name>\" or \"org=<name>\" (default: the org)"
11011
+ }).option("provider", {
11012
+ type: "string",
11013
+ describe: "Which notifications provider, when several can inspect"
11014
+ }).option("built-only", {
11015
+ type: "boolean",
11016
+ default: false,
11017
+ describe: "Leave out the features that are not built yet (a routine drift check)"
11018
+ }), async (argv) => {
11019
+ const tokens = tokenContext(argv.token);
11020
+ if (!tokens) return;
11021
+ const loaded = await loadConfig(argv.cwd);
11022
+ applyResolvedConfig(argv, loaded.resolved);
11023
+ if ((await runDoctorNotifications({
11024
+ loaded,
11025
+ context: {
11026
+ repoRoot: argv.cwd,
11027
+ ...tokens,
11028
+ org: resolveOrg(argv, loaded.resolved)
11029
+ },
11030
+ ...argv.scope ? { scope: parseScope(argv.scope) } : {},
11031
+ ...argv.provider ? { provider: argv.provider } : {},
11032
+ ...argv.builtOnly ? { builtOnly: true } : {}
11033
+ })).status === "fail") process.exitCode = 1;
10868
11034
  }), async (argv) => {
10869
11035
  const tokens = tokenContext(argv.token);
10870
11036
  if (!tokens) return;
@@ -11596,32 +11762,6 @@ try {
11596
11762
  ...argv.scope ? { scope: parseScope(argv.scope) } : {},
11597
11763
  ...argv.provider ? { provider: argv.provider } : {}
11598
11764
  })).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
11765
  }).command("$0", false, () => {}, async (argv) => {
11626
11766
  await launchGroupMenu("notifications", argv);
11627
11767
  }), () => {}).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", {