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

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,11 +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
2963
  {
2907
2964
  step: 13,
2908
2965
  id: "app-settings",
@@ -2942,6 +2999,7 @@ const promptKey = (p) => JSON.stringify([
2942
2999
  const accessText = (v) => v === "everyone" ? "everyone" : v.length === 0 ? "nobody (bots and webhooks only)" : v.join(", ");
2943
3000
  const shown = (s) => sanitizeDetail(s);
2944
3001
  const WELCOME_TITLE = "welcome screen and onboarding set";
3002
+ const AVATAR_TITLE = "webhook names and avatars set";
2945
3003
  const SERVER_TITLE = "server verification, notifications and community set";
2946
3004
  const ACCESS_TITLE = "channel and category access set";
2947
3005
  const ROLE_APPEARANCE_TITLE = "role colour, hoist and permissions set";
@@ -3064,7 +3122,8 @@ async function runDoctorNotifications(input) {
3064
3122
  ["role-appearance", ROLE_APPEARANCE_TITLE],
3065
3123
  ["access", ACCESS_TITLE],
3066
3124
  ["server-settings", SERVER_TITLE],
3067
- ["welcome-onboarding", WELCOME_TITLE]
3125
+ ["welcome-onboarding", WELCOME_TITLE],
3126
+ ["webhook-appearance", AVATAR_TITLE]
3068
3127
  ]) failed(id, title, "not checked: could not read the server");
3069
3128
  } else {
3070
3129
  const inspected = found;
@@ -3247,6 +3306,19 @@ async function runDoctorNotifications(input) {
3247
3306
  const welcomeParts = (wantWelcome ? 1 : 0) + (wantOnboarding ? 1 : 0);
3248
3307
  add("welcome-onboarding", WELCOME_TITLE, [...welcomeProblems, ...onboardingProblems], welcomeParts === 0 ? "none declared" : `${welcomeParts} of ${welcomeParts} as declared`);
3249
3308
  }
3309
+ if (found) {
3310
+ const avatarOf = (webhook) => (typeof webhook === "object" && webhook ? webhook.avatar : void 0) ?? layout.webhookAvatar;
3311
+ const wantAvatar = declaredChannels.filter(({ channel }) => channel.webhook && avatarOf(channel.webhook));
3312
+ const avatarProblems = [];
3313
+ for (const { cat, channel } of wantAvatar) {
3314
+ const name = (channel.webhook === true ? {} : channel.webhook).name ?? channel.name;
3315
+ const { match } = pick(channel.name, cat);
3316
+ if (!match) avatarProblems.push(`#${shown(channel.name)}: no channel to hold the webhook`);
3317
+ else if (match.webhooksError) avatarProblems.push(`#${shown(channel.name)}: could not list its webhooks (${shown(match.webhooksError)})`);
3318
+ else if (match.webhookAvatars && !match.webhookAvatars.some((w) => same(w, name))) avatarProblems.push(`#${shown(channel.name)}: webhook ${shown(name)} has no avatar`);
3319
+ }
3320
+ add("webhook-appearance", AVATAR_TITLE, avatarProblems, wantAvatar.length === 0 ? "none declared" : `${wantAvatar.length} of ${wantAvatar.length} set`);
3321
+ }
3250
3322
  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
3323
  if (wanted.length === 0) checks.push({
3252
3324
  id: "secrets",
@@ -3907,6 +3979,10 @@ async function runNew(input) {
3907
3979
  * shared preset would apply it from every repo.
3908
3980
  */
3909
3981
  /** A channel's access: its category's, with any field the channel states itself laid over it. */
3982
+ /** The picture a channel's webhook should have: its own, else the layout's, else none. */
3983
+ function webhookAvatarOf(layout, webhook) {
3984
+ return (typeof webhook === "object" ? webhook.avatar : void 0) ?? layout.webhookAvatar;
3985
+ }
3910
3986
  function effectiveAccess(category, channel) {
3911
3987
  if (!category && !channel) return void 0;
3912
3988
  return {
@@ -4034,6 +4110,17 @@ async function runNotificationsSetup(input) {
4034
4110
  name: org
4035
4111
  } : void 0);
4036
4112
  if (wantsSecrets && !scope && !dryRun) return refuse("no org to store secrets under — pass --scope or set `org` in holocron.config.ts");
4113
+ const avatars = /* @__PURE__ */ new Map();
4114
+ const read = input.loadAvatar ?? loadAvatar;
4115
+ for (const channel of (layout.categories ?? []).flatMap((c) => c.channels ?? [])) {
4116
+ const source = channel.webhook ? webhookAvatarOf(layout, channel.webhook) : void 0;
4117
+ if (source === void 0 || avatars.has(source)) continue;
4118
+ try {
4119
+ avatars.set(source, await read(source));
4120
+ } catch (err) {
4121
+ return refuse(`webhook avatar \`${source}\`: ${errorMessage(err)}`);
4122
+ }
4123
+ }
4037
4124
  if (secrets && scope && !dryRun) try {
4038
4125
  await assertSecretsReachable(secrets, scope);
4039
4126
  } catch (err) {
@@ -4139,6 +4226,11 @@ async function runNotificationsSetup(input) {
4139
4226
  continue;
4140
4227
  }
4141
4228
  for (const channel of channels) await attempt(`channel ${channel.name}`, async () => {
4229
+ const source = channel.webhook ? webhookAvatarOf(layout, channel.webhook) : void 0;
4230
+ const webhook = channel.webhook ? {
4231
+ ...channel.webhook === true ? {} : channel.webhook,
4232
+ ...source ? { avatar: avatars.get(source) } : {}
4233
+ } : void 0;
4142
4234
  const outcome = await provisionChannel({
4143
4235
  notifications,
4144
4236
  ...secrets ? { secrets } : {},
@@ -4151,7 +4243,7 @@ async function runNotificationsSetup(input) {
4151
4243
  category: category.name,
4152
4244
  ...channel.topic ? { topic: channel.topic } : {},
4153
4245
  ...effectiveAccess(category.access, channel.access) ? { access: effectiveAccess(category.access, channel.access) } : {},
4154
- ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
4246
+ ...webhook ? { webhook } : {}
4155
4247
  });
4156
4248
  report.channels.push(outcome);
4157
4249
  if (outcome.webhook && !outcome.webhook.stored) report.failures.push({