@theholocron/cli 5.0.0-alpha.196 → 5.0.0-alpha.198

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
@@ -24,8 +24,8 @@ import search from "@inquirer/search";
24
24
  import { execFileSync, spawn, spawnSync } from "node:child_process";
25
25
  import ora from "ora";
26
26
  import { findRootSync, isWorkspaceRoot, walkFilesSync } from "@theholocron/fs-utils";
27
- import { createHash, randomBytes, randomUUID } from "node:crypto";
28
27
  import { access, copyFile, mkdir, readFile, readdir, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
28
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
29
29
  import { stringifyJson } from "@theholocron/json-utils";
30
30
  import { generateReadme } from "@theholocron/components-doc/markdown";
31
31
  import { getClients, getConfigs, getDocs, getPlugins, getSkills, getThemes, getUtils } from "@theholocron/registry-doc";
@@ -2634,6 +2634,59 @@ function mk(capability, provider, status, message, logMessage) {
2634
2634
  };
2635
2635
  }
2636
2636
  //#endregion
2637
+ //#region src/commands/notifications-channel-add/avatar.ts
2638
+ /**
2639
+ * Read a webhook avatar named in a layout: a file path (from the directory the CLI runs in) or an
2640
+ * `https://` URL. Returns it as a `data:` URI, which is how providers take an image.
2641
+ *
2642
+ * The type comes from the first bytes, not from the name or the response header, so what is sent is
2643
+ * the image it claims to be.
2644
+ */
2645
+ /** Providers cap avatars well below this; it only keeps a wrong file from filling memory. */
2646
+ const MAX_AVATAR_BYTES = 8388608;
2647
+ const FETCH_TIMEOUT_MS$2 = 15e3;
2648
+ const SIGNATURES = [
2649
+ ["image/png", [
2650
+ 137,
2651
+ 80,
2652
+ 78,
2653
+ 71,
2654
+ 13,
2655
+ 10,
2656
+ 26,
2657
+ 10
2658
+ ]],
2659
+ ["image/jpeg", [
2660
+ 255,
2661
+ 216,
2662
+ 255
2663
+ ]],
2664
+ ["image/gif", [
2665
+ 71,
2666
+ 73,
2667
+ 70,
2668
+ 56
2669
+ ]]
2670
+ ];
2671
+ const tooBig = () => /* @__PURE__ */ new Error(`it is larger than ${MAX_AVATAR_BYTES / 1024 / 1024} MB`);
2672
+ async function loadAvatar(source, baseDir = process.cwd(), fetchImpl = fetch) {
2673
+ let bytes;
2674
+ if (/^https:\/\//i.test(source)) {
2675
+ const res = await fetchImpl(source, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS$2) });
2676
+ if (!res.ok) throw new Error(`fetching it answered ${res.status}`);
2677
+ if (Number(res.headers.get("content-length")) > 8388608) throw tooBig();
2678
+ bytes = Buffer.from(await res.arrayBuffer());
2679
+ } else {
2680
+ const path = resolve(baseDir, source);
2681
+ if ((await stat(path)).size > 8388608) throw tooBig();
2682
+ bytes = await readFile(path);
2683
+ }
2684
+ if (bytes.length > 8388608) throw tooBig();
2685
+ const type = SIGNATURES.find(([, sig]) => sig.every((b, i) => bytes[i] === b))?.[0];
2686
+ if (!type) throw new Error("it is not a PNG, JPEG or GIF image, which are the formats providers accept");
2687
+ return `data:${type};base64,${bytes.toString("base64")}`;
2688
+ }
2689
+ //#endregion
2637
2690
  //#region src/commands/notifications-channel-add/notifications-channel-add.ts
2638
2691
  /**
2639
2692
  * `holocron notifications channel add` — create one channel (and, with
@@ -2784,8 +2837,10 @@ async function provisionChannel(deps, spec) {
2784
2837
  const hook = await notifications.ensureWebhook({
2785
2838
  channel: spec.name,
2786
2839
  name: webhookName,
2787
- ...spec.category ? { category: spec.category } : {}
2840
+ ...spec.category ? { category: spec.category } : {},
2841
+ ...webhookCfg.avatar ? { avatar: webhookCfg.avatar } : {}
2788
2842
  });
2843
+ const avatarSet = hook.updated?.includes("avatar") ?? false;
2789
2844
  if (hook.url) {
2790
2845
  try {
2791
2846
  await secrets.setSecret(scope, secretName, hook.url);
@@ -2798,7 +2853,8 @@ async function provisionChannel(deps, spec) {
2798
2853
  name: webhookName,
2799
2854
  created: hook.created,
2800
2855
  secret: secretName,
2801
- stored: true
2856
+ stored: true,
2857
+ ...avatarSet ? { updated: ["avatar"] } : {}
2802
2858
  };
2803
2859
  } else {
2804
2860
  const note = `webhook ${webhookName} already exists and its URL cannot be read back — delete it in the provider and re-run, or set ${secretName} yourself`;
@@ -2808,9 +2864,15 @@ async function provisionChannel(deps, spec) {
2808
2864
  created: hook.created,
2809
2865
  secret: secretName,
2810
2866
  stored: false,
2811
- note
2867
+ note,
2868
+ ...avatarSet ? { updated: ["avatar"] } : {}
2812
2869
  };
2813
2870
  }
2871
+ if (avatarSet) {
2872
+ print(` ${style.success(`set avatar on webhook ${webhookName}`)}`);
2873
+ if (tally) tally.ok++;
2874
+ }
2875
+ if (hook.avatarError) throw new Error(`webhook ${webhookName} is ready but setting its avatar failed (${hook.avatarError})`);
2814
2876
  return outcome;
2815
2877
  }
2816
2878
  async function runNotificationsChannelAdd(input) {
@@ -2898,16 +2960,6 @@ async function runNotificationsChannelAdd(input) {
2898
2960
  */
2899
2961
  /** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
2900
2962
  const PLANNED_CHECKS = [
2901
- {
2902
- step: 12,
2903
- id: "webhook-appearance",
2904
- title: "webhook names and avatars set"
2905
- },
2906
- {
2907
- step: 13,
2908
- id: "app-settings",
2909
- title: "bot description, tags, install link and URLs set"
2910
- },
2911
2963
  {
2912
2964
  step: 14,
2913
2965
  id: "role-assignment",
@@ -2942,6 +2994,8 @@ const promptKey = (p) => JSON.stringify([
2942
2994
  const accessText = (v) => v === "everyone" ? "everyone" : v.length === 0 ? "nobody (bots and webhooks only)" : v.join(", ");
2943
2995
  const shown = (s) => sanitizeDetail(s);
2944
2996
  const WELCOME_TITLE = "welcome screen and onboarding set";
2997
+ const APP_TITLE = "bot description, tags, install link and URLs set";
2998
+ const AVATAR_TITLE = "webhook names and avatars set";
2945
2999
  const SERVER_TITLE = "server verification, notifications and community set";
2946
3000
  const ACCESS_TITLE = "channel and category access set";
2947
3001
  const ROLE_APPEARANCE_TITLE = "role colour, hoist and permissions set";
@@ -3060,11 +3114,13 @@ async function runDoctorNotifications(input) {
3060
3114
  ["categories", "categories exist"],
3061
3115
  ["channels", "channels in their category, with topics"],
3062
3116
  ["webhooks", "webhooks on their channels"],
3117
+ ["webhook-appearance", AVATAR_TITLE],
3063
3118
  ["roles", "roles exist and are mentionable"],
3064
3119
  ["role-appearance", ROLE_APPEARANCE_TITLE],
3065
3120
  ["access", ACCESS_TITLE],
3066
3121
  ["server-settings", SERVER_TITLE],
3067
- ["welcome-onboarding", WELCOME_TITLE]
3122
+ ["welcome-onboarding", WELCOME_TITLE],
3123
+ ["app-settings", APP_TITLE]
3068
3124
  ]) failed(id, title, "not checked: could not read the server");
3069
3125
  } else {
3070
3126
  const inspected = found;
@@ -3095,6 +3151,17 @@ async function runDoctorNotifications(input) {
3095
3151
  else if (!match.webhooks.some((w) => same(w, name))) webhookProblems.push(`#${shown(channel.name)}: no webhook named ${shown(name)}`);
3096
3152
  }
3097
3153
  add("webhooks", "webhooks on their channels", webhookProblems, withWebhook.length === 0 ? "none declared" : `${withWebhook.length} of ${withWebhook.length} exist`);
3154
+ const avatarOf = (webhook) => (typeof webhook === "object" && webhook ? webhook.avatar : void 0) ?? layout.webhookAvatar;
3155
+ const wantAvatar = declaredChannels.filter(({ channel }) => channel.webhook && avatarOf(channel.webhook));
3156
+ const avatarProblems = [];
3157
+ for (const { cat, channel } of wantAvatar) {
3158
+ const name = (channel.webhook === true ? {} : channel.webhook).name ?? channel.name;
3159
+ const { match } = pick(channel.name, cat);
3160
+ if (!match) avatarProblems.push(`#${shown(channel.name)}: no channel to hold the webhook`);
3161
+ else if (match.webhooksError) avatarProblems.push(`#${shown(channel.name)}: could not list its webhooks (${shown(match.webhooksError)})`);
3162
+ else if (match.webhookAvatars && !match.webhookAvatars.some((w) => same(w, name))) avatarProblems.push(`#${shown(channel.name)}: webhook ${shown(name)} has no avatar`);
3163
+ }
3164
+ add("webhook-appearance", AVATAR_TITLE, avatarProblems, wantAvatar.length === 0 ? "none declared" : `${wantAvatar.length} of ${wantAvatar.length} set`);
3098
3165
  const roles = layout.roles ?? [];
3099
3166
  const roleProblems = [];
3100
3167
  if (inspected.rolesError) roleProblems.push(`could not read the roles (${shown(inspected.rolesError)})`);
@@ -3128,8 +3195,21 @@ async function runDoctorNotifications(input) {
3128
3195
  }
3129
3196
  }
3130
3197
  }
3131
- const styled = roles.filter((r) => r.color !== void 0 || r.hoist !== void 0 || r.permissions !== void 0);
3132
- add("role-appearance", ROLE_APPEARANCE_TITLE, inspected.rolesError ? [`not checked: could not read the roles`] : appearance, styled.length === 0 ? "none declared" : `${styled.length} of ${styled.length} as declared`);
3198
+ const everyone = layout.everyone;
3199
+ const everyoneProblems = [];
3200
+ if (everyone) {
3201
+ if (inspected.everyoneError) everyoneProblems.push(`@everyone: not checked, could not read its permissions (${shown(inspected.everyoneError)})`);
3202
+ else if (!inspected.everyone) everyoneProblems.push("@everyone: the provider did not report its permissions");
3203
+ else {
3204
+ const have = new Set(inspected.everyone.permissions.map((n) => n.toLowerCase()));
3205
+ const lacking = (everyone.allow ?? []).filter((n) => !have.has(n.toLowerCase()));
3206
+ const keeping = (everyone.deny ?? []).filter((n) => have.has(n.toLowerCase()));
3207
+ if (lacking.length > 0) everyoneProblems.push(`@everyone: missing permissions ${lacking.map(shown).join(", ")}`);
3208
+ if (keeping.length > 0) everyoneProblems.push(`@everyone: still has permissions the layout takes away: ${keeping.map(shown).join(", ")}`);
3209
+ }
3210
+ }
3211
+ const styledCount = roles.filter((r) => r.color !== void 0 || r.hoist !== void 0 || r.permissions !== void 0).length + (everyone ? 1 : 0);
3212
+ add("role-appearance", ROLE_APPEARANCE_TITLE, inspected.rolesError ? [`not checked: could not read the roles`, ...everyoneProblems] : [...appearance, ...everyoneProblems], styledCount === 0 ? "none declared" : `${styledCount} of ${styledCount} as declared`);
3133
3213
  const accessProblems = [];
3134
3214
  const compare = (label, want, have) => {
3135
3215
  if (!have) {
@@ -3246,6 +3326,35 @@ async function runDoctorNotifications(input) {
3246
3326
  }
3247
3327
  const welcomeParts = (wantWelcome ? 1 : 0) + (wantOnboarding ? 1 : 0);
3248
3328
  add("welcome-onboarding", WELCOME_TITLE, [...welcomeProblems, ...onboardingProblems], welcomeParts === 0 ? "none declared" : `${welcomeParts} of ${welcomeParts} as declared`);
3329
+ const wantApp = layout.application;
3330
+ const appProblems = [];
3331
+ if (wantApp) {
3332
+ if (inspected.applicationError) appProblems.push(`not checked: could not read the application (${shown(inspected.applicationError)})`);
3333
+ else if (!inspected.application) appProblems.push("the provider did not report the application");
3334
+ else {
3335
+ const have = inspected.application;
3336
+ const set = (list) => JSON.stringify([...new Set(list.map((n) => n.toLowerCase()))].sort());
3337
+ const exact = (list) => JSON.stringify([...new Set(list)].sort());
3338
+ if (wantApp.description !== void 0 && (have.description ?? "") !== wantApp.description) appProblems.push("description differs from the layout");
3339
+ 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"}`);
3340
+ if (wantApp.install !== void 0) {
3341
+ if (!have.install) appProblems.push("no install link is set");
3342
+ else {
3343
+ if (exact(have.install.scopes) !== exact(wantApp.install.scopes)) appProblems.push(`install scopes are ${have.install.scopes.map(shown).join(", ")} but the layout says ${wantApp.install.scopes.map(shown).join(", ")}`);
3344
+ if (set(have.install.permissions) !== set(wantApp.install.permissions)) appProblems.push("install permissions differ from the layout");
3345
+ }
3346
+ }
3347
+ for (const [field, label] of [
3348
+ ["termsUrl", "terms URL"],
3349
+ ["privacyUrl", "privacy URL"],
3350
+ ["roleConnectionsUrl", "role-connections URL"]
3351
+ ]) {
3352
+ const wanted = wantApp[field];
3353
+ if (wanted !== void 0 && (have[field] ?? "") !== wanted) appProblems.push(`${label} is ${shown(have[field] || "not set")} but the layout says ${shown(wanted)}`);
3354
+ }
3355
+ }
3356
+ }
3357
+ add("app-settings", APP_TITLE, appProblems, wantApp ? "1 of 1 as declared" : "none declared");
3249
3358
  }
3250
3359
  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] : [])];
3251
3360
  if (wanted.length === 0) checks.push({
@@ -3907,6 +4016,10 @@ async function runNew(input) {
3907
4016
  * shared preset would apply it from every repo.
3908
4017
  */
3909
4018
  /** A channel's access: its category's, with any field the channel states itself laid over it. */
4019
+ /** The picture a channel's webhook should have: its own, else the layout's, else none. */
4020
+ function webhookAvatarOf(layout, webhook) {
4021
+ return (typeof webhook === "object" ? webhook.avatar : void 0) ?? layout.webhookAvatar;
4022
+ }
3910
4023
  function effectiveAccess(category, channel) {
3911
4024
  if (!category && !channel) return void 0;
3912
4025
  return {
@@ -3958,6 +4071,9 @@ function serverLayoutProblem(layout) {
3958
4071
  if (count === 0) return `${where} names the channel \`${name}\`, which is not in the layout's channels`;
3959
4072
  if (count > 1) return `${where} names \`${name}\`, but more than one channel in the layout has that name`;
3960
4073
  };
4074
+ const everyone = layout.everyone;
4075
+ const both = (everyone?.allow ?? []).find((p) => (everyone?.deny ?? []).some((d) => d.toLowerCase() === p.toLowerCase()));
4076
+ if (both !== void 0) return `everyone: \`${both}\` is in both \`allow\` and \`deny\``;
3961
4077
  const server = layout.server;
3962
4078
  if (server) {
3963
4079
  for (const field of [
@@ -4034,6 +4150,17 @@ async function runNotificationsSetup(input) {
4034
4150
  name: org
4035
4151
  } : void 0);
4036
4152
  if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
4153
+ const avatars = /* @__PURE__ */ new Map();
4154
+ const read = input.loadAvatar ?? loadAvatar;
4155
+ for (const channel of (layout.categories ?? []).flatMap((c) => c.channels ?? [])) {
4156
+ const source = channel.webhook ? webhookAvatarOf(layout, channel.webhook) : void 0;
4157
+ if (source === void 0 || avatars.has(source)) continue;
4158
+ try {
4159
+ avatars.set(source, await read(source));
4160
+ } catch (err) {
4161
+ return refuse(`webhook avatar \`${source}\`: ${errorMessage(err)}`);
4162
+ }
4163
+ }
4037
4164
  if (secrets && scope && !dryRun) try {
4038
4165
  await assertSecretsReachable(secrets, scope);
4039
4166
  } catch (err) {
@@ -4048,6 +4175,8 @@ async function runNotificationsSetup(input) {
4048
4175
  if (layout.server && !notifications.ensureServer) return refuse(`the layout declares \`server\` settings, but the \`${notifications.providerName}\` provider cannot manage them`);
4049
4176
  if (layout.welcome && !notifications.ensureWelcome) return refuse(`the layout declares a \`welcome\` screen, but the \`${notifications.providerName}\` provider cannot manage one`);
4050
4177
  if (layout.onboarding && !notifications.ensureOnboarding) return refuse(`the layout declares \`onboarding\`, but the \`${notifications.providerName}\` provider cannot manage it`);
4178
+ if (layout.application && !notifications.ensureApplication) return refuse(`the layout declares \`application\` settings, but the \`${notifications.providerName}\` provider cannot manage them`);
4179
+ if (layout.everyone && !notifications.ensureEveryone) return refuse(`the layout declares \`everyone\` permissions, but the \`${notifications.providerName}\` provider cannot manage them`);
4051
4180
  const needsCategories = (layout.categories ?? []).find((c) => c.access);
4052
4181
  if (needsCategories && !notifications.ensureCategory) return refuse(`category \`${needsCategories.name}\` declares \`access\`, but the \`${notifications.providerName}\` provider cannot manage categories`);
4053
4182
  const providerProblem = notifications.validateLayout?.(layout);
@@ -4139,6 +4268,11 @@ async function runNotificationsSetup(input) {
4139
4268
  continue;
4140
4269
  }
4141
4270
  for (const channel of channels) await attempt(`channel ${channel.name}`, async () => {
4271
+ const source = channel.webhook ? webhookAvatarOf(layout, channel.webhook) : void 0;
4272
+ const webhook = channel.webhook ? {
4273
+ ...channel.webhook === true ? {} : channel.webhook,
4274
+ ...source ? { avatar: avatars.get(source) } : {}
4275
+ } : void 0;
4142
4276
  const outcome = await provisionChannel({
4143
4277
  notifications,
4144
4278
  ...secrets ? { secrets } : {},
@@ -4151,7 +4285,7 @@ async function runNotificationsSetup(input) {
4151
4285
  category: category.name,
4152
4286
  ...channel.topic ? { topic: channel.topic } : {},
4153
4287
  ...effectiveAccess(category.access, channel.access) ? { access: effectiveAccess(category.access, channel.access) } : {},
4154
- ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
4288
+ ...webhook ? { webhook } : {}
4155
4289
  });
4156
4290
  report.channels.push(outcome);
4157
4291
  if (outcome.webhook && !outcome.webhook.stored) report.failures.push({
@@ -4160,7 +4294,7 @@ async function runNotificationsSetup(input) {
4160
4294
  });
4161
4295
  });
4162
4296
  }
4163
- if (layout.server || layout.welcome || layout.onboarding) {
4297
+ if (layout.server || layout.welcome || layout.onboarding || layout.application || layout.everyone) {
4164
4298
  print(`\n${style.header("Server")}`);
4165
4299
  const apply = async (label, declared, run) => {
4166
4300
  if (!declared) return;
@@ -4176,9 +4310,11 @@ async function runNotificationsSetup(input) {
4176
4310
  made(changed);
4177
4311
  });
4178
4312
  };
4313
+ await apply("@everyone permissions", !!layout.everyone, () => notifications.ensureEveryone(layout.everyone));
4179
4314
  await apply("server settings", !!layout.server, () => notifications.ensureServer(layout.server));
4180
4315
  await apply("welcome screen", !!layout.welcome, () => notifications.ensureWelcome(layout.welcome));
4181
4316
  await apply("onboarding", !!layout.onboarding, () => notifications.ensureOnboarding(layout.onboarding));
4317
+ await apply("bot application", !!layout.application, () => notifications.ensureApplication(layout.application));
4182
4318
  }
4183
4319
  if (report.failures.length > 0) report.status = "fail";
4184
4320
  else if (dryRun) report.status = "dry-run";