@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/README.md +48 -1
- package/dist/cli.mjs +702 -17
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/plugin/capabilities.d.mts +114 -1
- package/package.json +4 -4
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
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
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", {
|