@theholocron/cli 5.0.0-alpha.185 → 5.0.0-alpha.187
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 +390 -32
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/plugin/capabilities.d.mts +38 -1
- package/package.json +3 -3
package/dist/cli.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import ora from "ora";
|
|
|
26
26
|
import { findRootSync, isWorkspaceRoot, walkFilesSync } from "@theholocron/fs-utils";
|
|
27
27
|
import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
28
28
|
import { stringifyJson } from "@theholocron/json-utils";
|
|
29
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
29
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
30
30
|
import { generateReadme } from "@theholocron/components-doc/markdown";
|
|
31
31
|
import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
|
|
32
32
|
import { createGitHubClient } from "@theholocron/github-client";
|
|
@@ -409,6 +409,7 @@ const COMMAND_CONTEXTS = {
|
|
|
409
409
|
"secrets sync": "workspace",
|
|
410
410
|
"notifications channel add": "workspace",
|
|
411
411
|
"notifications setup": "workspace",
|
|
412
|
+
"notifications verify": "workspace",
|
|
412
413
|
deploy: "workspace",
|
|
413
414
|
"deploy cleanup-preview": "workspace",
|
|
414
415
|
"monitoring check": "workspace",
|
|
@@ -2402,8 +2403,12 @@ async function runDeployFromFiles(input) {
|
|
|
2402
2403
|
* (newlines and the C1 range, whose 0x9b is an 8-bit CSI) so a crafted body
|
|
2403
2404
|
* can't forge extra report lines or inject terminal escape sequences. */
|
|
2404
2405
|
const MAX_DETAIL_LENGTH = 200;
|
|
2406
|
+
/** Control characters (newlines and the C1 range included) turned into spaces, whitespace collapsed, no length cap. */
|
|
2407
|
+
function stripControl(text) {
|
|
2408
|
+
return text.replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
|
|
2409
|
+
}
|
|
2405
2410
|
function sanitizeDetail(text) {
|
|
2406
|
-
const stripped = text
|
|
2411
|
+
const stripped = stripControl(text);
|
|
2407
2412
|
return stripped.length > MAX_DETAIL_LENGTH ? `${stripped.slice(0, MAX_DETAIL_LENGTH)}…` : stripped;
|
|
2408
2413
|
}
|
|
2409
2414
|
/**
|
|
@@ -2416,11 +2421,12 @@ function sanitizeDetail(text) {
|
|
|
2416
2421
|
* able to overwhelm or manipulate the terminal.
|
|
2417
2422
|
*/
|
|
2418
2423
|
function describeProviderError(err) {
|
|
2424
|
+
const base = stripControl(errorMessage(err));
|
|
2419
2425
|
if (err instanceof ProviderApiError$1 && err.details) {
|
|
2420
2426
|
const detail = sanitizeDetail(String(err.details));
|
|
2421
|
-
if (detail) return `${
|
|
2427
|
+
if (detail) return `${base}: ${detail}`;
|
|
2422
2428
|
}
|
|
2423
|
-
return
|
|
2429
|
+
return base;
|
|
2424
2430
|
}
|
|
2425
2431
|
/**
|
|
2426
2432
|
* What to put in structured logs: the plain message of the underlying error,
|
|
@@ -3249,8 +3255,16 @@ async function runNew(input) {
|
|
|
3249
3255
|
function defaultSecretName(channel) {
|
|
3250
3256
|
return `HOLOCRON_NOTIFY_WEBHOOK_${channel.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
3251
3257
|
}
|
|
3252
|
-
/**
|
|
3253
|
-
|
|
3258
|
+
/**
|
|
3259
|
+
* Prefix of the throwaway secret the preflight writes and removes. Every run adds a
|
|
3260
|
+
* random suffix, so it can never overwrite (and then delete) a secret someone already
|
|
3261
|
+
* has; layouts may not use any name under this prefix for their own secrets.
|
|
3262
|
+
*/
|
|
3263
|
+
const PREFLIGHT_PREFIX = "HOLOCRON_NOTIFY_PREFLIGHT";
|
|
3264
|
+
/** Whether a secret name is under the preflight's reserved prefix. GitHub secret names are case-insensitive. */
|
|
3265
|
+
function isReservedSecret(name) {
|
|
3266
|
+
return name.toUpperCase().startsWith(PREFLIGHT_PREFIX);
|
|
3267
|
+
}
|
|
3254
3268
|
/**
|
|
3255
3269
|
* Prove the secrets store can be WRITTEN before creating anything that needs it.
|
|
3256
3270
|
* A webhook's URL is shown once; creating the webhook and then failing to store
|
|
@@ -3259,32 +3273,78 @@ const PREFLIGHT_SECRET = "HOLOCRON_NOTIFY_PREFLIGHT";
|
|
|
3259
3273
|
* remove it.
|
|
3260
3274
|
*/
|
|
3261
3275
|
async function assertSecretsReachable(secrets, scope) {
|
|
3276
|
+
const probe = `${PREFLIGHT_PREFIX}_${randomBytes(4).toString("hex").toUpperCase()}`;
|
|
3262
3277
|
try {
|
|
3263
|
-
await secrets.setSecret(scope,
|
|
3264
|
-
await secrets.deleteSecret(scope,
|
|
3278
|
+
await secrets.setSecret(scope, probe, "ok");
|
|
3279
|
+
await secrets.deleteSecret(scope, probe);
|
|
3265
3280
|
} catch (err) {
|
|
3266
3281
|
const where = scope.kind === "organization" ? `organization ${scope.name}` : scope.kind;
|
|
3267
|
-
throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks`, { cause: err });
|
|
3282
|
+
throw new Error(`cannot write to the secrets store for ${where} (${describeProviderError(err)}) — fix that token before creating webhooks. If a secret named ${probe} was left behind, delete it`, { cause: err });
|
|
3268
3283
|
}
|
|
3269
3284
|
}
|
|
3270
3285
|
/** Why a capability is not loaded: the plugin's own load error when there is one (e.g. a missing token). */
|
|
3271
3286
|
function whyMissing(loader, key, fallback) {
|
|
3272
3287
|
const failure = loader.loadFailures().find((f) => f.key === key);
|
|
3273
|
-
return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${failure.error
|
|
3288
|
+
return failure ? `${fallback} (\`${failure.provider}\` failed to load: ${describeProviderError(failure.error)})` : fallback;
|
|
3274
3289
|
}
|
|
3275
|
-
/**
|
|
3276
|
-
function
|
|
3277
|
-
const
|
|
3278
|
-
if (
|
|
3290
|
+
/** The configured `notifications` providers, or an error that says why there are none (including a plugin that failed to load). */
|
|
3291
|
+
function notificationProviders(loader) {
|
|
3292
|
+
const providers = loader.has("notifications") ? loader.get("notifications") : [];
|
|
3293
|
+
if (providers.length === 0) throw new Error(whyMissing(loader, "notifications", "no `notifications` provider is available — add one to holocron.config.ts"));
|
|
3294
|
+
return providers;
|
|
3295
|
+
}
|
|
3296
|
+
/**
|
|
3297
|
+
* Pick the provider that can do what the command needs; with several, `--provider`
|
|
3298
|
+
* decides. `can` says what "able" means (a command that only reads needs `inspect`, not
|
|
3299
|
+
* provisioning), and `verb` words the errors.
|
|
3300
|
+
*/
|
|
3301
|
+
function pickProvider(providers, wanted, can, verb) {
|
|
3302
|
+
const capable = providers.filter(can);
|
|
3303
|
+
if (capable.length === 0) throw new Error(`no \`notifications\` provider can ${verb.infinitive} — add one that implements it (for example discord) to holocron.config.ts`);
|
|
3279
3304
|
if (wanted) {
|
|
3280
3305
|
const match = capable.find((p) => p.providerName === wanted);
|
|
3281
|
-
if (!match) throw new Error(`no
|
|
3306
|
+
if (!match) throw new Error(`no ${verb.gerund} \`notifications\` provider named \`${wanted}\` (have: ${names(capable)})`);
|
|
3282
3307
|
return match;
|
|
3283
3308
|
}
|
|
3284
|
-
if (capable.length > 1) throw new Error(`several \`notifications\` providers can
|
|
3309
|
+
if (capable.length > 1) throw new Error(`several \`notifications\` providers can ${verb.infinitive} (${names(capable)}) — pass --provider <name>`);
|
|
3285
3310
|
return capable[0];
|
|
3286
3311
|
}
|
|
3312
|
+
/** Pick the provider that can provision. Any ensure* method counts: a role-only layout needs no channel support. */
|
|
3313
|
+
function pickProvisioner(providers, wanted) {
|
|
3314
|
+
return pickProvider(providers, wanted, (p) => typeof p.ensureChannel === "function" || typeof p.ensureCategory === "function" || typeof p.ensureRole === "function", {
|
|
3315
|
+
infinitive: "provision",
|
|
3316
|
+
gerund: "provisioning"
|
|
3317
|
+
});
|
|
3318
|
+
}
|
|
3287
3319
|
const names = (ps) => ps.map((p) => p.providerName).join(", ");
|
|
3320
|
+
/**
|
|
3321
|
+
* The first reason the layout's secret names cannot work, or undefined. Two things
|
|
3322
|
+
* cannot share one secret: the second write would overwrite the first and callers
|
|
3323
|
+
* would read the wrong value. Punctuation normalising (release-notes vs
|
|
3324
|
+
* release_notes) makes that easy to hit by accident, GitHub secret names are
|
|
3325
|
+
* case-insensitive, and the preflight's own prefix is reserved. Shared by `setup`
|
|
3326
|
+
* (which refuses) and `verify` (which reports it).
|
|
3327
|
+
*/
|
|
3328
|
+
function layoutSecretProblem(layout) {
|
|
3329
|
+
const taken = /* @__PURE__ */ new Map();
|
|
3330
|
+
const claim = (secret, owner, hint) => {
|
|
3331
|
+
if (isReservedSecret(secret)) return `${owner} would store into ${secret}, a name reserved for the setup preflight — choose another`;
|
|
3332
|
+
const key = secret.toUpperCase();
|
|
3333
|
+
const other = taken.get(key);
|
|
3334
|
+
if (other) return `${other} and ${owner} would both store into ${secret} — ${hint}`;
|
|
3335
|
+
taken.set(key, owner);
|
|
3336
|
+
};
|
|
3337
|
+
for (const category of layout.categories ?? []) for (const channel of category.channels ?? []) {
|
|
3338
|
+
if (!channel.webhook) continue;
|
|
3339
|
+
const clash = claim(channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name), `channel \`${channel.name}\``, "give one a distinct `webhook.secret`");
|
|
3340
|
+
if (clash) return clash;
|
|
3341
|
+
}
|
|
3342
|
+
for (const role of layout.roles ?? []) {
|
|
3343
|
+
if (!role.secret) continue;
|
|
3344
|
+
const clash = claim(role.secret, `role \`${role.name}\``, "give one a distinct secret");
|
|
3345
|
+
if (clash) return clash;
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3288
3348
|
/** Ensure one channel and, if asked, its webhook + secret. Shared with `notifications setup`. */
|
|
3289
3349
|
async function provisionChannel(deps, spec) {
|
|
3290
3350
|
const { notifications, secrets, scope, dryRun, print } = deps;
|
|
@@ -3294,6 +3354,7 @@ async function provisionChannel(deps, spec) {
|
|
|
3294
3354
|
const where = spec.category ? ` in ${spec.category}` : "";
|
|
3295
3355
|
if (!notifications.ensureChannel) throw new Error(`\`${notifications.providerName}\` cannot create channels`);
|
|
3296
3356
|
if (webhookCfg && !notifications.ensureWebhook) throw new Error(`\`${notifications.providerName}\` cannot create webhooks`);
|
|
3357
|
+
if (webhookCfg && isReservedSecret(secretName)) throw new Error(`secret name ${secretName} is reserved for the setup preflight -- choose another`);
|
|
3297
3358
|
if (webhookCfg && !dryRun && !secrets) throw new Error("storing a webhook URL needs a `secrets` provider — add one to holocron.config.ts");
|
|
3298
3359
|
if (dryRun) {
|
|
3299
3360
|
print(` … would ensure channel ${spec.name}${where}`);
|
|
@@ -3357,7 +3418,7 @@ async function runNotificationsChannelAdd(input) {
|
|
|
3357
3418
|
const dryRun = input.context.dryRun ?? false;
|
|
3358
3419
|
print(`Holocron notifications channel add — ${input.name}${dryRun ? " (dry-run)" : ""}`);
|
|
3359
3420
|
try {
|
|
3360
|
-
const notifications = pickProvisioner(
|
|
3421
|
+
const notifications = pickProvisioner(notificationProviders(loader), input.provider);
|
|
3361
3422
|
let secrets;
|
|
3362
3423
|
if (input.withWebhook && !dryRun) {
|
|
3363
3424
|
if (!loader.has("secrets")) throw new Error(whyMissing(loader, "secrets", "`--with-webhook` needs a `secrets` capability — add a `secrets` provider to holocron.config.ts"));
|
|
@@ -3457,16 +3518,16 @@ async function runNotificationsSetup(input) {
|
|
|
3457
3518
|
};
|
|
3458
3519
|
let notifications;
|
|
3459
3520
|
try {
|
|
3460
|
-
notifications = pickProvisioner(
|
|
3521
|
+
notifications = pickProvisioner(notificationProviders(loader), input.provider);
|
|
3461
3522
|
} catch (err) {
|
|
3462
3523
|
return refuse(errorMessage(err));
|
|
3463
3524
|
}
|
|
3464
3525
|
const layout = notifications.layout;
|
|
3465
3526
|
if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
|
|
3466
|
-
const
|
|
3527
|
+
const wantsSecrets = (layout.categories ?? []).some((c) => (c.channels ?? []).some((ch) => ch.webhook)) || (layout.roles ?? []).some((r) => r.secret);
|
|
3467
3528
|
let secrets;
|
|
3468
|
-
if (
|
|
3469
|
-
if (!loader.has("secrets")) return refuse(whyMissing(loader, "secrets", "the layout
|
|
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"));
|
|
3470
3531
|
secrets = loader.get("secrets");
|
|
3471
3532
|
}
|
|
3472
3533
|
const org = input.context.org;
|
|
@@ -3474,20 +3535,14 @@ async function runNotificationsSetup(input) {
|
|
|
3474
3535
|
kind: "organization",
|
|
3475
3536
|
name: org
|
|
3476
3537
|
} : void 0);
|
|
3477
|
-
if (
|
|
3538
|
+
if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
|
|
3478
3539
|
if (secrets && scope && !dryRun) try {
|
|
3479
3540
|
await assertSecretsReachable(secrets, scope);
|
|
3480
3541
|
} catch (err) {
|
|
3481
3542
|
return refuse(errorMessage(err), logReason(err));
|
|
3482
3543
|
}
|
|
3483
|
-
const
|
|
3484
|
-
|
|
3485
|
-
if (!channel.webhook) continue;
|
|
3486
|
-
const secret = channel.webhook !== true && channel.webhook.secret || defaultSecretName(channel.name);
|
|
3487
|
-
const other = taken.get(secret);
|
|
3488
|
-
if (other) return refuse(`channels \`${other}\` and \`${channel.name}\` would both store their webhook in ${secret} — give one a distinct \`webhook.secret\``);
|
|
3489
|
-
taken.set(secret, channel.name);
|
|
3490
|
-
}
|
|
3544
|
+
const secretProblem = layoutSecretProblem(layout);
|
|
3545
|
+
if (secretProblem) return refuse(secretProblem);
|
|
3491
3546
|
print(`Holocron notifications setup — ${notifications.providerName}${dryRun ? " (dry-run)" : ""}`);
|
|
3492
3547
|
const attempt = async (step, fn) => {
|
|
3493
3548
|
try {
|
|
@@ -3537,10 +3592,11 @@ async function runNotificationsSetup(input) {
|
|
|
3537
3592
|
for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
|
|
3538
3593
|
if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
|
|
3539
3594
|
if (dryRun) {
|
|
3540
|
-
print(` … would ensure role ${role.name}`);
|
|
3595
|
+
print(` … would ensure role ${role.name}${role.secret ? ` and store its id in ${role.secret}` : ""}`);
|
|
3541
3596
|
report.roles.push({
|
|
3542
3597
|
name: role.name,
|
|
3543
|
-
created: false
|
|
3598
|
+
created: false,
|
|
3599
|
+
...role.secret ? { secret: role.secret } : {}
|
|
3544
3600
|
});
|
|
3545
3601
|
return;
|
|
3546
3602
|
}
|
|
@@ -3555,6 +3611,12 @@ async function runNotificationsSetup(input) {
|
|
|
3555
3611
|
};
|
|
3556
3612
|
report.roles.push(outcome);
|
|
3557
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
|
+
}
|
|
3558
3620
|
});
|
|
3559
3621
|
if (report.failures.length > 0) {
|
|
3560
3622
|
report.status = "fail";
|
|
@@ -3570,6 +3632,270 @@ async function runNotificationsSetup(input) {
|
|
|
3570
3632
|
return report;
|
|
3571
3633
|
}
|
|
3572
3634
|
//#endregion
|
|
3635
|
+
//#region src/commands/notifications-verify/notifications-verify.ts
|
|
3636
|
+
/**
|
|
3637
|
+
* `holocron notifications verify` -- read-only: compare the layout a provider
|
|
3638
|
+
* declares in `holocron.config.ts` with what is actually on the server and in the
|
|
3639
|
+
* secrets store, step by step.
|
|
3640
|
+
*
|
|
3641
|
+
* It is the end-to-end check for `notifications setup`, and stays useful after
|
|
3642
|
+
* as a drift check. Everything the layout and `setup` cannot do yet is listed
|
|
3643
|
+
* too, as a failing "not built" step, so the command is red until the last
|
|
3644
|
+
* feature lands and each feature built turns exactly one step green. The list
|
|
3645
|
+
* is {@link PLANNED_CHECKS}; the numbers match the table in holocron#1098.
|
|
3646
|
+
*/
|
|
3647
|
+
/** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
|
|
3648
|
+
const PLANNED_CHECKS = [
|
|
3649
|
+
{
|
|
3650
|
+
step: 8,
|
|
3651
|
+
id: "permissions",
|
|
3652
|
+
title: "channel and category permissions"
|
|
3653
|
+
},
|
|
3654
|
+
{
|
|
3655
|
+
step: 9,
|
|
3656
|
+
id: "role-appearance",
|
|
3657
|
+
title: "role colour, hoist and permissions"
|
|
3658
|
+
},
|
|
3659
|
+
{
|
|
3660
|
+
step: 10,
|
|
3661
|
+
id: "server-settings",
|
|
3662
|
+
title: "server settings (verification, notifications, system channels)"
|
|
3663
|
+
},
|
|
3664
|
+
{
|
|
3665
|
+
step: 11,
|
|
3666
|
+
id: "welcome",
|
|
3667
|
+
title: "welcome and onboarding content"
|
|
3668
|
+
},
|
|
3669
|
+
{
|
|
3670
|
+
step: 12,
|
|
3671
|
+
id: "webhook-appearance",
|
|
3672
|
+
title: "webhook names and avatars"
|
|
3673
|
+
},
|
|
3674
|
+
{
|
|
3675
|
+
step: 13,
|
|
3676
|
+
id: "app-settings",
|
|
3677
|
+
title: "bot application settings (description, tags, install, links)"
|
|
3678
|
+
},
|
|
3679
|
+
{
|
|
3680
|
+
step: 14,
|
|
3681
|
+
id: "role-assignment",
|
|
3682
|
+
title: "role assigned to team members"
|
|
3683
|
+
},
|
|
3684
|
+
{
|
|
3685
|
+
step: 15,
|
|
3686
|
+
id: "post-works",
|
|
3687
|
+
title: "a test post reached every channel (platform.notifyCheck)"
|
|
3688
|
+
},
|
|
3689
|
+
{
|
|
3690
|
+
step: 16,
|
|
3691
|
+
id: "server-creation",
|
|
3692
|
+
title: "the server itself created from config"
|
|
3693
|
+
}
|
|
3694
|
+
];
|
|
3695
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
3696
|
+
const shown = (s) => sanitizeDetail(s);
|
|
3697
|
+
async function runNotificationsVerify(input) {
|
|
3698
|
+
const print = input.print ?? ((line) => console.log(line));
|
|
3699
|
+
const logger = input.logger ?? getLogger();
|
|
3700
|
+
/* v8 ignore next -- the real loader is only built outside tests, which always inject one */
|
|
3701
|
+
const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
|
|
3702
|
+
await loader.load();
|
|
3703
|
+
assertPluginsResolvable(loader, "notifications verify");
|
|
3704
|
+
const checks = [];
|
|
3705
|
+
const add = (id, title, problems, pass) => {
|
|
3706
|
+
checks.push({
|
|
3707
|
+
id,
|
|
3708
|
+
title,
|
|
3709
|
+
status: problems.length === 0 ? "pass" : "fail",
|
|
3710
|
+
detail: problems.length === 0 ? pass : `${problems.length} problem(s)`,
|
|
3711
|
+
problems
|
|
3712
|
+
});
|
|
3713
|
+
};
|
|
3714
|
+
const byId = (id) => checks.find((c) => c.id === id);
|
|
3715
|
+
const failed = (id, title, reason) => {
|
|
3716
|
+
checks.push({
|
|
3717
|
+
id,
|
|
3718
|
+
title,
|
|
3719
|
+
status: "fail",
|
|
3720
|
+
detail: reason,
|
|
3721
|
+
problems: []
|
|
3722
|
+
});
|
|
3723
|
+
};
|
|
3724
|
+
const summarise = () => ({
|
|
3725
|
+
pass: checks.filter((c) => c.status === "pass").length,
|
|
3726
|
+
fail: checks.filter((c) => c.status === "fail").length,
|
|
3727
|
+
planned: checks.filter((c) => c.status === "planned").length
|
|
3728
|
+
});
|
|
3729
|
+
const finish = () => {
|
|
3730
|
+
const summary = summarise();
|
|
3731
|
+
const report = {
|
|
3732
|
+
status: summary.fail + summary.planned > 0 ? "fail" : "ok",
|
|
3733
|
+
checks,
|
|
3734
|
+
summary
|
|
3735
|
+
};
|
|
3736
|
+
logger.info({
|
|
3737
|
+
...summary,
|
|
3738
|
+
status: report.status,
|
|
3739
|
+
checks: checks.map((c) => `${c.id}:${c.status}`)
|
|
3740
|
+
}, "notifications verify");
|
|
3741
|
+
return report;
|
|
3742
|
+
};
|
|
3743
|
+
const refuse = (message, reason = message) => {
|
|
3744
|
+
print(` ✗ ${message}`);
|
|
3745
|
+
logger.warn({ reason }, "notifications verify");
|
|
3746
|
+
return {
|
|
3747
|
+
status: "fail",
|
|
3748
|
+
checks,
|
|
3749
|
+
summary: summarise(),
|
|
3750
|
+
message
|
|
3751
|
+
};
|
|
3752
|
+
};
|
|
3753
|
+
let notifications;
|
|
3754
|
+
try {
|
|
3755
|
+
notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.inspect === "function", {
|
|
3756
|
+
infinitive: "inspect a server",
|
|
3757
|
+
gerund: "inspecting"
|
|
3758
|
+
});
|
|
3759
|
+
} catch (err) {
|
|
3760
|
+
return refuse(describeProviderError(err));
|
|
3761
|
+
}
|
|
3762
|
+
const layout = notifications.layout;
|
|
3763
|
+
if (!layout) return refuse(`the \`${notifications.providerName}\` provider declares no layout — add \`layout\` to its options in holocron.config.ts`);
|
|
3764
|
+
print(`Holocron notifications verify — ${notifications.providerName}\n`);
|
|
3765
|
+
let found;
|
|
3766
|
+
let readError = "";
|
|
3767
|
+
try {
|
|
3768
|
+
found = await notifications.inspect(layout);
|
|
3769
|
+
} catch (err) {
|
|
3770
|
+
readError = describeProviderError(err);
|
|
3771
|
+
}
|
|
3772
|
+
if (notifications.whoami) try {
|
|
3773
|
+
const who = await notifications.whoami();
|
|
3774
|
+
if (who) checks.push({
|
|
3775
|
+
id: "server",
|
|
3776
|
+
title: "server",
|
|
3777
|
+
status: "pass",
|
|
3778
|
+
detail: shown(who.target),
|
|
3779
|
+
problems: []
|
|
3780
|
+
});
|
|
3781
|
+
else failed("server", "server", "no server configured (the provider's whoami has nothing to check)");
|
|
3782
|
+
} catch (err) {
|
|
3783
|
+
failed("server", "server", describeProviderError(err));
|
|
3784
|
+
}
|
|
3785
|
+
else if (found) checks.push({
|
|
3786
|
+
id: "server",
|
|
3787
|
+
title: "server",
|
|
3788
|
+
status: "pass",
|
|
3789
|
+
detail: "readable (the provider has no whoami)",
|
|
3790
|
+
problems: []
|
|
3791
|
+
});
|
|
3792
|
+
else failed("server", "server", readError);
|
|
3793
|
+
const declaredChannels = (layout.categories ?? []).flatMap((cat) => (cat.channels ?? []).map((channel) => ({
|
|
3794
|
+
cat: cat.name,
|
|
3795
|
+
channel
|
|
3796
|
+
})));
|
|
3797
|
+
const pick = (name, category) => {
|
|
3798
|
+
const named = found.channels.filter((c) => same(c.name, name));
|
|
3799
|
+
return {
|
|
3800
|
+
named,
|
|
3801
|
+
match: named.find((c) => c.category !== void 0 && same(c.category, category))
|
|
3802
|
+
};
|
|
3803
|
+
};
|
|
3804
|
+
if (!found) {
|
|
3805
|
+
if (byId("server")?.status !== "fail") failed("read", "read server", readError);
|
|
3806
|
+
for (const [id, title] of [
|
|
3807
|
+
["categories", "categories"],
|
|
3808
|
+
["channels", "channels"],
|
|
3809
|
+
["webhooks", "webhooks"],
|
|
3810
|
+
["roles", "roles"]
|
|
3811
|
+
]) failed(id, title, "not checked: could not read the server");
|
|
3812
|
+
} else {
|
|
3813
|
+
const inspected = found;
|
|
3814
|
+
const categories = layout.categories ?? [];
|
|
3815
|
+
add("categories", "categories", categories.filter((cat) => !inspected.categories.some((name) => same(name, cat.name))).map((cat) => `${shown(cat.name)}: missing`), categories.length === 0 ? "none declared" : `${categories.length} of ${categories.length} exist`);
|
|
3816
|
+
const channelProblems = [];
|
|
3817
|
+
for (const { cat, channel } of declaredChannels) {
|
|
3818
|
+
const { named, match } = pick(channel.name, cat);
|
|
3819
|
+
if (named.length === 0) {
|
|
3820
|
+
channelProblems.push(`#${shown(channel.name)}: missing`);
|
|
3821
|
+
continue;
|
|
3822
|
+
}
|
|
3823
|
+
if (!match) {
|
|
3824
|
+
const where = named.map((c) => c.category ?? "no category").join(", ");
|
|
3825
|
+
channelProblems.push(`#${shown(channel.name)}: exists, but under ${shown(where)} and not ${shown(cat)}`);
|
|
3826
|
+
continue;
|
|
3827
|
+
}
|
|
3828
|
+
if (channel.topic && (match.topic ?? "") !== channel.topic) channelProblems.push(`#${shown(channel.name)}: topic is "${shown(match.topic ?? "")}" but the layout says "${shown(channel.topic)}"`);
|
|
3829
|
+
}
|
|
3830
|
+
add("channels", "channels", channelProblems, declaredChannels.length === 0 ? "none declared" : `${declaredChannels.length} of ${declaredChannels.length} as declared`);
|
|
3831
|
+
const withWebhook = declaredChannels.filter(({ channel }) => channel.webhook);
|
|
3832
|
+
const webhookProblems = [];
|
|
3833
|
+
for (const { cat, channel } of withWebhook) {
|
|
3834
|
+
const name = (channel.webhook === true ? {} : channel.webhook).name ?? channel.name;
|
|
3835
|
+
const { match } = pick(channel.name, cat);
|
|
3836
|
+
if (!match) webhookProblems.push(`#${shown(channel.name)}: no channel to hold the webhook`);
|
|
3837
|
+
else if (match.webhooksError) webhookProblems.push(`#${shown(channel.name)}: could not list its webhooks (${shown(match.webhooksError)})`);
|
|
3838
|
+
else if (!match.webhooks.some((w) => same(w, name))) webhookProblems.push(`#${shown(channel.name)}: no webhook named ${shown(name)}`);
|
|
3839
|
+
}
|
|
3840
|
+
add("webhooks", "webhooks", webhookProblems, withWebhook.length === 0 ? "none declared" : `${withWebhook.length} of ${withWebhook.length} exist`);
|
|
3841
|
+
const roles = layout.roles ?? [];
|
|
3842
|
+
const roleProblems = [];
|
|
3843
|
+
if (inspected.rolesError) roleProblems.push(`could not read the roles (${shown(inspected.rolesError)})`);
|
|
3844
|
+
for (const role of inspected.rolesError ? [] : roles) {
|
|
3845
|
+
const actual = inspected.roles.find((r) => same(r.name, role.name));
|
|
3846
|
+
if (!actual) roleProblems.push(`${shown(role.name)}: missing`);
|
|
3847
|
+
else if (role.mentionable !== void 0 && actual.mentionable !== role.mentionable) roleProblems.push(`${shown(role.name)}: mentionable is ${actual.mentionable} but the layout says ${role.mentionable}`);
|
|
3848
|
+
}
|
|
3849
|
+
add("roles", "roles", roleProblems, roles.length === 0 ? "none declared" : `${roles.length} of ${roles.length} as declared`);
|
|
3850
|
+
}
|
|
3851
|
+
const wanted = [...declaredChannels.filter(({ channel }) => channel.webhook).map(({ channel }) => channel.webhook !== true && channel.webhook && channel.webhook.secret ? channel.webhook.secret : defaultSecretName(channel.name)), ...(layout.roles ?? []).flatMap((r) => r.secret ? [r.secret] : [])];
|
|
3852
|
+
if (wanted.length === 0) checks.push({
|
|
3853
|
+
id: "secrets",
|
|
3854
|
+
title: "secrets",
|
|
3855
|
+
status: "pass",
|
|
3856
|
+
detail: "none declared",
|
|
3857
|
+
problems: []
|
|
3858
|
+
});
|
|
3859
|
+
else if (!loader.has("secrets")) failed("secrets", "secrets", whyMissing(loader, "secrets", "no `secrets` provider to check them with"));
|
|
3860
|
+
else {
|
|
3861
|
+
const org = input.context.org;
|
|
3862
|
+
const scope = input.scope ?? (org ? {
|
|
3863
|
+
kind: "organization",
|
|
3864
|
+
name: org
|
|
3865
|
+
} : void 0);
|
|
3866
|
+
if (!scope) failed("secrets", "secrets", "no org to look under — pass --scope or set `org` in holocron.config.ts");
|
|
3867
|
+
else try {
|
|
3868
|
+
const have = new Set((await loader.get("secrets").listSecrets(scope)).map((n) => n.toUpperCase()));
|
|
3869
|
+
const clash = layoutSecretProblem(layout);
|
|
3870
|
+
add("secrets", "secrets", [...clash ? [shown(clash)] : [], ...wanted.filter((n) => !have.has(n.toUpperCase())).map((n) => `${shown(n)}: missing`)], `${wanted.length} of ${wanted.length} exist`);
|
|
3871
|
+
} catch (err) {
|
|
3872
|
+
failed("secrets", "secrets", describeProviderError(err));
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
if (!input.builtOnly) for (const planned of PLANNED_CHECKS) checks.push({
|
|
3876
|
+
id: planned.id,
|
|
3877
|
+
title: planned.title,
|
|
3878
|
+
status: "planned",
|
|
3879
|
+
detail: `not built yet (#1098, step ${planned.step})`,
|
|
3880
|
+
problems: [],
|
|
3881
|
+
step: planned.step
|
|
3882
|
+
});
|
|
3883
|
+
const built = checks.filter((c) => c.status !== "planned");
|
|
3884
|
+
const width = Math.max(...built.map((c) => c.title.length));
|
|
3885
|
+
for (const check of built) {
|
|
3886
|
+
print(` ${check.status === "pass" ? "✓" : "✗"} ${check.title.padEnd(width)} ${check.detail}`);
|
|
3887
|
+
for (const problem of check.problems) print(` - ${problem}`);
|
|
3888
|
+
}
|
|
3889
|
+
const planned = checks.filter((c) => c.status === "planned");
|
|
3890
|
+
if (planned.length > 0) {
|
|
3891
|
+
print("\nNot built yet (each fails until it lands, see #1098):");
|
|
3892
|
+
for (const check of planned) print(` ✗ ${String(check.step).padStart(2)} ${check.title}`);
|
|
3893
|
+
}
|
|
3894
|
+
const summary = summarise();
|
|
3895
|
+
print(`\n${summary.pass} passed, ${summary.fail} failed${input.builtOnly ? "" : `, ${summary.planned} not built`}`);
|
|
3896
|
+
return finish();
|
|
3897
|
+
}
|
|
3898
|
+
//#endregion
|
|
3573
3899
|
//#region src/commands/npm-bump-versions/npm-bump-versions.ts
|
|
3574
3900
|
async function runNpmBumpVersions(input) {
|
|
3575
3901
|
const print = input.print ?? ((line) => console.log(line));
|
|
@@ -9706,6 +10032,12 @@ const COMMAND_REGISTRY = [
|
|
|
9706
10032
|
}],
|
|
9707
10033
|
supportsDryRun: true
|
|
9708
10034
|
},
|
|
10035
|
+
{
|
|
10036
|
+
name: "notifications verify",
|
|
10037
|
+
description: "Check the layout against the server and the secrets store, step by step (read-only)",
|
|
10038
|
+
positionals: [],
|
|
10039
|
+
supportsDryRun: false
|
|
10040
|
+
},
|
|
9709
10041
|
{
|
|
9710
10042
|
name: "notifications setup",
|
|
9711
10043
|
description: "Apply the server layout a notifications provider declares in holocron.config.ts",
|
|
@@ -11264,6 +11596,32 @@ try {
|
|
|
11264
11596
|
...argv.scope ? { scope: parseScope(argv.scope) } : {},
|
|
11265
11597
|
...argv.provider ? { provider: argv.provider } : {}
|
|
11266
11598
|
})).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;
|
|
11267
11625
|
}).command("$0", false, () => {}, async (argv) => {
|
|
11268
11626
|
await launchGroupMenu("notifications", argv);
|
|
11269
11627
|
}), () => {}).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", {
|