@theholocron/cli 5.0.0-alpha.199 → 5.0.0-alpha.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -26,6 +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 { createHash, randomBytes, randomUUID } from "node:crypto";
29
+ import { createServer } from "node:http";
29
30
  import { stringifyJson } from "@theholocron/json-utils";
30
31
  import { generateReadme } from "@theholocron/components-doc/markdown";
31
32
  import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
@@ -409,6 +410,8 @@ const COMMAND_CONTEXTS = {
409
410
  "secrets sync": "workspace",
410
411
  "notifications channel add": "workspace",
411
412
  "notifications setup": "workspace",
413
+ "notifications link": "workspace",
414
+ "notifications sync-roles": "workspace",
412
415
  "doctor notifications": "workspace",
413
416
  deploy: "workspace",
414
417
  "deploy cleanup-preview": "workspace",
@@ -2945,6 +2948,570 @@ async function runNotificationsChannelAdd(input) {
2945
2948
  };
2946
2949
  }
2947
2950
  }
2951
+ const localRedirectUri = (port) => `http://localhost:${port}/callback`;
2952
+ /**
2953
+ * The hosted service answers `GET <hostedUrl>/api/link` with `{ "service": "link" }`. Anything else (a 402 for
2954
+ * an exhausted plan, a 404 before the route exists, a timeout) means it cannot serve sign-in now.
2955
+ */
2956
+ async function resolveCallback(input) {
2957
+ const port = input.port ?? 53682;
2958
+ const local = (reason) => ({
2959
+ kind: "local",
2960
+ redirectUri: localRedirectUri(port),
2961
+ reason
2962
+ });
2963
+ if (!input.hostedUrl) return local("no hosted URL is configured (`layout.links.hostedUrl`)");
2964
+ let base = input.hostedUrl;
2965
+ while (base.endsWith("/")) base = base.slice(0, -1);
2966
+ try {
2967
+ const res = await (input.fetch ?? fetch)(`${base}/api/link`, {
2968
+ headers: { accept: "application/json" },
2969
+ signal: AbortSignal.timeout(input.timeoutMs ?? 5e3)
2970
+ });
2971
+ if (res.status === 402) return local(`${base} answered 402 (the plan's quota is used up)`);
2972
+ if (!res.ok) return local(`${base}/api/link answered ${res.status}`);
2973
+ if ((await res.json().catch(() => ({}))).service !== "link") return local(`${base}/api/link is not the link service`);
2974
+ return {
2975
+ kind: "hosted",
2976
+ redirectUri: `${base}/api/link/callback`,
2977
+ reason: `${base} serves sign-in`
2978
+ };
2979
+ } catch {
2980
+ return local(`${base} did not answer`);
2981
+ }
2982
+ }
2983
+ /** The portal step, worded for whichever redirect is wanted, for `link` and the doctor to show. */
2984
+ function redirectInstruction(redirectUri) {
2985
+ return `add ${redirectUri} under OAuth2 → Redirects in the Discord developer portal (the API does not allow setting it)`;
2986
+ }
2987
+ //#endregion
2988
+ //#region src/commands/notifications-link/listener.ts
2989
+ const PAGE = (title, body) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:3rem auto;max-width:32rem"><h1>${title}</h1><p>${body}</p>`;
2990
+ /**
2991
+ * Listens once on this machine for the browser coming back from the provider, and resolves with the `code` it
2992
+ * carries. It accepts nothing but a `/callback` whose `state` is the one that was handed out, answers every
2993
+ * other request with 404, stops after the first good answer, and gives up after `timeoutMs`.
2994
+ * Bound to the loopback address only, so nothing else on the network can reach it.
2995
+ */
2996
+ function waitForCode(input) {
2997
+ return new Promise((resolve, reject) => {
2998
+ let done = false;
2999
+ const finish = (fn) => {
3000
+ /* v8 ignore next -- a second event after the first has settled; the guard keeps it harmless */
3001
+ if (done) return;
3002
+ done = true;
3003
+ clearTimeout(timer);
3004
+ server.close();
3005
+ server.closeAllConnections();
3006
+ fn();
3007
+ };
3008
+ const server = createServer((req, res) => {
3009
+ /* v8 ignore next -- a server request always has a url */
3010
+ const url = new URL(req.url ?? "/", "http://localhost");
3011
+ if (req.method !== "GET" || url.pathname !== "/callback") {
3012
+ res.writeHead(404).end();
3013
+ return;
3014
+ }
3015
+ const reply = (status, title, body) => {
3016
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(PAGE(title, body));
3017
+ };
3018
+ if (url.searchParams.get("state") !== input.state) {
3019
+ reply(400, "Wrong sign-in", "This link does not belong to the running command. Start it again.");
3020
+ return;
3021
+ }
3022
+ const error = url.searchParams.get("error");
3023
+ const code = url.searchParams.get("code");
3024
+ if (error || !code) {
3025
+ reply(400, "Sign-in was not approved", "You can close this window.");
3026
+ finish(() => reject(/* @__PURE__ */ new Error(`the sign-in was not approved (${error ?? "no code returned"})`)));
3027
+ return;
3028
+ }
3029
+ reply(200, "You are signed in", "You can close this window and go back to the terminal.");
3030
+ finish(() => resolve(code));
3031
+ });
3032
+ const timer = setTimeout(() => finish(() => reject(/* @__PURE__ */ new Error("timed out waiting for the sign-in to come back"))), input.timeoutMs ?? 3e5);
3033
+ server.once("error", (err) => finish(() => reject(err.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`port ${input.port} is already in use; stop what is using it or set \`layout.links.localPort\``) : /* v8 ignore start -- an error other than the port being taken cannot be provoked in a test */ err)));
3034
+ server.listen(input.port, "127.0.0.1", () => {
3035
+ const address = server.address();
3036
+ /* v8 ignore next -- a bound TCP server always reports an address object */
3037
+ input.onListening?.(typeof address === "object" && address ? address.port : input.port);
3038
+ });
3039
+ });
3040
+ }
3041
+ //#endregion
3042
+ //#region src/commands/notifications-sync-roles/links.ts
3043
+ const EMPTY_LINKS = {
3044
+ version: 1,
3045
+ links: []
3046
+ };
3047
+ const DIGITS = /^\d{1,20}$/;
3048
+ /** Reads the file's text. Throws with a plain reason on anything that is not a links file, so a damaged one is never treated as empty. */
3049
+ function parseLinks(text) {
3050
+ let raw;
3051
+ try {
3052
+ raw = JSON.parse(text);
3053
+ } catch {
3054
+ throw new Error("the links file is not valid JSON");
3055
+ }
3056
+ const file = raw;
3057
+ if (file.version !== 1) throw new Error(`the links file has version ${JSON.stringify(file.version)}; expected 1`);
3058
+ if (!Array.isArray(file.links)) throw new Error("the links file has no `links` list");
3059
+ const links = file.links.map((entry, i) => {
3060
+ const e = entry;
3061
+ if (typeof e.github !== "string" || !DIGITS.test(e.github)) throw new Error(`links[${i}]: \`github\` must be a numeric user id string`);
3062
+ if (typeof e.member !== "string" || !DIGITS.test(e.member)) throw new Error(`links[${i}]: \`member\` must be a numeric id string`);
3063
+ if (typeof e.login !== "string") throw new Error(`links[${i}]: \`login\` must be a string`);
3064
+ return {
3065
+ github: e.github,
3066
+ login: e.login,
3067
+ member: e.member
3068
+ };
3069
+ });
3070
+ for (const key of ["github", "member"]) {
3071
+ const seen = /* @__PURE__ */ new Set();
3072
+ for (const [i, link] of links.entries()) {
3073
+ if (seen.has(link[key])) throw new Error(`links[${i}]: \`${key}\` ${link[key]} appears more than once`);
3074
+ seen.add(link[key]);
3075
+ }
3076
+ }
3077
+ return {
3078
+ version: 1,
3079
+ links
3080
+ };
3081
+ }
3082
+ /** Stable text: sorted by GitHub id, two-space JSON, one trailing newline, so a change is a clean diff. */
3083
+ function serializeLinks(file) {
3084
+ const links = [...file.links].sort((a, b) => BigInt(a.github) < BigInt(b.github) ? -1 : 1);
3085
+ return `${JSON.stringify({
3086
+ version: 1,
3087
+ links
3088
+ }, null, 2)}\n`;
3089
+ }
3090
+ /**
3091
+ * Records that `link.github` and `link.member` are the same person. A GitHub account has one member and a
3092
+ * member one GitHub account, so any earlier entry for either is replaced. `changed` is false when the file
3093
+ * already said exactly this.
3094
+ */
3095
+ function upsertLink(file, link) {
3096
+ const next = {
3097
+ version: 1,
3098
+ links: [...file.links.filter((l) => l.github !== link.github && l.member !== link.member), link]
3099
+ };
3100
+ return {
3101
+ file: next,
3102
+ changed: serializeLinks(next) !== serializeLinks(file)
3103
+ };
3104
+ }
3105
+ /** Where the links file is: the layout's `links`, else `<org>/.github-private` and `notifications/<provider>-links.json`. */
3106
+ function linksLocation(layout, org, provider) {
3107
+ const repo = layout.links?.repo ?? (org ? `${org}/.github-private` : void 0);
3108
+ if (!repo) throw new Error("no org to find the links file under — set `layout.links.repo` or `org` in holocron.config.ts");
3109
+ return {
3110
+ repo,
3111
+ path: layout.links?.path ?? `notifications/${provider.toLowerCase()}-links.json`
3112
+ };
3113
+ }
3114
+ //#endregion
3115
+ //#region src/commands/notifications-sync-roles/plan.ts
3116
+ /**
3117
+ * What it takes to make a role's holders equal a team, given who is linked to whom. Pure: it decides, the
3118
+ * caller acts. A holder is removed only when a link says they are a person who left the team; a holder
3119
+ * nobody has linked is reported, never removed, so an empty or missing links file cannot strip the role from
3120
+ * everyone.
3121
+ */
3122
+ function planRoleSync(input) {
3123
+ const byGithub = new Map(input.links.map((l) => [l.github, l]));
3124
+ const byMember = new Map(input.links.map((l) => [l.member, l]));
3125
+ const onTeam = new Set(input.team.map((m) => m.id));
3126
+ const holding = new Set(input.holders.map((h) => h.id));
3127
+ const plan = {
3128
+ add: [],
3129
+ remove: [],
3130
+ unlinked: [],
3131
+ unknownHolders: [],
3132
+ inSync: 0
3133
+ };
3134
+ for (const person of input.team) {
3135
+ const link = byGithub.get(person.id);
3136
+ if (!link) plan.unlinked.push(person);
3137
+ else if (holding.has(link.member)) plan.inSync++;
3138
+ else plan.add.push({
3139
+ member: link.member,
3140
+ login: person.login
3141
+ });
3142
+ }
3143
+ for (const holder of input.holders) {
3144
+ const link = byMember.get(holder.id);
3145
+ if (!link) plan.unknownHolders.push({
3146
+ member: holder.id,
3147
+ ...holder.name ? { name: holder.name } : {}
3148
+ });
3149
+ else if (!onTeam.has(link.github)) plan.remove.push({
3150
+ member: holder.id,
3151
+ login: link.login
3152
+ });
3153
+ }
3154
+ return plan;
3155
+ }
3156
+ /**
3157
+ * The first role the layout declares for two different teams, or undefined. Names compare ignoring case, as
3158
+ * the server does; the role would be synced against one team and then the other, so the last would win.
3159
+ */
3160
+ function roleTeamProblem(layout) {
3161
+ const seen = /* @__PURE__ */ new Map();
3162
+ for (const role of layout.roles ?? []) {
3163
+ if (!role.team) continue;
3164
+ const key = role.name.toLowerCase();
3165
+ const earlier = seen.get(key);
3166
+ if (earlier !== void 0 && earlier.toLowerCase() !== role.team.toLowerCase()) return `role \`${role.name}\` is declared for two teams (\`${earlier}\` and \`${role.team}\`); a role follows one team`;
3167
+ seen.set(key, role.team);
3168
+ }
3169
+ }
3170
+ //#endregion
3171
+ //#region src/commands/notifications-sync-roles/notifications-sync-roles.ts
3172
+ /**
3173
+ * `holocron notifications sync-roles` — make who holds a server role match a team.
3174
+ *
3175
+ * A role that declares `team` in the layout is kept equal to that team of the `source` provider (for GitHub,
3176
+ * a team in the org). A team member is matched to a server member through the links file, so only people who
3177
+ * have linked their account can be given the role; the rest are listed. The role is taken from a linked
3178
+ * person who has left the team, but never from someone nobody has linked: an empty or missing links file
3179
+ * cannot strip the role from everyone.
3180
+ *
3181
+ * Idempotent and safe to re-run. `--dry-run` previews it.
3182
+ */
3183
+ /** The provider that can list and change who holds a role. */
3184
+ function pickRoleManager(providers, wanted, keep = false) {
3185
+ return pickProvider(providers, wanted, (p) => typeof p.roleMembers === "function" && typeof p.addRoleMember === "function" && (keep || typeof p.removeRoleMember === "function"), {
3186
+ infinitive: "manage role members",
3187
+ gerund: "managing role members"
3188
+ });
3189
+ }
3190
+ /** What a source provider needs to be able to do to feed a role sync, or the reason it cannot. */
3191
+ function sourceProblem(source) {
3192
+ if (!source) return "no `source` provider is available to read the team and the links file";
3193
+ if (!source.teamMembers) return `the \`${source.providerName}\` provider cannot list team members`;
3194
+ if (!source.readFile) return `the \`${source.providerName}\` provider cannot read the links file`;
3195
+ }
3196
+ /** Reads the links file; a file that does not exist yet is an empty one, a damaged one throws. */
3197
+ async function readLinks(source, layout, org, provider) {
3198
+ const { repo, path } = linksLocation(layout, org, provider);
3199
+ const file = await source.readFile(path, repo);
3200
+ if (!file) return EMPTY_LINKS;
3201
+ try {
3202
+ return parseLinks(file.content);
3203
+ } catch (err) {
3204
+ throw new Error(`${repo}/${path}: ${errorMessage(err)}`, { cause: err });
3205
+ }
3206
+ }
3207
+ /** Reads the team, the links and the role's holders, and works out what has to change. */
3208
+ async function planForRole(args) {
3209
+ const [team, holders] = await Promise.all([args.source.teamMembers(args.team, args.org), args.notifications.roleMembers(args.role)]);
3210
+ return planRoleSync({
3211
+ team,
3212
+ links: args.links.links,
3213
+ holders
3214
+ });
3215
+ }
3216
+ async function runNotificationsSyncRoles(input) {
3217
+ const print = input.print ?? ((line) => console.log(line));
3218
+ const logger = input.logger ?? getLogger();
3219
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3220
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3221
+ await loader.load();
3222
+ assertPluginsResolvable(loader, "notifications sync-roles");
3223
+ const dryRun = input.context.dryRun ?? false;
3224
+ const report = {
3225
+ status: "ok",
3226
+ roles: [],
3227
+ failures: []
3228
+ };
3229
+ const refuse = (message, reason = message) => {
3230
+ print(` ${style.fail(message)}`);
3231
+ print("");
3232
+ print(style.header("0 ok, 1 fail, 0 skipped"));
3233
+ logger.warn({ reason }, "notifications sync-roles");
3234
+ return {
3235
+ ...report,
3236
+ status: "fail",
3237
+ message
3238
+ };
3239
+ };
3240
+ let notifications;
3241
+ try {
3242
+ notifications = pickRoleManager(notificationProviders(loader), input.provider, input.keep);
3243
+ } catch (err) {
3244
+ return refuse(errorMessage(err));
3245
+ }
3246
+ const layout = notifications.layout;
3247
+ const roles = (layout?.roles ?? []).filter((r) => r.team);
3248
+ if (!layout || roles.length === 0) return refuse(`no role in the \`${notifications.providerName}\` layout declares a \`team\` — add \`team: "<slug>"\` to a role in holocron.config.ts`);
3249
+ const conflict = roleTeamProblem(layout);
3250
+ if (conflict) return refuse(conflict);
3251
+ const source = loader.has("source") ? loader.get("source") : void 0;
3252
+ const problem = sourceProblem(source);
3253
+ if (problem) return refuse(loader.has("source") ? problem : whyMissing(loader, "source", problem));
3254
+ let links;
3255
+ try {
3256
+ links = await readLinks(source, layout, input.context.org, notifications.providerName);
3257
+ } catch (err) {
3258
+ return refuse(describeProviderError(err), logReason(err));
3259
+ }
3260
+ if (links.links.length === 0) print(` ${style.skip("no links yet: nobody can be given a role until they link their account")}`);
3261
+ const tally = {
3262
+ ok: 0,
3263
+ skip: 0
3264
+ };
3265
+ for (const role of roles) {
3266
+ print(`\n${style.header(`${role.name} ← team ${role.team}`)}`);
3267
+ const step = `role ${role.name}`;
3268
+ let plan;
3269
+ try {
3270
+ plan = await planForRole({
3271
+ notifications,
3272
+ source,
3273
+ role: role.name,
3274
+ team: role.team,
3275
+ ...input.context.org ? { org: input.context.org } : {},
3276
+ links
3277
+ });
3278
+ } catch (err) {
3279
+ const message = describeProviderError(err);
3280
+ print(` ${style.fail(`${step}: ${message}`)}`);
3281
+ report.failures.push({
3282
+ step,
3283
+ message
3284
+ });
3285
+ continue;
3286
+ }
3287
+ const outcome = {
3288
+ role: role.name,
3289
+ team: role.team,
3290
+ plan,
3291
+ added: [],
3292
+ removed: []
3293
+ };
3294
+ report.roles.push(outcome);
3295
+ tally.skip += plan.inSync;
3296
+ if (plan.inSync > 0) print(` ${style.skip(`${plan.inSync} already have it`)}`);
3297
+ const act = async (verb, who) => {
3298
+ const label = `${verb === "add" ? "gave" : "took"} ${role.name} ${verb === "add" ? "to" : "from"} ${who.login} (${who.member})`;
3299
+ if (dryRun || verb === "remove" && input.keep) {
3300
+ print(` ${style.skip(`would ${verb === "add" ? "give" : "take"} ${role.name} ${verb === "add" ? "to" : "from"} ${who.login} (${who.member})${input.keep && verb === "remove" ? " (kept: --keep)" : ""}`)}`);
3301
+ tally.skip++;
3302
+ return;
3303
+ }
3304
+ try {
3305
+ if (verb === "add") await notifications.addRoleMember(role.name, who.member);
3306
+ else await notifications.removeRoleMember(role.name, who.member);
3307
+ (verb === "add" ? outcome.added : outcome.removed).push(who.member);
3308
+ print(` ${style.success(label)}`);
3309
+ tally.ok++;
3310
+ } catch (err) {
3311
+ const message = describeProviderError(err);
3312
+ print(` ${style.fail(`${step}: ${verb} ${who.login}: ${message}`)}`);
3313
+ report.failures.push({
3314
+ step: `${step} ${verb} ${who.login}`,
3315
+ message
3316
+ });
3317
+ }
3318
+ };
3319
+ for (const who of plan.add) await act("add", who);
3320
+ for (const who of plan.remove) await act("remove", who);
3321
+ for (const person of plan.unlinked) {
3322
+ print(` ${style.skip(`${person.login} is on the team but has not linked their account`)}`);
3323
+ tally.skip++;
3324
+ }
3325
+ for (const holder of plan.unknownHolders) {
3326
+ print(` ${style.skip(`${holder.name ?? holder.member} holds ${role.name} but is not linked to anyone: left alone`)}`);
3327
+ tally.skip++;
3328
+ }
3329
+ }
3330
+ if (report.failures.length > 0) report.status = "fail";
3331
+ else if (dryRun) report.status = "dry-run";
3332
+ print("");
3333
+ print(style.header(`${tally.ok} ok, ${report.failures.length} fail, ${tally.skip} skipped`));
3334
+ logger.info({
3335
+ provider: notifications.providerName,
3336
+ roles: report.roles.length,
3337
+ failures: report.failures.length,
3338
+ status: report.status
3339
+ }, "notifications sync-roles");
3340
+ return report;
3341
+ }
3342
+ //#endregion
3343
+ //#region src/commands/notifications-link/notifications-link.ts
3344
+ /**
3345
+ * `holocron notifications link` — record which server member is which GitHub account, by having the member
3346
+ * sign in with the provider. The provider reports the GitHub account it has verified they own, so a link
3347
+ * cannot be claimed for someone else's account. Writes the links file `sync-roles` reads.
3348
+ *
3349
+ * Signs in on this machine (a one-shot listener on localhost) unless the hosted service (Sentinel) answers
3350
+ * its health route, in which case it points you there. Nothing but the two public ids is stored; the token
3351
+ * the provider hands over is discarded as soon as it has been read.
3352
+ */
3353
+ /* v8 ignore start -- hands a URL to the operating system's browser, which a test must not do */
3354
+ /** Opens a URL in the system browser; resolves once it has been handed over. */
3355
+ async function openInBrowser(url) {
3356
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
3357
+ "/c",
3358
+ "start",
3359
+ "",
3360
+ url
3361
+ ]] : ["xdg-open", [url]];
3362
+ await new Promise((resolve, reject) => {
3363
+ const child = spawn(cmd, args, {
3364
+ stdio: "ignore",
3365
+ detached: true
3366
+ });
3367
+ child.once("error", reject);
3368
+ child.once("spawn", () => {
3369
+ child.unref();
3370
+ resolve();
3371
+ });
3372
+ });
3373
+ }
3374
+ /* v8 ignore stop */
3375
+ async function runNotificationsLink(input) {
3376
+ /* v8 ignore start -- the real console and logger are only used outside tests, which inject both */
3377
+ const print = input.print ?? ((line) => console.log(line));
3378
+ const logger = input.logger ?? getLogger();
3379
+ /* v8 ignore stop */
3380
+ /* v8 ignore next -- the real loader is only built outside tests, which always inject one */
3381
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
3382
+ await loader.load();
3383
+ assertPluginsResolvable(loader, "notifications link");
3384
+ const dryRun = input.context.dryRun ?? false;
3385
+ const refuse = (message, reason = message) => {
3386
+ print(` ${style.fail(message)}`);
3387
+ print("");
3388
+ print(style.header("0 ok, 1 fail, 0 skipped"));
3389
+ logger.warn({ reason }, "notifications link");
3390
+ return {
3391
+ status: "fail",
3392
+ message
3393
+ };
3394
+ };
3395
+ let notifications;
3396
+ try {
3397
+ notifications = pickProvider(notificationProviders(loader), input.provider, (p) => typeof p.signIn === "function", {
3398
+ infinitive: "sign members in",
3399
+ gerund: "signing members in"
3400
+ });
3401
+ } catch (err) {
3402
+ return refuse(errorMessage(err));
3403
+ }
3404
+ const layout = notifications.layout ?? {};
3405
+ const source = loader.has("source") ? loader.get("source") : void 0;
3406
+ if (!source?.readFile || !source.writeFile) return refuse(whyMissing(loader, "source", "no `source` provider that can read and write files is available for the links file"));
3407
+ let location;
3408
+ try {
3409
+ location = linksLocation(layout, input.context.org, notifications.providerName);
3410
+ } catch (err) {
3411
+ return refuse(errorMessage(err));
3412
+ }
3413
+ const port = layout.links?.localPort ?? 53682;
3414
+ const choice = await resolveCallback({
3415
+ ...layout.links?.hostedUrl ? { hostedUrl: layout.links.hostedUrl } : {},
3416
+ port,
3417
+ /* v8 ignore next -- the real fetch is only used outside tests, which inject one */
3418
+ ...input.fetch ? { fetch: input.fetch } : {}
3419
+ });
3420
+ print(` ${style.skip(`sign-in ${choice.kind === "hosted" ? "is hosted" : "runs on this machine"}: ${choice.reason}`)}`);
3421
+ if (choice.kind === "hosted") {
3422
+ print(` open ${`${choice.redirectUri.replace(/\/callback$/, "")}/start`} in a browser to link your account`);
3423
+ print("");
3424
+ print(style.header("0 ok, 0 fail, 1 skipped"));
3425
+ return {
3426
+ status: "ok",
3427
+ via: "hosted"
3428
+ };
3429
+ }
3430
+ if (dryRun) {
3431
+ print(` ${style.skip(`would sign in on ${choice.redirectUri} and write the link to ${location.repo}/${location.path}`)}`);
3432
+ print(` ${style.skip(`first, ${redirectInstruction(choice.redirectUri)}`)}`);
3433
+ print("");
3434
+ print(style.header("0 ok, 0 fail, 1 skipped"));
3435
+ return {
3436
+ status: "dry-run",
3437
+ via: "local"
3438
+ };
3439
+ }
3440
+ const signIn = notifications.signIn();
3441
+ /* v8 ignore next -- tests inject a fixed state */
3442
+ const state = (input.newState ?? (() => randomBytes(16).toString("hex")))();
3443
+ try {
3444
+ /* v8 ignore next 3 -- the real listener and browser are only used outside tests, which inject both */
3445
+ const wait = input.waitForCode ?? ((i) => waitForCode({
3446
+ port: i.port,
3447
+ state: i.state,
3448
+ onListening: i.onListening
3449
+ }));
3450
+ /* v8 ignore next -- see above */
3451
+ const open = input.openBrowser ?? openInBrowser;
3452
+ const url = await signIn.authorizeUrl({
3453
+ redirectUri: choice.redirectUri,
3454
+ state
3455
+ });
3456
+ print(` if your browser does not open, go to: ${url}`);
3457
+ print(` (it must be allowed to come back to ${choice.redirectUri}: ${redirectInstruction(choice.redirectUri)})`);
3458
+ const code = await wait({
3459
+ port,
3460
+ state,
3461
+ onListening: () => void open(url).catch(() => print(` ${style.skip("could not open a browser; use the link above")}`))
3462
+ });
3463
+ const who = await signIn.complete({
3464
+ redirectUri: choice.redirectUri,
3465
+ code
3466
+ });
3467
+ if (!who.github) return refuse(`${who.name ?? who.member} has no verified GitHub account on ${notifications.providerName}: connect GitHub under User Settings → Connections, then run this again`);
3468
+ let outcome = { changed: false };
3469
+ for (let attempt = 0; attempt < 2; attempt++) {
3470
+ const file = await source.readFile(location.path, location.repo);
3471
+ const next = upsertLink(file ? await readLinks(source, layout, input.context.org, notifications.providerName) : {
3472
+ version: 1,
3473
+ links: []
3474
+ }, {
3475
+ github: who.github.id,
3476
+ login: who.github.login,
3477
+ member: who.member
3478
+ });
3479
+ outcome = { changed: next.changed };
3480
+ if (!next.changed) break;
3481
+ try {
3482
+ await source.writeFile(location.path, serializeLinks(next.file), {
3483
+ message: `chore(notifications): link ${who.github.login} to ${notifications.providerName} member ${who.member}`,
3484
+ ...file ? { version: file.version } : {},
3485
+ repo: location.repo
3486
+ });
3487
+ break;
3488
+ } catch (err) {
3489
+ const status = err.status;
3490
+ if (attempt === 0 && (status === 409 || status === 422)) continue;
3491
+ throw err;
3492
+ }
3493
+ }
3494
+ print(` ${style.success(`${outcome.changed ? "linked" : "already linked"} ${who.github.login} (github ${who.github.id}) to ${who.name ?? who.member} (${who.member})`)}`);
3495
+ print("");
3496
+ print(style.header(`${outcome.changed ? 1 : 0} ok, 0 fail, ${outcome.changed ? 0 : 1} skipped`));
3497
+ logger.info({
3498
+ provider: notifications.providerName,
3499
+ changed: outcome.changed
3500
+ }, "notifications link");
3501
+ return {
3502
+ status: "ok",
3503
+ via: "local",
3504
+ linked: {
3505
+ github: who.github.id,
3506
+ login: who.github.login,
3507
+ member: who.member,
3508
+ changed: outcome.changed
3509
+ }
3510
+ };
3511
+ } catch (err) {
3512
+ return refuse(describeProviderError(err), logReason(err));
3513
+ }
3514
+ }
2948
3515
  //#endregion
2949
3516
  //#region src/commands/doctor-notifications/doctor-notifications.ts
2950
3517
  /**
@@ -2959,23 +3526,15 @@ async function runNotificationsChannelAdd(input) {
2959
3526
  * is {@link PLANNED_CHECKS}; the numbers match the table in holocron#1098.
2960
3527
  */
2961
3528
  /** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
2962
- const PLANNED_CHECKS = [
2963
- {
2964
- step: 14,
2965
- id: "role-assignment",
2966
- title: "role assigned to team members"
2967
- },
2968
- {
2969
- step: 15,
2970
- id: "post-works",
2971
- title: "test post reached every channel (platform.notifyCheck)"
2972
- },
2973
- {
2974
- step: 16,
2975
- id: "server-creation",
2976
- title: "server created from config"
2977
- }
2978
- ];
3529
+ const PLANNED_CHECKS = [{
3530
+ step: 15,
3531
+ id: "post-works",
3532
+ title: "test post reached every channel (platform.notifyCheck)"
3533
+ }, {
3534
+ step: 16,
3535
+ id: "server-creation",
3536
+ title: "server created from config"
3537
+ }];
2979
3538
  const same = (a, b) => a.toLowerCase() === b.toLowerCase();
2980
3539
  /** `everyone`, or the roles sorted and lower-cased, so order and case do not matter when comparing. */
2981
3540
  const accessKey = (v) => v === "everyone" ? "everyone" : JSON.stringify([...new Set(v.map((r) => r.toLowerCase()))].sort());
@@ -2999,6 +3558,7 @@ const AVATAR_TITLE = "webhook names and avatars set";
2999
3558
  const SERVER_TITLE = "server verification, notifications and community set";
3000
3559
  const ACCESS_TITLE = "channel and category access set";
3001
3560
  const ROLE_APPEARANCE_TITLE = "role colour, hoist and permissions set";
3561
+ const ROLE_MEMBERS_TITLE = "role assigned to team members";
3002
3562
  const SECRETS_TITLE = "webhook URLs and role id stored as secrets";
3003
3563
  async function runDoctorNotifications(input) {
3004
3564
  const print = input.print ?? ((line) => console.log(line));
@@ -3337,6 +3897,14 @@ async function runDoctorNotifications(input) {
3337
3897
  const exact = (list) => JSON.stringify([...new Set(list)].sort());
3338
3898
  if (wantApp.description !== void 0 && (have.description ?? "") !== wantApp.description) appProblems.push("description differs from the layout");
3339
3899
  if (wantApp.tags !== void 0 && set(have.tags ?? []) !== set(wantApp.tags)) appProblems.push(`tags are ${(have.tags ?? []).map(shown).join(", ") || "none"} but the layout says ${wantApp.tags.map(shown).join(", ") || "none"}`);
3900
+ if (wantApp.intents?.members !== void 0 && (have.intents?.members ?? false) !== wantApp.intents.members) appProblems.push(`the Server Members Intent is ${have.intents?.members ? "on" : "off"} but the layout says ${wantApp.intents.members ? "on" : "off"} (\`holocron notifications setup\` changes it)`);
3901
+ const needsLink = (layout.roles ?? []).some((r) => r.team);
3902
+ const wantRedirects = wantApp.redirectUris ?? (needsLink ? [(await resolveCallback({
3903
+ ...layout.links?.hostedUrl ? { hostedUrl: layout.links.hostedUrl } : {},
3904
+ ...layout.links?.localPort ? { port: layout.links.localPort } : {},
3905
+ ...input.fetch ? { fetch: input.fetch } : {}
3906
+ })).redirectUri] : []);
3907
+ for (const uri of wantRedirects) if (!(have.redirectUris ?? []).includes(uri)) appProblems.push(shown(redirectInstruction(uri)));
3340
3908
  if (wantApp.install !== void 0) {
3341
3909
  if (!have.install) appProblems.push("no install link is set");
3342
3910
  else {
@@ -3380,6 +3948,41 @@ async function runDoctorNotifications(input) {
3380
3948
  failed("secrets", SECRETS_TITLE, describeProviderError(err));
3381
3949
  }
3382
3950
  }
3951
+ const teamRoles = (layout.roles ?? []).filter((r) => r.team);
3952
+ const blankTeams = (layout.roles ?? []).filter((r) => r.team !== void 0 && r.team.trim() === "");
3953
+ if (blankTeams.length > 0) failed("role-members", ROLE_MEMBERS_TITLE, shown(`${blankTeams.map((r) => r.name).join(", ")}: declares an empty \`team\`; name a team or remove the field`));
3954
+ else if (teamRoles.length === 0) checks.push({
3955
+ id: "role-members",
3956
+ title: ROLE_MEMBERS_TITLE,
3957
+ status: "pass",
3958
+ detail: "none declared",
3959
+ problems: []
3960
+ });
3961
+ else {
3962
+ const source = loader.has("source") ? loader.get("source") : void 0;
3963
+ const problem = !notifications.roleMembers ? `the \`${notifications.providerName}\` provider cannot list who holds a role` : sourceProblem(source);
3964
+ if (problem) failed("role-members", ROLE_MEMBERS_TITLE, shown(loader.has("source") ? problem : whyMissing(loader, "source", problem)));
3965
+ else try {
3966
+ const links = await readLinks(source, layout, input.context.org, notifications.providerName);
3967
+ const problems = [];
3968
+ for (const role of teamRoles) {
3969
+ const plan = await planForRole({
3970
+ notifications,
3971
+ source,
3972
+ role: role.name,
3973
+ team: role.team,
3974
+ ...input.context.org ? { org: input.context.org } : {},
3975
+ links
3976
+ });
3977
+ for (const who of plan.add) problems.push(`${shown(who.login)}: on team ${shown(role.team)} but lacks ${shown(role.name)}`);
3978
+ for (const who of plan.remove) problems.push(`${shown(who.login)}: holds ${shown(role.name)} but left team ${shown(role.team)}`);
3979
+ for (const who of plan.unlinked) problems.push(`${shown(who.login)}: on team ${shown(role.team)} but has not linked their account`);
3980
+ }
3981
+ add("role-members", ROLE_MEMBERS_TITLE, problems, `${teamRoles.length} of ${teamRoles.length} in sync`);
3982
+ } catch (err) {
3983
+ failed("role-members", ROLE_MEMBERS_TITLE, shown(describeProviderError(err)));
3984
+ }
3985
+ }
3383
3986
  if (!input.builtOnly) for (const planned of PLANNED_CHECKS) checks.push({
3384
3987
  id: planned.id,
3385
3988
  title: planned.title,
@@ -4071,6 +4674,10 @@ function serverLayoutProblem(layout) {
4071
4674
  if (count === 0) return `${where} names the channel \`${name}\`, which is not in the layout's channels`;
4072
4675
  if (count > 1) return `${where} names \`${name}\`, but more than one channel in the layout has that name`;
4073
4676
  };
4677
+ const roleTeams = roleTeamProblem(layout);
4678
+ if (roleTeams) return roleTeams;
4679
+ const emptyTeam = (layout.roles ?? []).find((r) => r.team !== void 0 && r.team.trim() === "");
4680
+ if (emptyTeam) return `role \`${emptyTeam.name}\` has an empty \`team\``;
4074
4681
  const everyone = layout.everyone;
4075
4682
  const both = (everyone?.allow ?? []).find((p) => (everyone?.deny ?? []).some((d) => d.toLowerCase() === p.toLowerCase()));
4076
4683
  if (both !== void 0) return `everyone: \`${both}\` is in both \`allow\` and \`deny\``;
@@ -4308,6 +4915,13 @@ async function runNotificationsSetup(input) {
4308
4915
  const changed = result.updated.length > 0;
4309
4916
  print(` ${(changed ? style.success : style.skip)(`${changed ? "updated" : "exists "} ${label}${changed ? `: ${result.updated.join(", ")}` : ""}`)}`);
4310
4917
  made(changed);
4918
+ for (const todo of result.todo ?? []) {
4919
+ print(` ${style.fail(`${label}: ${todo}`)}`);
4920
+ report.failures.push({
4921
+ step: label,
4922
+ message: todo
4923
+ });
4924
+ }
4311
4925
  });
4312
4926
  };
4313
4927
  await apply("@everyone permissions", !!layout.everyone, () => notifications.ensureEveryone(layout.everyone));
@@ -10478,6 +11092,36 @@ const COMMAND_REGISTRY = [
10478
11092
  positionals: [],
10479
11093
  supportsDryRun: true
10480
11094
  },
11095
+ {
11096
+ name: "notifications link",
11097
+ description: "Link your server account to your GitHub account by signing in with the provider",
11098
+ positionals: [],
11099
+ options: [{
11100
+ key: "provider",
11101
+ flag: "--provider",
11102
+ type: "input",
11103
+ message: "Which notifications provider (blank = the only one that can sign members in):"
11104
+ }],
11105
+ supportsDryRun: true
11106
+ },
11107
+ {
11108
+ name: "notifications sync-roles",
11109
+ description: "Make who holds a server role match a team, through the links file",
11110
+ positionals: [],
11111
+ options: [{
11112
+ key: "provider",
11113
+ flag: "--provider",
11114
+ type: "input",
11115
+ message: "Which notifications provider (blank = the only one that can manage roles):"
11116
+ }, {
11117
+ key: "keep",
11118
+ flag: "--keep",
11119
+ type: "confirm",
11120
+ default: false,
11121
+ message: "Keep the role on linked people who left the team (only add, never remove)?"
11122
+ }],
11123
+ supportsDryRun: true
11124
+ },
10481
11125
  {
10482
11126
  name: "deploy",
10483
11127
  description: "Trigger a deployment via the configured `deployment` capability",
@@ -12056,6 +12700,47 @@ try {
12056
12700
  ...argv.scope ? { scope: parseScope(argv.scope) } : {},
12057
12701
  ...argv.provider ? { provider: argv.provider } : {}
12058
12702
  })).status === "fail") process.exitCode = 1;
12703
+ }).command("link", "Link your server account to your GitHub account by signing in with the provider", (y) => y.option("provider", {
12704
+ type: "string",
12705
+ describe: "Which notifications provider, when several can sign members in"
12706
+ }), async (argv) => {
12707
+ const tokens = tokenContext(argv.token);
12708
+ if (!tokens) return;
12709
+ const loaded = await loadConfig(argv.cwd);
12710
+ applyResolvedConfig(argv, loaded.resolved);
12711
+ if ((await runNotificationsLink({
12712
+ loaded,
12713
+ context: {
12714
+ repoRoot: argv.cwd,
12715
+ dryRun: argv.dryRun,
12716
+ ...tokens,
12717
+ org: resolveOrg(argv, loaded.resolved)
12718
+ },
12719
+ ...argv.provider ? { provider: argv.provider } : {}
12720
+ })).status === "fail") process.exitCode = 1;
12721
+ }).command("sync-roles", "Make who holds a server role match a team, through the links file", (y) => y.option("provider", {
12722
+ type: "string",
12723
+ describe: "Which notifications provider, when several can manage roles"
12724
+ }).option("keep", {
12725
+ type: "boolean",
12726
+ default: false,
12727
+ describe: "Give the role to new team members but do not take it from people who left"
12728
+ }), async (argv) => {
12729
+ const tokens = tokenContext(argv.token);
12730
+ if (!tokens) return;
12731
+ const loaded = await loadConfig(argv.cwd);
12732
+ applyResolvedConfig(argv, loaded.resolved);
12733
+ if ((await runNotificationsSyncRoles({
12734
+ loaded,
12735
+ context: {
12736
+ repoRoot: argv.cwd,
12737
+ dryRun: argv.dryRun,
12738
+ ...tokens,
12739
+ org: resolveOrg(argv, loaded.resolved)
12740
+ },
12741
+ ...argv.provider ? { provider: argv.provider } : {},
12742
+ keep: argv.keep
12743
+ })).status === "fail") process.exitCode = 1;
12059
12744
  }).command("$0", false, () => {}, async (argv) => {
12060
12745
  await launchGroupMenu("notifications", argv);
12061
12746
  }), () => {}).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", {