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