@theholocron/cli 5.0.0-alpha.186 → 5.0.0-alpha.188
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +635 -261
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/plugin/capabilities.d.mts +55 -1
- 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,6 +409,7 @@ const COMMAND_CONTEXTS = {
|
|
|
409
409
|
"secrets sync": "workspace",
|
|
410
410
|
"notifications channel add": "workspace",
|
|
411
411
|
"notifications setup": "workspace",
|
|
412
|
+
"doctor notifications": "workspace",
|
|
412
413
|
deploy: "workspace",
|
|
413
414
|
"deploy cleanup-preview": "workspace",
|
|
414
415
|
"monitoring check": "workspace",
|
|
@@ -2402,8 +2403,12 @@ async function runDeployFromFiles(input) {
|
|
|
2402
2403
|
* (newlines and the C1 range, whose 0x9b is an 8-bit CSI) so a crafted body
|
|
2403
2404
|
* can't forge extra report lines or inject terminal escape sequences. */
|
|
2404
2405
|
const MAX_DETAIL_LENGTH = 200;
|
|
2406
|
+
/** Control characters (newlines and the C1 range included) turned into spaces, whitespace collapsed, no length cap. */
|
|
2407
|
+
function stripControl(text) {
|
|
2408
|
+
return text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
|
|
2409
|
+
}
|
|
2405
2410
|
function sanitizeDetail(text) {
|
|
2406
|
-
const stripped = text
|
|
2411
|
+
const stripped = stripControl(text);
|
|
2407
2412
|
return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
|
|
2408
2413
|
}
|
|
2409
2414
|
/**
|
|
@@ -2416,11 +2421,12 @@ function sanitizeDetail(text) {
|
|
|
2416
2421
|
* able to overwhelm or manipulate the terminal.
|
|
2417
2422
|
*/
|
|
2418
2423
|
function describeProviderError(err) {
|
|
2424
|
+
const base = stripControl(errorMessage(err));
|
|
2419
2425
|
if (err instanceof ProviderApiError$1 && err.details) {
|
|
2420
2426
|
const detail = sanitizeDetail(String(err.details));
|
|
2421
|
-
if (detail) return `${
|
|
2427
|
+
if (detail) return `${base}: ${detail}`;
|
|
2422
2428
|
}
|
|
2423
|
-
return
|
|
2429
|
+
return base;
|
|
2424
2430
|
}
|
|
2425
2431
|
/**
|
|
2426
2432
|
* What to put in structured logs: the plain message of the underlying error,
|
|
@@ -2604,28 +2610,557 @@ async function runSmokeCheck(key, provider, impl) {
|
|
|
2604
2610
|
}
|
|
2605
2611
|
return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2606
2612
|
}
|
|
2607
|
-
default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2613
|
+
default: return mk(key, provider, "skip", "loaded (no smoke check defined for this capability)");
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* What the issues row says: how many lifecycle slots (inProgress, inReview, done)
|
|
2618
|
+
* map to something the tracker recognises, and which do not. The token's user and
|
|
2619
|
+
* the repo are not repeated -- `source` already names the account, and a slot
|
|
2620
|
+
* that is not configured is the part someone has to act on.
|
|
2621
|
+
*/
|
|
2622
|
+
function describeLifecycle(report) {
|
|
2623
|
+
const unresolved = report.lifecycle.filter((l) => !l.resolved).map((l) => l.slot);
|
|
2624
|
+
const base = `lifecycle: ${report.lifecycle.length - unresolved.length}/${report.lifecycle.length} slots ready`;
|
|
2625
|
+
return unresolved.length > 0 ? `${base} (unresolved: ${unresolved.join(", ")})` : base;
|
|
2626
|
+
}
|
|
2627
|
+
function mk(capability, provider, status, message, logMessage) {
|
|
2628
|
+
return {
|
|
2629
|
+
capability,
|
|
2630
|
+
provider,
|
|
2631
|
+
status,
|
|
2632
|
+
message,
|
|
2633
|
+
...logMessage !== void 0 ? { logMessage } : {}
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
//#endregion
|
|
2637
|
+
//#region src/commands/notifications-channel-add/notifications-channel-add.ts
|
|
2638
|
+
/**
|
|
2639
|
+
* `holocron notifications channel add` — create one channel (and, with
|
|
2640
|
+
* `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
|
|
2641
|
+
*
|
|
2642
|
+
* Provider-neutral: it drives the `notifications` capability's `ensure*`
|
|
2643
|
+
* methods, so any provider that implements them works. Idempotent — an
|
|
2644
|
+
* existing channel or webhook is left alone, and a webhook URL is never
|
|
2645
|
+
* printed, only stored.
|
|
2646
|
+
*/
|
|
2647
|
+
/**
|
|
2648
|
+
* `HOLOCRON_NOTIFY_WEBHOOK_SYNC` for channel `sync`. Named after the job, not the
|
|
2649
|
+
* provider, so changing providers means re-pointing the same secrets at new
|
|
2650
|
+
* webhook URLs, with no workflow edits.
|
|
2651
|
+
*/
|
|
2652
|
+
function defaultSecretName(channel) {
|
|
2653
|
+
return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* Prefix of the throwaway secret the preflight writes and removes. Every run adds a
|
|
2657
|
+
* random suffix, so it can never overwrite (and then delete) a secret someone already
|
|
2658
|
+
* has; layouts may not use any name under this prefix for their own secrets.
|
|
2659
|
+
*/
|
|
2660
|
+
const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
|
|
2661
|
+
/** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
|
|
2662
|
+
function isReservedSecret(name) {
|
|
2663
|
+
return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Prove the secrets store can be WRITTEN before creating anything that needs it.
|
|
2667
|
+
* A webhook's URL is shown once; creating the webhook and then failing to store
|
|
2668
|
+
* it leaves an orphan the user has to delete by hand. Listing is not enough --
|
|
2669
|
+
* a token can read secrets it cannot write -- so write a throwaway secret and
|
|
2670
|
+
* remove it.
|
|
2671
|
+
*/
|
|
2672
|
+
async function assertSecretsReachable(secrets, scope) {
|
|
2673
|
+
const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
|
|
2674
|
+
try {
|
|
2675
|
+
await secrets.setSecret(scope, probe, "ok");
|
|
2676
|
+
await secrets.deleteSecret(scope, probe);
|
|
2677
|
+
} catch (err) {
|
|
2678
|
+
const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
|
|
2679
|
+
throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks. If a secret named ${probe} was left behind, delete it`, { cause: err });
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
/** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
|
|
2683
|
+
function whyMissing(loader, key, fallback) {
|
|
2684
|
+
const failure = loader.loadFailures().find((f) => f.key === key);
|
|
2685
|
+
return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${describeProviderError(failure.error)})` : fallback;
|
|
2686
|
+
}
|
|
2687
|
+
/** The configured `notifications` providers, or an error that says why there are none (including a plugin that failed to load). */
|
|
2688
|
+
function notificationProviders(loader) {
|
|
2689
|
+
const providers = loader.has("notifications") ? loader.get("notifications") : [];
|
|
2690
|
+
if (providers.length === 0) throw new Error(whyMissing(loader, "notifications", "no `notifications` provider is available — add one to holocron.config.ts"));
|
|
2691
|
+
return providers;
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Pick the provider that can do what the command needs; with several, `--provider`
|
|
2695
|
+
* decides. `can` says what "able" means (a command that only reads needs `inspect`, not
|
|
2696
|
+
* provisioning), and `verb` words the errors.
|
|
2697
|
+
*/
|
|
2698
|
+
function pickProvider(providers, wanted, can, verb) {
|
|
2699
|
+
const capable = providers.filter(can);
|
|
2700
|
+
if (capable.length === 0) throw new Error(`no \`notifications\` provider can ${verb.infinitive} — add one that implements it (for example discord) to holocron.config.ts`);
|
|
2701
|
+
if (wanted) {
|
|
2702
|
+
const match = capable.find((p) => p.providerName === wanted);
|
|
2703
|
+
if (!match) throw new Error(`no ${verb.gerund} \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
|
|
2704
|
+
return match;
|
|
2705
|
+
}
|
|
2706
|
+
if (capable.length > 1) throw new Error(`several \`notifications\` providers can ${verb.infinitive} (${names(capable)}) — pass --provider <name>`);
|
|
2707
|
+
return capable[0];
|
|
2708
|
+
}
|
|
2709
|
+
/** Pick the provider that can provision. Any ensure* method counts: a role-only layout needs no channel support. */
|
|
2710
|
+
function pickProvisioner(providers, wanted) {
|
|
2711
|
+
return pickProvider(providers, wanted, (p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function", {
|
|
2712
|
+
infinitive: "provision",
|
|
2713
|
+
gerund: "provisioning"
|
|
2714
|
+
});
|
|
2715
|
+
}
|
|
2716
|
+
const names = (ps) => ps.map((p) => p.providerName).join(", ");
|
|
2717
|
+
/**
|
|
2718
|
+
* The first reason the layout's secret names cannot work, or undefined. Two things
|
|
2719
|
+
* cannot share one secret: the second write would overwrite the first and callers
|
|
2720
|
+
* would read the wrong value. Punctuation normalising (release-notes vs
|
|
2721
|
+
* release_notes) makes that easy to hit by accident, GitHub secret names are
|
|
2722
|
+
* case-insensitive, and the preflight's own prefix is reserved. Shared by `setup`
|
|
2723
|
+
* (which refuses) and `verify` (which reports it).
|
|
2724
|
+
*/
|
|
2725
|
+
function layoutSecretProblem(layout) {
|
|
2726
|
+
const taken = /* @__PURE__ */ new Map();
|
|
2727
|
+
const claim = (secret, owner, hint) => {
|
|
2728
|
+
if (isReservedSecret(secret)) return `${owner} would store into ${secret}, a name reserved for the setup preflight — choose another`;
|
|
2729
|
+
const key = secret.toUpperCase();
|
|
2730
|
+
const other = taken.get(key);
|
|
2731
|
+
if (other) return `${other} and ${owner} would both store into ${secret} — ${hint}`;
|
|
2732
|
+
taken.set(key, owner);
|
|
2733
|
+
};
|
|
2734
|
+
for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
|
|
2735
|
+
if (!channel.webhook) continue;
|
|
2736
|
+
const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
|
|
2737
|
+
if (clash) return clash;
|
|
2738
|
+
}
|
|
2739
|
+
for (const role of layout.roles ?? []) {
|
|
2740
|
+
if (!role.secret) continue;
|
|
2741
|
+
const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
|
|
2742
|
+
if (clash) return clash;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
/** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
|
|
2746
|
+
async function provisionChannel(deps, spec) {
|
|
2747
|
+
const { notifications, secrets, scope, dryRun, print, tally } = deps;
|
|
2748
|
+
const webhookCfg = spec.webhook || void 0;
|
|
2749
|
+
const secretName = webhookCfg?.secret ?? defaultSecretName(spec.name);
|
|
2750
|
+
const webhookName = webhookCfg?.name ?? spec.name;
|
|
2751
|
+
const where = spec.category ? ` in ${spec.category}` : "";
|
|
2752
|
+
if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
|
|
2753
|
+
if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
|
|
2754
|
+
if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
|
|
2755
|
+
if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
|
|
2756
|
+
if (dryRun) {
|
|
2757
|
+
print(` ${style.skip(`would ensure channel ${spec.name}${where}`)}`);
|
|
2758
|
+
if (tally) tally.skip += webhookCfg ? 2 : 1;
|
|
2759
|
+
if (webhookCfg) print(` ${style.skip(`would ensure webhook ${webhookName} and store its URL in ${secretName}`)}`);
|
|
2760
|
+
return {
|
|
2761
|
+
name: spec.name,
|
|
2762
|
+
...spec.category ? { category: spec.category } : {},
|
|
2763
|
+
created: false
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
const channel = await notifications.ensureChannel({
|
|
2767
|
+
name: spec.name,
|
|
2768
|
+
...spec.category ? { category: spec.category } : {},
|
|
2769
|
+
...spec.topic ? { topic: spec.topic } : {}
|
|
2770
|
+
});
|
|
2771
|
+
print(` ${(channel.created ? style.success : style.skip)(`${channel.created ? "created" : "exists "} channel ${spec.name}${where}`)}`);
|
|
2772
|
+
if (tally) tally[channel.created ? "ok" : "skip"]++;
|
|
2773
|
+
const outcome = {
|
|
2774
|
+
name: spec.name,
|
|
2775
|
+
...spec.category ? { category: spec.category } : {},
|
|
2776
|
+
created: channel.created
|
|
2777
|
+
};
|
|
2778
|
+
if (!webhookCfg) return outcome;
|
|
2779
|
+
const hook = await notifications.ensureWebhook({
|
|
2780
|
+
channel: spec.name,
|
|
2781
|
+
name: webhookName,
|
|
2782
|
+
...spec.category ? { category: spec.category } : {}
|
|
2783
|
+
});
|
|
2784
|
+
if (hook.url) {
|
|
2785
|
+
try {
|
|
2786
|
+
await secrets.setSecret(scope, secretName, hook.url);
|
|
2787
|
+
} catch (err) {
|
|
2788
|
+
throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${describeProviderError(err)}) — delete the webhook in the provider and re-run to get a fresh URL`, { cause: err });
|
|
2789
|
+
}
|
|
2790
|
+
print(` ${style.success(`${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`)}`);
|
|
2791
|
+
if (tally) tally.ok++;
|
|
2792
|
+
outcome.webhook = {
|
|
2793
|
+
name: webhookName,
|
|
2794
|
+
created: hook.created,
|
|
2795
|
+
secret: secretName,
|
|
2796
|
+
stored: true
|
|
2797
|
+
};
|
|
2798
|
+
} else {
|
|
2799
|
+
const note = `webhook ${webhookName} already exists and its URL cannot be read back — delete it in the provider and re-run, or set ${secretName} yourself`;
|
|
2800
|
+
print(` ${style.warn(note)}`);
|
|
2801
|
+
outcome.webhook = {
|
|
2802
|
+
name: webhookName,
|
|
2803
|
+
created: hook.created,
|
|
2804
|
+
secret: secretName,
|
|
2805
|
+
stored: false,
|
|
2806
|
+
note
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
return outcome;
|
|
2810
|
+
}
|
|
2811
|
+
async function runNotificationsChannelAdd(input) {
|
|
2812
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
2813
|
+
const logger = input.logger ?? getLogger();
|
|
2814
|
+
/* v8 ignore next -- the real loader is only built outside tests, which always inject one */
|
|
2815
|
+
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
2816
|
+
await loader.load();
|
|
2817
|
+
assertPluginsResolvable(loader, "notifications channel add");
|
|
2818
|
+
const dryRun = input.context.dryRun ?? false;
|
|
2819
|
+
print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
|
|
2820
|
+
try {
|
|
2821
|
+
const notifications = pickProvisioner(notificationProviders(loader), input.provider);
|
|
2822
|
+
let secrets;
|
|
2823
|
+
if (input.withWebhook && !dryRun) {
|
|
2824
|
+
if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
|
|
2825
|
+
secrets = loader.get("secrets");
|
|
2826
|
+
}
|
|
2827
|
+
const org = input.context.org;
|
|
2828
|
+
const scope = input.scope ?? (org ? {
|
|
2829
|
+
kind: "organization",
|
|
2830
|
+
name: org
|
|
2831
|
+
} : void 0);
|
|
2832
|
+
if (input.withWebhook && !scope && !dryRun) throw new Error("no org to store the webhook secret under — pass --scope or set `org` in holocron.config.ts");
|
|
2833
|
+
if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
|
|
2834
|
+
const channel = await provisionChannel({
|
|
2835
|
+
notifications,
|
|
2836
|
+
...secrets ? { secrets } : {},
|
|
2837
|
+
scope: scope ?? { kind: "repo" },
|
|
2838
|
+
dryRun,
|
|
2839
|
+
print
|
|
2840
|
+
}, {
|
|
2841
|
+
name: input.name,
|
|
2842
|
+
...input.category ? { category: input.category } : {},
|
|
2843
|
+
...input.topic ? { topic: input.topic } : {},
|
|
2844
|
+
...input.withWebhook ? { webhook: {
|
|
2845
|
+
...input.webhookName ? { name: input.webhookName } : {},
|
|
2846
|
+
...input.secret ? { secret: input.secret } : {}
|
|
2847
|
+
} } : {}
|
|
2848
|
+
});
|
|
2849
|
+
logger.info({
|
|
2850
|
+
channel: input.name,
|
|
2851
|
+
created: channel.created,
|
|
2852
|
+
provider: notifications.providerName,
|
|
2853
|
+
status: "ok"
|
|
2854
|
+
}, `notifications channel add: ${input.name}`);
|
|
2855
|
+
if (channel.webhook && !channel.webhook.stored && !dryRun) {
|
|
2856
|
+
const message = channel.webhook.note;
|
|
2857
|
+
print(` ${style.fail(message)}`);
|
|
2858
|
+
return {
|
|
2859
|
+
status: "fail",
|
|
2860
|
+
channel,
|
|
2861
|
+
message
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
return {
|
|
2865
|
+
status: dryRun ? "dry-run" : "ok",
|
|
2866
|
+
channel
|
|
2867
|
+
};
|
|
2868
|
+
} catch (err) {
|
|
2869
|
+
const message = describeProviderError(err);
|
|
2870
|
+
print(` ${style.fail(message)}`);
|
|
2871
|
+
logger.warn({
|
|
2872
|
+
channel: input.name,
|
|
2873
|
+
reason: logReason(err)
|
|
2874
|
+
}, `notifications channel add: ${input.name}`);
|
|
2875
|
+
return {
|
|
2876
|
+
status: "fail",
|
|
2877
|
+
message
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
//#endregion
|
|
2882
|
+
//#region src/commands/doctor-notifications/doctor-notifications.ts
|
|
2883
|
+
/**
|
|
2884
|
+
* `holocron doctor notifications` -- read-only: compare the layout a provider
|
|
2885
|
+
* declares in `holocron.config.ts` with what is actually on the server and in the
|
|
2886
|
+
* secrets store, step by step.
|
|
2887
|
+
*
|
|
2888
|
+
* It is the end-to-end check for `notifications setup`, and stays useful after
|
|
2889
|
+
* as a drift check. Everything the layout and `setup` cannot do yet is listed
|
|
2890
|
+
* too, as plain failing steps, so the command is red until the last feature
|
|
2891
|
+
* lands and each feature built turns exactly one step green. The list
|
|
2892
|
+
* is {@link PLANNED_CHECKS}; the numbers match the table in holocron#1098.
|
|
2893
|
+
*/
|
|
2894
|
+
/** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
|
|
2895
|
+
const PLANNED_CHECKS = [
|
|
2896
|
+
{
|
|
2897
|
+
step: 8,
|
|
2898
|
+
id: "permissions",
|
|
2899
|
+
title: "channel and category permissions set"
|
|
2900
|
+
},
|
|
2901
|
+
{
|
|
2902
|
+
step: 10,
|
|
2903
|
+
id: "server-settings",
|
|
2904
|
+
title: "server verification, notifications and system channels set"
|
|
2905
|
+
},
|
|
2906
|
+
{
|
|
2907
|
+
step: 11,
|
|
2908
|
+
id: "welcome",
|
|
2909
|
+
title: "welcome screen and onboarding set"
|
|
2910
|
+
},
|
|
2911
|
+
{
|
|
2912
|
+
step: 12,
|
|
2913
|
+
id: "webhook-appearance",
|
|
2914
|
+
title: "webhook names and avatars set"
|
|
2915
|
+
},
|
|
2916
|
+
{
|
|
2917
|
+
step: 13,
|
|
2918
|
+
id: "app-settings",
|
|
2919
|
+
title: "bot description, tags, install link and URLs set"
|
|
2920
|
+
},
|
|
2921
|
+
{
|
|
2922
|
+
step: 14,
|
|
2923
|
+
id: "role-assignment",
|
|
2924
|
+
title: "role assigned to team members"
|
|
2925
|
+
},
|
|
2926
|
+
{
|
|
2927
|
+
step: 15,
|
|
2928
|
+
id: "post-works",
|
|
2929
|
+
title: "test post reached every channel (platform.notifyCheck)"
|
|
2930
|
+
},
|
|
2931
|
+
{
|
|
2932
|
+
step: 16,
|
|
2933
|
+
id: "server-creation",
|
|
2934
|
+
title: "server created from config"
|
|
2935
|
+
}
|
|
2936
|
+
];
|
|
2937
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
2938
|
+
const shown = (s) => sanitizeDetail(s);
|
|
2939
|
+
const ROLE_APPEARANCE_TITLE = "role colour, hoist and permissions set";
|
|
2940
|
+
const SECRETS_TITLE = "webhook URLs and role id stored as secrets";
|
|
2941
|
+
async function runDoctorNotifications(input) {
|
|
2942
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
2943
|
+
const logger = input.logger ?? getLogger();
|
|
2944
|
+
/* v8 ignore next -- the real loader is only built outside tests, which always inject one */
|
|
2945
|
+
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
2946
|
+
await loader.load();
|
|
2947
|
+
assertPluginsResolvable(loader, "doctor notifications");
|
|
2948
|
+
const checks = [];
|
|
2949
|
+
const add = (id, title, problems, pass) => {
|
|
2950
|
+
checks.push({
|
|
2951
|
+
id,
|
|
2952
|
+
title,
|
|
2953
|
+
status: problems.length === 0 ? "pass" : "fail",
|
|
2954
|
+
detail: problems.length === 0 ? pass : `${problems.length} problem(s)`,
|
|
2955
|
+
problems
|
|
2956
|
+
});
|
|
2957
|
+
};
|
|
2958
|
+
const byId = (id) => checks.find((c) => c.id === id);
|
|
2959
|
+
const failed = (id, title, reason) => {
|
|
2960
|
+
checks.push({
|
|
2961
|
+
id,
|
|
2962
|
+
title,
|
|
2963
|
+
status: "fail",
|
|
2964
|
+
detail: reason,
|
|
2965
|
+
problems: []
|
|
2966
|
+
});
|
|
2967
|
+
};
|
|
2968
|
+
const summarise = () => ({
|
|
2969
|
+
pass: checks.filter((c) => c.status === "pass").length,
|
|
2970
|
+
fail: checks.filter((c) => c.status === "fail").length,
|
|
2971
|
+
skip: input.builtOnly ? PLANNED_CHECKS.length : 0
|
|
2972
|
+
});
|
|
2973
|
+
const finish = () => {
|
|
2974
|
+
const summary = summarise();
|
|
2975
|
+
const report = {
|
|
2976
|
+
status: summary.fail > 0 ? "fail" : "ok",
|
|
2977
|
+
checks,
|
|
2978
|
+
summary
|
|
2979
|
+
};
|
|
2980
|
+
logger.info({
|
|
2981
|
+
...summary,
|
|
2982
|
+
status: report.status,
|
|
2983
|
+
checks: checks.map((c) => `${c.id}:${c.status}`)
|
|
2984
|
+
}, "doctor notifications");
|
|
2985
|
+
return report;
|
|
2986
|
+
};
|
|
2987
|
+
const refuse = (message, reason = message) => {
|
|
2988
|
+
print(` ${style.fail(message)}`);
|
|
2989
|
+
print("");
|
|
2990
|
+
print(style.header("0 ok, 1 fail, 0 skipped"));
|
|
2991
|
+
logger.warn({ reason }, "doctor notifications");
|
|
2992
|
+
return {
|
|
2993
|
+
status: "fail",
|
|
2994
|
+
checks,
|
|
2995
|
+
summary: summarise(),
|
|
2996
|
+
message
|
|
2997
|
+
};
|
|
2998
|
+
};
|
|
2999
|
+
let notifications;
|
|
3000
|
+
try {
|
|
3001
|
+
notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.inspect === "function", {
|
|
3002
|
+
infinitive: "inspect a server",
|
|
3003
|
+
gerund: "inspecting"
|
|
3004
|
+
});
|
|
3005
|
+
} catch (err) {
|
|
3006
|
+
return refuse(describeProviderError(err));
|
|
3007
|
+
}
|
|
3008
|
+
const layout = notifications.layout;
|
|
3009
|
+
if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
|
|
3010
|
+
let found;
|
|
3011
|
+
let readError = "";
|
|
3012
|
+
try {
|
|
3013
|
+
found = await notifications.inspect(layout);
|
|
3014
|
+
} catch (err) {
|
|
3015
|
+
readError = describeProviderError(err);
|
|
3016
|
+
}
|
|
3017
|
+
if (notifications.whoami) try {
|
|
3018
|
+
const who = await notifications.whoami();
|
|
3019
|
+
if (who) checks.push({
|
|
3020
|
+
id: "server",
|
|
3021
|
+
title: "server reachable",
|
|
3022
|
+
status: "pass",
|
|
3023
|
+
detail: shown(who.target),
|
|
3024
|
+
problems: []
|
|
3025
|
+
});
|
|
3026
|
+
else failed("server", "server reachable", "no server configured (the provider's whoami has nothing to check)");
|
|
3027
|
+
} catch (err) {
|
|
3028
|
+
failed("server", "server reachable", describeProviderError(err));
|
|
3029
|
+
}
|
|
3030
|
+
else if (found) checks.push({
|
|
3031
|
+
id: "server",
|
|
3032
|
+
title: "server reachable",
|
|
3033
|
+
status: "pass",
|
|
3034
|
+
detail: "readable (the provider has no whoami)",
|
|
3035
|
+
problems: []
|
|
3036
|
+
});
|
|
3037
|
+
else failed("server", "server reachable", readError);
|
|
3038
|
+
const declaredChannels = (layout.categories ?? []).flatMap((cat) => (cat.channels ?? []).map((channel) => ({
|
|
3039
|
+
cat: cat.name,
|
|
3040
|
+
channel
|
|
3041
|
+
})));
|
|
3042
|
+
const pick = (name, category) => {
|
|
3043
|
+
const named = found.channels.filter((c) => same(c.name, name));
|
|
3044
|
+
return {
|
|
3045
|
+
named,
|
|
3046
|
+
match: named.find((c) => c.category !== void 0 && same(c.category, category))
|
|
3047
|
+
};
|
|
3048
|
+
};
|
|
3049
|
+
if (!found) {
|
|
3050
|
+
if (byId("server")?.status !== "fail") failed("read", "read server", readError);
|
|
3051
|
+
for (const [id, title] of [
|
|
3052
|
+
["categories", "categories exist"],
|
|
3053
|
+
["channels", "channels in their category, with topics"],
|
|
3054
|
+
["webhooks", "webhooks on their channels"],
|
|
3055
|
+
["roles", "roles exist and are mentionable"],
|
|
3056
|
+
["role-appearance", ROLE_APPEARANCE_TITLE]
|
|
3057
|
+
]) failed(id, title, "not checked: could not read the server");
|
|
3058
|
+
} else {
|
|
3059
|
+
const inspected = found;
|
|
3060
|
+
const categories = layout.categories ?? [];
|
|
3061
|
+
add("categories", "categories exist", categories.filter((cat) => !inspected.categories.some((name) => same(name, cat.name))).map((cat) => `${shown(cat.name)}: missing`), categories.length === 0 ? "none declared" : `${categories.length} of ${categories.length} exist`);
|
|
3062
|
+
const channelProblems = [];
|
|
3063
|
+
for (const { cat, channel } of declaredChannels) {
|
|
3064
|
+
const { named, match } = pick(channel.name, cat);
|
|
3065
|
+
if (named.length === 0) {
|
|
3066
|
+
channelProblems.push(`#${shown(channel.name)}: missing`);
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
if (!match) {
|
|
3070
|
+
const where = named.map((c) => c.category ?? "no category").join(", ");
|
|
3071
|
+
channelProblems.push(`#${shown(channel.name)}: exists, but under ${shown(where)} and not ${shown(cat)}`);
|
|
3072
|
+
continue;
|
|
3073
|
+
}
|
|
3074
|
+
if (channel.topic && (match.topic ?? "") !== channel.topic) channelProblems.push(`#${shown(channel.name)}: topic is "${shown(match.topic ?? "")}" but the layout says "${shown(channel.topic)}"`);
|
|
3075
|
+
}
|
|
3076
|
+
add("channels", "channels in their category, with topics", channelProblems, declaredChannels.length === 0 ? "none declared" : `${declaredChannels.length} of ${declaredChannels.length} as declared`);
|
|
3077
|
+
const withWebhook = declaredChannels.filter(({ channel }) => channel.webhook);
|
|
3078
|
+
const webhookProblems = [];
|
|
3079
|
+
for (const { cat, channel } of withWebhook) {
|
|
3080
|
+
const name = (channel.webhook === true ? {} : channel.webhook).name ?? channel.name;
|
|
3081
|
+
const { match } = pick(channel.name, cat);
|
|
3082
|
+
if (!match) webhookProblems.push(`#${shown(channel.name)}: no channel to hold the webhook`);
|
|
3083
|
+
else if (match.webhooksError) webhookProblems.push(`#${shown(channel.name)}: could not list its webhooks (${shown(match.webhooksError)})`);
|
|
3084
|
+
else if (!match.webhooks.some((w) => same(w, name))) webhookProblems.push(`#${shown(channel.name)}: no webhook named ${shown(name)}`);
|
|
3085
|
+
}
|
|
3086
|
+
add("webhooks", "webhooks on their channels", webhookProblems, withWebhook.length === 0 ? "none declared" : `${withWebhook.length} of ${withWebhook.length} exist`);
|
|
3087
|
+
const roles = layout.roles ?? [];
|
|
3088
|
+
const roleProblems = [];
|
|
3089
|
+
if (inspected.rolesError) roleProblems.push(`could not read the roles (${shown(inspected.rolesError)})`);
|
|
3090
|
+
for (const role of inspected.rolesError ? [] : roles) {
|
|
3091
|
+
const named = inspected.roles.filter((r) => same(r.name, role.name));
|
|
3092
|
+
const actual = named[0];
|
|
3093
|
+
if (named.length > 1) roleProblems.push(`${shown(role.name)}: ${named.length} roles have this name; setup cannot tell which to manage`);
|
|
3094
|
+
else if (!actual) roleProblems.push(`${shown(role.name)}: missing`);
|
|
3095
|
+
else if (role.mentionable !== void 0 && actual.mentionable !== role.mentionable) roleProblems.push(`${shown(role.name)}: mentionable is ${actual.mentionable} but the layout says ${role.mentionable}`);
|
|
3096
|
+
}
|
|
3097
|
+
add("roles", "roles exist and are mentionable", roleProblems, roles.length === 0 ? "none declared" : `${roles.length} of ${roles.length} as declared`);
|
|
3098
|
+
const appearance = [];
|
|
3099
|
+
if (!inspected.rolesError) for (const role of roles) {
|
|
3100
|
+
const actual = inspected.roles.find((r) => same(r.name, role.name));
|
|
3101
|
+
if (!actual) {
|
|
3102
|
+
appearance.push(`${shown(role.name)}: not checked, the role is missing`);
|
|
3103
|
+
continue;
|
|
3104
|
+
}
|
|
3105
|
+
if (role.color !== void 0 && (actual.color ?? "").toLowerCase() !== role.color.toLowerCase()) appearance.push(`${shown(role.name)}: colour is ${shown(actual.color ?? "default")} but the layout says ${shown(role.color)}`);
|
|
3106
|
+
if (role.hoist !== void 0 && (actual.hoist ?? false) !== role.hoist) appearance.push(`${shown(role.name)}: hoist is ${actual.hoist ?? false} but the layout says ${role.hoist}`);
|
|
3107
|
+
const reported = actual.permissions;
|
|
3108
|
+
if (role.permissions !== void 0) {
|
|
3109
|
+
if (reported === void 0) appearance.push(`${shown(role.name)}: the provider did not report its permissions`);
|
|
3110
|
+
else {
|
|
3111
|
+
const have = new Set(reported.map((n) => n.toLowerCase()));
|
|
3112
|
+
const want = new Set(role.permissions.map((n) => n.toLowerCase()));
|
|
3113
|
+
const missing = role.permissions.filter((n) => !have.has(n.toLowerCase()));
|
|
3114
|
+
const extra = reported.filter((n) => !want.has(n.toLowerCase()));
|
|
3115
|
+
if (missing.length > 0) appearance.push(`${shown(role.name)}: missing permissions ${missing.map(shown).join(", ")}`);
|
|
3116
|
+
if (extra.length > 0) appearance.push(`${shown(role.name)}: has extra permissions ${extra.map(shown).join(", ")}`);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
const styled = roles.filter((r) => r.color !== void 0 || r.hoist !== void 0 || r.permissions !== void 0);
|
|
3121
|
+
add("role-appearance", ROLE_APPEARANCE_TITLE, inspected.rolesError ? [`not checked: could not read the roles`] : appearance, styled.length === 0 ? "none declared" : `${styled.length} of ${styled.length} as declared`);
|
|
3122
|
+
}
|
|
3123
|
+
const wanted = [...declaredChannels.filter(({ channel }) => channel.webhook).map(({ channel }) => channel.webhook !== true && channel.webhook && channel.webhook.secret ? channel.webhook.secret : defaultSecretName(channel.name)), ...(layout.roles ?? []).flatMap((r) => r.secret ? [r.secret] : [])];
|
|
3124
|
+
if (wanted.length === 0) checks.push({
|
|
3125
|
+
id: "secrets",
|
|
3126
|
+
title: SECRETS_TITLE,
|
|
3127
|
+
status: "pass",
|
|
3128
|
+
detail: "none declared",
|
|
3129
|
+
problems: []
|
|
3130
|
+
});
|
|
3131
|
+
else if (!loader.has("secrets")) failed("secrets", SECRETS_TITLE, whyMissing(loader, "secrets", "no `secrets` provider to check them with"));
|
|
3132
|
+
else {
|
|
3133
|
+
const org = input.context.org;
|
|
3134
|
+
const scope = input.scope ?? (org ? {
|
|
3135
|
+
kind: "organization",
|
|
3136
|
+
name: org
|
|
3137
|
+
} : void 0);
|
|
3138
|
+
if (!scope) failed("secrets", SECRETS_TITLE, "no org to look under — pass --scope or set `org` in holocron.config.ts");
|
|
3139
|
+
else try {
|
|
3140
|
+
const have = new Set((await loader.get("secrets").listSecrets(scope)).map((n) => n.toUpperCase()));
|
|
3141
|
+
const clash = layoutSecretProblem(layout);
|
|
3142
|
+
add("secrets", SECRETS_TITLE, [...clash ? [shown(clash)] : [], ...wanted.filter((n) => !have.has(n.toUpperCase())).map((n) => `${shown(n)}: missing`)], `${wanted.length} of ${wanted.length} exist`);
|
|
3143
|
+
} catch (err) {
|
|
3144
|
+
failed("secrets", SECRETS_TITLE, describeProviderError(err));
|
|
3145
|
+
}
|
|
2608
3146
|
}
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
const
|
|
2618
|
-
const
|
|
2619
|
-
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
message,
|
|
2627
|
-
...logMessage !== void 0 ? { logMessage } : {}
|
|
2628
|
-
};
|
|
3147
|
+
if (!input.builtOnly) for (const planned of PLANNED_CHECKS) checks.push({
|
|
3148
|
+
id: planned.id,
|
|
3149
|
+
title: planned.title,
|
|
3150
|
+
status: "fail",
|
|
3151
|
+
detail: `not built yet (#1098, step ${planned.step})`,
|
|
3152
|
+
problems: [],
|
|
3153
|
+
step: planned.step
|
|
3154
|
+
});
|
|
3155
|
+
const width = Math.max(...checks.map((c) => c.title.length));
|
|
3156
|
+
for (const check of checks) {
|
|
3157
|
+
print(statusRow(check.status === "pass" ? "ok" : "fail", [check.title.padEnd(width), check.detail]));
|
|
3158
|
+
for (const problem of check.problems) print(` - ${problem}`);
|
|
3159
|
+
}
|
|
3160
|
+
const summary = summarise();
|
|
3161
|
+
print("");
|
|
3162
|
+
print(style.header(`${summary.pass} ok, ${summary.fail} fail, ${summary.skip} skipped`));
|
|
3163
|
+
return finish();
|
|
2629
3164
|
}
|
|
2630
3165
|
//#endregion
|
|
2631
3166
|
//#region src/commands/monitoring-check/monitoring-check.ts
|
|
@@ -3231,203 +3766,6 @@ async function runNew(input) {
|
|
|
3231
3766
|
};
|
|
3232
3767
|
}
|
|
3233
3768
|
//#endregion
|
|
3234
|
-
//#region src/commands/notifications-channel-add/notifications-channel-add.ts
|
|
3235
|
-
/**
|
|
3236
|
-
* `holocron notifications channel add` — create one channel (and, with
|
|
3237
|
-
* `--with-webhook`, an inbound webhook whose URL goes straight into a secret).
|
|
3238
|
-
*
|
|
3239
|
-
* Provider-neutral: it drives the `notifications` capability's `ensure*`
|
|
3240
|
-
* methods, so any provider that implements them works. Idempotent — an
|
|
3241
|
-
* existing channel or webhook is left alone, and a webhook URL is never
|
|
3242
|
-
* printed, only stored.
|
|
3243
|
-
*/
|
|
3244
|
-
/**
|
|
3245
|
-
* `HOLOCRON_NOTIFY_WEBHOOK_SYNC` for channel `sync`. Named after the job, not the
|
|
3246
|
-
* provider, so changing providers means re-pointing the same secrets at new
|
|
3247
|
-
* webhook URLs, with no workflow edits.
|
|
3248
|
-
*/
|
|
3249
|
-
function defaultSecretName(channel) {
|
|
3250
|
-
return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
3251
|
-
}
|
|
3252
|
-
/**
|
|
3253
|
-
* Prefix of the throwaway secret the preflight writes and removes. Every run adds a
|
|
3254
|
-
* random suffix, so it can never overwrite (and then delete) a secret someone already
|
|
3255
|
-
* has; layouts may not use any name under this prefix for their own secrets.
|
|
3256
|
-
*/
|
|
3257
|
-
const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
|
|
3258
|
-
/** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
|
|
3259
|
-
function isReservedSecret(name) {
|
|
3260
|
-
return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
|
|
3261
|
-
}
|
|
3262
|
-
/**
|
|
3263
|
-
* Prove the secrets store can be WRITTEN before creating anything that needs it.
|
|
3264
|
-
* A webhook's URL is shown once; creating the webhook and then failing to store
|
|
3265
|
-
* it leaves an orphan the user has to delete by hand. Listing is not enough --
|
|
3266
|
-
* a token can read secrets it cannot write -- so write a throwaway secret and
|
|
3267
|
-
* remove it.
|
|
3268
|
-
*/
|
|
3269
|
-
async function assertSecretsReachable(secrets, scope) {
|
|
3270
|
-
const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
|
|
3271
|
-
try {
|
|
3272
|
-
await secrets.setSecret(scope, probe, "ok");
|
|
3273
|
-
await secrets.deleteSecret(scope, probe);
|
|
3274
|
-
} catch (err) {
|
|
3275
|
-
const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
|
|
3276
|
-
throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks. If a secret named ${probe} was left behind, delete it`, { cause: err });
|
|
3277
|
-
}
|
|
3278
|
-
}
|
|
3279
|
-
/** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
|
|
3280
|
-
function whyMissing(loader, key, fallback) {
|
|
3281
|
-
const failure = loader.loadFailures().find((f) => f.key === key);
|
|
3282
|
-
return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${failure.error.message})` : fallback;
|
|
3283
|
-
}
|
|
3284
|
-
/** Pick the provider that can provision; with several, `--provider` decides. */
|
|
3285
|
-
function pickProvisioner(providers, wanted) {
|
|
3286
|
-
const capable = providers.filter((p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function");
|
|
3287
|
-
if (capable.length === 0) throw new Error("no `notifications` provider can provision — add one that implements it (for example discord) to holocron.config.ts");
|
|
3288
|
-
if (wanted) {
|
|
3289
|
-
const match = capable.find((p) => p.providerName === wanted);
|
|
3290
|
-
if (!match) throw new Error(`no provisioning \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
|
|
3291
|
-
return match;
|
|
3292
|
-
}
|
|
3293
|
-
if (capable.length > 1) throw new Error(`several \`notifications\` providers can provision (${names(capable)}) — pass --provider <name>`);
|
|
3294
|
-
return capable[0];
|
|
3295
|
-
}
|
|
3296
|
-
const names = (ps) => ps.map((p) => p.providerName).join(", ");
|
|
3297
|
-
/** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
|
|
3298
|
-
async function provisionChannel(deps, spec) {
|
|
3299
|
-
const { notifications, secrets, scope, dryRun, print } = deps;
|
|
3300
|
-
const webhookCfg = spec.webhook || void 0;
|
|
3301
|
-
const secretName = webhookCfg?.secret ?? defaultSecretName(spec.name);
|
|
3302
|
-
const webhookName = webhookCfg?.name ?? spec.name;
|
|
3303
|
-
const where = spec.category ? ` in ${spec.category}` : "";
|
|
3304
|
-
if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
|
|
3305
|
-
if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
|
|
3306
|
-
if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
|
|
3307
|
-
if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
|
|
3308
|
-
if (dryRun) {
|
|
3309
|
-
print(` … would ensure channel ${spec.name}${where}`);
|
|
3310
|
-
if (webhookCfg) print(` … would ensure webhook ${webhookName} and store its URL in ${secretName}`);
|
|
3311
|
-
return {
|
|
3312
|
-
name: spec.name,
|
|
3313
|
-
...spec.category ? { category: spec.category } : {},
|
|
3314
|
-
created: false
|
|
3315
|
-
};
|
|
3316
|
-
}
|
|
3317
|
-
const channel = await notifications.ensureChannel({
|
|
3318
|
-
name: spec.name,
|
|
3319
|
-
...spec.category ? { category: spec.category } : {},
|
|
3320
|
-
...spec.topic ? { topic: spec.topic } : {}
|
|
3321
|
-
});
|
|
3322
|
-
print(` ${channel.created ? "✓ created" : "· exists "} channel ${spec.name}${where}`);
|
|
3323
|
-
const outcome = {
|
|
3324
|
-
name: spec.name,
|
|
3325
|
-
...spec.category ? { category: spec.category } : {},
|
|
3326
|
-
created: channel.created
|
|
3327
|
-
};
|
|
3328
|
-
if (!webhookCfg) return outcome;
|
|
3329
|
-
const hook = await notifications.ensureWebhook({
|
|
3330
|
-
channel: spec.name,
|
|
3331
|
-
name: webhookName,
|
|
3332
|
-
...spec.category ? { category: spec.category } : {}
|
|
3333
|
-
});
|
|
3334
|
-
if (hook.url) {
|
|
3335
|
-
try {
|
|
3336
|
-
await secrets.setSecret(scope, secretName, hook.url);
|
|
3337
|
-
} catch (err) {
|
|
3338
|
-
throw new Error(`webhook ${webhookName} was ${hook.created ? "created" : "found"} but storing its URL in ${secretName} failed (${describeProviderError(err)}) — delete the webhook in the provider and re-run to get a fresh URL`, { cause: err });
|
|
3339
|
-
}
|
|
3340
|
-
print(` ✓ ${hook.created ? "created" : "found "} webhook ${webhookName}; URL stored in ${secretName}`);
|
|
3341
|
-
outcome.webhook = {
|
|
3342
|
-
name: webhookName,
|
|
3343
|
-
created: hook.created,
|
|
3344
|
-
secret: secretName,
|
|
3345
|
-
stored: true
|
|
3346
|
-
};
|
|
3347
|
-
} else {
|
|
3348
|
-
const note = `webhook ${webhookName} already exists and its URL cannot be read back — delete it in the provider and re-run, or set ${secretName} yourself`;
|
|
3349
|
-
print(` ! ${note}`);
|
|
3350
|
-
outcome.webhook = {
|
|
3351
|
-
name: webhookName,
|
|
3352
|
-
created: hook.created,
|
|
3353
|
-
secret: secretName,
|
|
3354
|
-
stored: false,
|
|
3355
|
-
note
|
|
3356
|
-
};
|
|
3357
|
-
}
|
|
3358
|
-
return outcome;
|
|
3359
|
-
}
|
|
3360
|
-
async function runNotificationsChannelAdd(input) {
|
|
3361
|
-
const print = input.print ?? ((line) => console.log(line));
|
|
3362
|
-
const logger = input.logger ?? getLogger();
|
|
3363
|
-
/* v8 ignore next -- the real loader is only built outside tests, which always inject one */
|
|
3364
|
-
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
3365
|
-
await loader.load();
|
|
3366
|
-
assertPluginsResolvable(loader, "notifications channel add");
|
|
3367
|
-
const dryRun = input.context.dryRun ?? false;
|
|
3368
|
-
print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
|
|
3369
|
-
try {
|
|
3370
|
-
const notifications = pickProvisioner(loader.has("notifications") ? loader.get("notifications") : [], input.provider);
|
|
3371
|
-
let secrets;
|
|
3372
|
-
if (input.withWebhook && !dryRun) {
|
|
3373
|
-
if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
|
|
3374
|
-
secrets = loader.get("secrets");
|
|
3375
|
-
}
|
|
3376
|
-
const org = input.context.org;
|
|
3377
|
-
const scope = input.scope ?? (org ? {
|
|
3378
|
-
kind: "organization",
|
|
3379
|
-
name: org
|
|
3380
|
-
} : void 0);
|
|
3381
|
-
if (input.withWebhook && !scope && !dryRun) throw new Error("no org to store the webhook secret under — pass --scope or set `org` in holocron.config.ts");
|
|
3382
|
-
if (secrets && scope && !dryRun) await assertSecretsReachable(secrets, scope);
|
|
3383
|
-
const channel = await provisionChannel({
|
|
3384
|
-
notifications,
|
|
3385
|
-
...secrets ? { secrets } : {},
|
|
3386
|
-
scope: scope ?? { kind: "repo" },
|
|
3387
|
-
dryRun,
|
|
3388
|
-
print
|
|
3389
|
-
}, {
|
|
3390
|
-
name: input.name,
|
|
3391
|
-
...input.category ? { category: input.category } : {},
|
|
3392
|
-
...input.topic ? { topic: input.topic } : {},
|
|
3393
|
-
...input.withWebhook ? { webhook: {
|
|
3394
|
-
...input.webhookName ? { name: input.webhookName } : {},
|
|
3395
|
-
...input.secret ? { secret: input.secret } : {}
|
|
3396
|
-
} } : {}
|
|
3397
|
-
});
|
|
3398
|
-
logger.info({
|
|
3399
|
-
channel: input.name,
|
|
3400
|
-
created: channel.created,
|
|
3401
|
-
provider: notifications.providerName,
|
|
3402
|
-
status: "ok"
|
|
3403
|
-
}, `notifications channel add: ${input.name}`);
|
|
3404
|
-
if (channel.webhook && !channel.webhook.stored && !dryRun) {
|
|
3405
|
-
const message = channel.webhook.note;
|
|
3406
|
-
print(` ✗ ${message}`);
|
|
3407
|
-
return {
|
|
3408
|
-
status: "fail",
|
|
3409
|
-
channel,
|
|
3410
|
-
message
|
|
3411
|
-
};
|
|
3412
|
-
}
|
|
3413
|
-
return {
|
|
3414
|
-
status: dryRun ? "dry-run" : "ok",
|
|
3415
|
-
channel
|
|
3416
|
-
};
|
|
3417
|
-
} catch (err) {
|
|
3418
|
-
const message = describeProviderError(err);
|
|
3419
|
-
print(` ✗ ${message}`);
|
|
3420
|
-
logger.warn({
|
|
3421
|
-
channel: input.name,
|
|
3422
|
-
reason: logReason(err)
|
|
3423
|
-
}, `notifications channel add: ${input.name}`);
|
|
3424
|
-
return {
|
|
3425
|
-
status: "fail",
|
|
3426
|
-
message
|
|
3427
|
-
};
|
|
3428
|
-
}
|
|
3429
|
-
}
|
|
3430
|
-
//#endregion
|
|
3431
3769
|
//#region src/commands/notifications-setup/notifications-setup.ts
|
|
3432
3770
|
/**
|
|
3433
3771
|
* `holocron notifications setup` — apply the server layout a `notifications`
|
|
@@ -3457,7 +3795,9 @@ async function runNotificationsSetup(input) {
|
|
|
3457
3795
|
failures: []
|
|
3458
3796
|
};
|
|
3459
3797
|
const refuse = (message, reason = message) => {
|
|
3460
|
-
print(`
|
|
3798
|
+
print(` ${style.fail(message)}`);
|
|
3799
|
+
print("");
|
|
3800
|
+
print(style.header("0 ok, 1 fail, 0 skipped"));
|
|
3461
3801
|
logger.warn({ reason }, "notifications setup");
|
|
3462
3802
|
return {
|
|
3463
3803
|
...report,
|
|
@@ -3467,7 +3807,7 @@ async function runNotificationsSetup(input) {
|
|
|
3467
3807
|
};
|
|
3468
3808
|
let notifications;
|
|
3469
3809
|
try {
|
|
3470
|
-
notifications = pickProvisioner(
|
|
3810
|
+
notifications = pickProvisioner(notificationProviders(loader), input.provider);
|
|
3471
3811
|
} catch (err) {
|
|
3472
3812
|
return refuse(errorMessage(err));
|
|
3473
3813
|
}
|
|
@@ -3490,31 +3830,21 @@ async function runNotificationsSetup(input) {
|
|
|
3490
3830
|
} catch (err) {
|
|
3491
3831
|
return refuse(errorMessage(err), logReason(err));
|
|
3492
3832
|
}
|
|
3493
|
-
const
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3833
|
+
const secretProblem = layoutSecretProblem(layout);
|
|
3834
|
+
if (secretProblem) return refuse(secretProblem);
|
|
3835
|
+
const providerProblem = notifications.validateLayout?.(layout);
|
|
3836
|
+
if (providerProblem) return refuse(providerProblem);
|
|
3837
|
+
const tally = {
|
|
3838
|
+
ok: 0,
|
|
3839
|
+
skip: 0
|
|
3500
3840
|
};
|
|
3501
|
-
|
|
3502
|
-
if (!channel.webhook) continue;
|
|
3503
|
-
const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
|
|
3504
|
-
if (clash) return refuse(clash);
|
|
3505
|
-
}
|
|
3506
|
-
for (const role of layout.roles ?? []) {
|
|
3507
|
-
if (!role.secret) continue;
|
|
3508
|
-
const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
|
|
3509
|
-
if (clash) return refuse(clash);
|
|
3510
|
-
}
|
|
3511
|
-
print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
|
|
3841
|
+
const made = (created) => void (created ? tally.ok++ : tally.skip++);
|
|
3512
3842
|
const attempt = async (step, fn) => {
|
|
3513
3843
|
try {
|
|
3514
3844
|
await fn();
|
|
3515
3845
|
} catch (err) {
|
|
3516
3846
|
const message = describeProviderError(err);
|
|
3517
|
-
print(`
|
|
3847
|
+
print(` ${style.fail(`${step}: ${message}`)}`);
|
|
3518
3848
|
report.failures.push({
|
|
3519
3849
|
step,
|
|
3520
3850
|
message
|
|
@@ -3522,16 +3852,18 @@ async function runNotificationsSetup(input) {
|
|
|
3522
3852
|
}
|
|
3523
3853
|
};
|
|
3524
3854
|
for (const category of layout.categories ?? []) {
|
|
3525
|
-
print(`\n${category.name}`);
|
|
3855
|
+
print(`\n${style.header(category.name)}`);
|
|
3526
3856
|
report.categories.push(category.name);
|
|
3527
3857
|
await attempt(`category ${category.name}`, async () => {
|
|
3528
3858
|
if (!notifications.ensureCategory) return;
|
|
3529
3859
|
if (dryRun) {
|
|
3530
|
-
print(`
|
|
3860
|
+
print(` ${style.skip(`would ensure category ${category.name}`)}`);
|
|
3861
|
+
tally.skip++;
|
|
3531
3862
|
return;
|
|
3532
3863
|
}
|
|
3533
|
-
const
|
|
3534
|
-
print(` ${
|
|
3864
|
+
const result = await notifications.ensureCategory(category.name);
|
|
3865
|
+
print(` ${(result.created ? style.success : style.skip)(`${result.created ? "created" : "exists "} category ${category.name}`)}`);
|
|
3866
|
+
made(result.created);
|
|
3535
3867
|
});
|
|
3536
3868
|
for (const channel of category.channels ?? []) await attempt(`channel ${channel.name}`, async () => {
|
|
3537
3869
|
const outcome = await provisionChannel({
|
|
@@ -3539,7 +3871,8 @@ async function runNotificationsSetup(input) {
|
|
|
3539
3871
|
...secrets ? { secrets } : {},
|
|
3540
3872
|
scope: scope ?? { kind: "repo" },
|
|
3541
3873
|
dryRun,
|
|
3542
|
-
print
|
|
3874
|
+
print,
|
|
3875
|
+
tally
|
|
3543
3876
|
}, {
|
|
3544
3877
|
name: channel.name,
|
|
3545
3878
|
category: category.name,
|
|
@@ -3553,11 +3886,12 @@ async function runNotificationsSetup(input) {
|
|
|
3553
3886
|
});
|
|
3554
3887
|
});
|
|
3555
3888
|
}
|
|
3556
|
-
if ((layout.roles ?? []).length > 0) print("
|
|
3889
|
+
if ((layout.roles ?? []).length > 0) print(`\n${style.header("Roles")}`);
|
|
3557
3890
|
for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
|
|
3558
3891
|
if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
|
|
3559
3892
|
if (dryRun) {
|
|
3560
|
-
print(`
|
|
3893
|
+
print(` ${style.skip(`would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`)}`);
|
|
3894
|
+
tally.skip++;
|
|
3561
3895
|
report.roles.push({
|
|
3562
3896
|
name: role.name,
|
|
3563
3897
|
created: false,
|
|
@@ -3565,28 +3899,36 @@ async function runNotificationsSetup(input) {
|
|
|
3565
3899
|
});
|
|
3566
3900
|
return;
|
|
3567
3901
|
}
|
|
3568
|
-
const
|
|
3902
|
+
const result = await notifications.ensureRole({
|
|
3569
3903
|
name: role.name,
|
|
3570
|
-
...role.mentionable !== void 0 ? { mentionable: role.mentionable } : {}
|
|
3904
|
+
...role.mentionable !== void 0 ? { mentionable: role.mentionable } : {},
|
|
3905
|
+
...role.color !== void 0 ? { color: role.color } : {},
|
|
3906
|
+
...role.hoist !== void 0 ? { hoist: role.hoist } : {},
|
|
3907
|
+
...role.permissions !== void 0 ? { permissions: role.permissions } : {}
|
|
3571
3908
|
});
|
|
3572
3909
|
const outcome = {
|
|
3573
3910
|
name: role.name,
|
|
3574
|
-
id:
|
|
3575
|
-
created:
|
|
3911
|
+
id: result.id,
|
|
3912
|
+
created: result.created,
|
|
3913
|
+
...result.updated?.length ? { updated: result.updated } : {}
|
|
3576
3914
|
};
|
|
3577
3915
|
report.roles.push(outcome);
|
|
3578
|
-
|
|
3916
|
+
const changed = result.created || (result.updated?.length ?? 0) > 0;
|
|
3917
|
+
const verb = result.created ? "created" : changed ? "updated" : "exists ";
|
|
3918
|
+
print(` ${(changed ? style.success : style.skip)(`${verb} role ${role.name} (id ${result.id})${result.updated?.length ? `: ${result.updated.join(", ")}` : ""}`)}`);
|
|
3919
|
+
made(changed);
|
|
3579
3920
|
if (role.secret) {
|
|
3580
|
-
await secrets.setSecret(scope, role.secret,
|
|
3921
|
+
await secrets.setSecret(scope, role.secret, result.id);
|
|
3581
3922
|
outcome.secret = role.secret;
|
|
3582
3923
|
outcome.stored = true;
|
|
3583
|
-
print(`
|
|
3924
|
+
print(` ${style.success(`stored the id of ${role.name} in ${role.secret}`)}`);
|
|
3925
|
+
tally.ok++;
|
|
3584
3926
|
}
|
|
3585
3927
|
});
|
|
3586
|
-
if (report.failures.length > 0)
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
}
|
|
3928
|
+
if (report.failures.length > 0) report.status = "fail";
|
|
3929
|
+
else if (dryRun) report.status = "dry-run";
|
|
3930
|
+
print("");
|
|
3931
|
+
print(style.header(`${tally.ok} ok, ${report.failures.length} fail, ${tally.skip} skipped`));
|
|
3590
3932
|
logger.info({
|
|
3591
3933
|
provider: notifications.providerName,
|
|
3592
3934
|
channels: report.channels.length,
|
|
@@ -9733,6 +10075,12 @@ const COMMAND_REGISTRY = [
|
|
|
9733
10075
|
}],
|
|
9734
10076
|
supportsDryRun: true
|
|
9735
10077
|
},
|
|
10078
|
+
{
|
|
10079
|
+
name: "doctor notifications",
|
|
10080
|
+
description: "Check the layout against the server and the secrets store, step by step (read-only)",
|
|
10081
|
+
positionals: [],
|
|
10082
|
+
supportsDryRun: false
|
|
10083
|
+
},
|
|
9736
10084
|
{
|
|
9737
10085
|
name: "notifications setup",
|
|
9738
10086
|
description: "Apply the server layout a notifications provider declares in holocron.config.ts",
|
|
@@ -10560,6 +10908,32 @@ try {
|
|
|
10560
10908
|
}).command("doctor", "Load the config and run a smoke check against every provider", (y) => y.option("repo", {
|
|
10561
10909
|
type: "string",
|
|
10562
10910
|
describe: "Repo coords (\"owner/name\"). Defaults to plugin-specific resolution."
|
|
10911
|
+
}).command("notifications", "Check the layout against the server and the secrets store, step by step (read-only)", (y) => y.option("scope", {
|
|
10912
|
+
type: "string",
|
|
10913
|
+
describe: "Where the layout's secrets live: \"repo\", \"env=<name>\" or \"org=<name>\" (default: the org)"
|
|
10914
|
+
}).option("provider", {
|
|
10915
|
+
type: "string",
|
|
10916
|
+
describe: "Which notifications provider, when several can inspect"
|
|
10917
|
+
}).option("built-only", {
|
|
10918
|
+
type: "boolean",
|
|
10919
|
+
default: false,
|
|
10920
|
+
describe: "Leave out the features that are not built yet (a routine drift check)"
|
|
10921
|
+
}), async (argv) => {
|
|
10922
|
+
const tokens = tokenContext(argv.token);
|
|
10923
|
+
if (!tokens) return;
|
|
10924
|
+
const loaded = await loadConfig(argv.cwd);
|
|
10925
|
+
applyResolvedConfig(argv, loaded.resolved);
|
|
10926
|
+
if ((await runDoctorNotifications({
|
|
10927
|
+
loaded,
|
|
10928
|
+
context: {
|
|
10929
|
+
repoRoot: argv.cwd,
|
|
10930
|
+
...tokens,
|
|
10931
|
+
org: resolveOrg(argv, loaded.resolved)
|
|
10932
|
+
},
|
|
10933
|
+
...argv.scope ? { scope: parseScope(argv.scope) } : {},
|
|
10934
|
+
...argv.provider ? { provider: argv.provider } : {},
|
|
10935
|
+
...argv.builtOnly ? { builtOnly: true } : {}
|
|
10936
|
+
})).status === "fail") process.exitCode = 1;
|
|
10563
10937
|
}), async (argv) => {
|
|
10564
10938
|
const tokens = tokenContext(argv.token);
|
|
10565
10939
|
if (!tokens) return;
|