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