@theholocron/cli 5.0.0-alpha.188 → 5.0.0-alpha.189

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
@@ -2766,14 +2766,19 @@ async function provisionChannel(deps, spec) {
2766
2766
  const channel = await notifications.ensureChannel({
2767
2767
  name: spec.name,
2768
2768
  ...spec.category ? { category: spec.category } : {},
2769
- ...spec.topic ? { topic: spec.topic } : {}
2769
+ ...spec.topic ? { topic: spec.topic } : {},
2770
+ ...spec.access ? { access: spec.access } : {}
2770
2771
  });
2771
- print(` ${(channel.created ? style.success : style.skip)(`${channel.created ? "created" : "exists "} channel ${spec.name}${where}`)}`);
2772
- if (tally) tally[channel.created ? "ok" : "skip"]++;
2772
+ const changed = channel.created || (channel.updated?.length ?? 0) > 0;
2773
+ const verb = channel.created ? "created" : changed ? "updated" : "exists ";
2774
+ const what = channel.updated?.length ? `: ${channel.updated.join(", ")}` : "";
2775
+ print(` ${(changed ? style.success : style.skip)(`${verb} channel ${spec.name}${where}${what}`)}`);
2776
+ if (tally) tally[changed ? "ok" : "skip"]++;
2773
2777
  const outcome = {
2774
2778
  name: spec.name,
2775
2779
  ...spec.category ? { category: spec.category } : {},
2776
- created: channel.created
2780
+ created: channel.created,
2781
+ ...channel.updated?.length ? { updated: channel.updated } : {}
2777
2782
  };
2778
2783
  if (!webhookCfg) return outcome;
2779
2784
  const hook = await notifications.ensureWebhook({
@@ -2893,11 +2898,6 @@ async function runNotificationsChannelAdd(input) {
2893
2898
  */
2894
2899
  /** Features not built yet, in the order of the table in holocron#1098. Move an entry into a real check when it lands. */
2895
2900
  const PLANNED_CHECKS = [
2896
- {
2897
- step: 8,
2898
- id: "permissions",
2899
- title: "channel and category permissions set"
2900
- },
2901
2901
  {
2902
2902
  step: 10,
2903
2903
  id: "server-settings",
@@ -2935,7 +2935,11 @@ const PLANNED_CHECKS = [
2935
2935
  }
2936
2936
  ];
2937
2937
  const same = (a, b) => a.toLowerCase() === b.toLowerCase();
2938
+ /** `everyone`, or the roles sorted and lower-cased, so order and case do not matter when comparing. */
2939
+ const accessKey = (v) => v === "everyone" ? "everyone" : JSON.stringify([...new Set(v.map((r) => r.toLowerCase()))].sort());
2940
+ const accessText = (v) => v === "everyone" ? "everyone" : v.length === 0 ? "nobody (bots and webhooks only)" : v.join(", ");
2938
2941
  const shown = (s) => sanitizeDetail(s);
2942
+ const ACCESS_TITLE = "channel and category access set";
2939
2943
  const ROLE_APPEARANCE_TITLE = "role colour, hoist and permissions set";
2940
2944
  const SECRETS_TITLE = "webhook URLs and role id stored as secrets";
2941
2945
  async function runDoctorNotifications(input) {
@@ -3053,7 +3057,8 @@ async function runDoctorNotifications(input) {
3053
3057
  ["channels", "channels in their category, with topics"],
3054
3058
  ["webhooks", "webhooks on their channels"],
3055
3059
  ["roles", "roles exist and are mentionable"],
3056
- ["role-appearance", ROLE_APPEARANCE_TITLE]
3060
+ ["role-appearance", ROLE_APPEARANCE_TITLE],
3061
+ ["access", ACCESS_TITLE]
3057
3062
  ]) failed(id, title, "not checked: could not read the server");
3058
3063
  } else {
3059
3064
  const inspected = found;
@@ -3119,6 +3124,44 @@ async function runDoctorNotifications(input) {
3119
3124
  }
3120
3125
  const styled = roles.filter((r) => r.color !== void 0 || r.hoist !== void 0 || r.permissions !== void 0);
3121
3126
  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`);
3127
+ const accessProblems = [];
3128
+ const compare = (label, want, have) => {
3129
+ if (!have) {
3130
+ accessProblems.push(`${label}: the provider did not report its access`);
3131
+ return;
3132
+ }
3133
+ if (have === "custom") {
3134
+ accessProblems.push(`${label}: has permission overwrites that are not an access the layout can state; re-run setup to replace them`);
3135
+ return;
3136
+ }
3137
+ for (const field of ["view", "post"]) {
3138
+ const wanted = want[field] ?? "everyone";
3139
+ if (accessKey(wanted) !== accessKey(have[field])) accessProblems.push(`${label}: ${field} is ${accessText(have[field])} but the layout says ${accessText(wanted)}`);
3140
+ }
3141
+ };
3142
+ let accessDeclared = 0;
3143
+ for (const cat of categories) {
3144
+ if (cat.access) {
3145
+ accessDeclared++;
3146
+ if (!inspected.categories.some((name) => same(name, cat.name))) accessProblems.push(`${shown(cat.name)}: not checked, the category is missing`);
3147
+ else {
3148
+ const have = Object.entries(inspected.categoryAccess ?? {}).find(([n]) => same(n, cat.name))?.[1];
3149
+ compare(shown(cat.name), cat.access, have);
3150
+ }
3151
+ }
3152
+ for (const channel of cat.channels ?? []) {
3153
+ if (!cat.access && !channel.access) continue;
3154
+ accessDeclared++;
3155
+ const want = {
3156
+ ...cat.access,
3157
+ ...channel.access
3158
+ };
3159
+ const { match } = pick(channel.name, cat.name);
3160
+ if (!match) accessProblems.push(`#${shown(channel.name)}: not checked, the channel is missing`);
3161
+ else compare(`#${shown(channel.name)}`, want, match.access);
3162
+ }
3163
+ }
3164
+ add("access", ACCESS_TITLE, inspected.rolesError && accessDeclared > 0 ? ["not checked: could not read the roles"] : accessProblems, accessDeclared === 0 ? "none declared" : `${accessDeclared} of ${accessDeclared} as declared`);
3122
3165
  }
3123
3166
  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] : [])];
3124
3167
  if (wanted.length === 0) checks.push({
@@ -3779,6 +3822,44 @@ async function runNew(input) {
3779
3822
  * Run it from ONE repo per server — a layout is org-level, and putting it in a
3780
3823
  * shared preset would apply it from every repo.
3781
3824
  */
3825
+ /** A channel's access: its category's, with any field the channel states itself laid over it. */
3826
+ function effectiveAccess(category, channel) {
3827
+ if (!category && !channel) return void 0;
3828
+ return {
3829
+ ...category,
3830
+ ...channel
3831
+ };
3832
+ }
3833
+ /** The first role an `access` names that the layout does not declare, or undefined. */
3834
+ function accessRoleProblem(layout) {
3835
+ const declared = new Set((layout.roles ?? []).map((r) => r.name.toLowerCase()));
3836
+ const check = (where, access) => {
3837
+ for (const field of ["view", "post"]) {
3838
+ const value = access?.[field];
3839
+ if (!Array.isArray(value)) continue;
3840
+ const unknown = value.find((name) => !declared.has(name.toLowerCase()));
3841
+ if (unknown !== void 0) return `${where}: \`access.${field}\` names the role \`${unknown}\`, which is not in the layout's roles`;
3842
+ }
3843
+ };
3844
+ const cannotSee = (where, access) => {
3845
+ if (!Array.isArray(access?.view) || !Array.isArray(access?.post)) return void 0;
3846
+ const seen = new Set(access.view.map((r) => r.toLowerCase()));
3847
+ const stuck = access.post.find((r) => !seen.has(r.toLowerCase()));
3848
+ return stuck === void 0 ? void 0 : `${where}: the role \`${stuck}\` may post but is not in \`view\`, so it could not see the channel`;
3849
+ };
3850
+ for (const category of layout.categories ?? []) {
3851
+ const problem = check(`category \`${category.name}\``, category.access);
3852
+ if (problem) return problem;
3853
+ const stuckCategory = cannotSee(`category \`${category.name}\``, category.access);
3854
+ if (stuckCategory) return stuckCategory;
3855
+ for (const channel of category.channels ?? []) {
3856
+ const found = check(`channel \`${channel.name}\``, channel.access);
3857
+ if (found) return found;
3858
+ const stuck = cannotSee(`channel \`${channel.name}\``, effectiveAccess(category.access, channel.access));
3859
+ if (stuck) return stuck;
3860
+ }
3861
+ }
3862
+ }
3782
3863
  async function runNotificationsSetup(input) {
3783
3864
  const print = input.print ?? ((line) => console.log(line));
3784
3865
  const logger = input.logger ?? getLogger();
@@ -3832,6 +3913,10 @@ async function runNotificationsSetup(input) {
3832
3913
  }
3833
3914
  const secretProblem = layoutSecretProblem(layout);
3834
3915
  if (secretProblem) return refuse(secretProblem);
3916
+ const accessProblem = accessRoleProblem(layout);
3917
+ if (accessProblem) return refuse(accessProblem);
3918
+ const needsCategories = (layout.categories ?? []).find((c) => c.access);
3919
+ if (needsCategories && !notifications.ensureCategory) return refuse(`category \`${needsCategories.name}\` declares \`access\`, but the \`${notifications.providerName}\` provider cannot manage categories`);
3835
3920
  const providerProblem = notifications.validateLayout?.(layout);
3836
3921
  if (providerProblem) return refuse(providerProblem);
3837
3922
  const tally = {
@@ -3842,6 +3927,7 @@ async function runNotificationsSetup(input) {
3842
3927
  const attempt = async (step, fn) => {
3843
3928
  try {
3844
3929
  await fn();
3930
+ return true;
3845
3931
  } catch (err) {
3846
3932
  const message = describeProviderError(err);
3847
3933
  print(` ${style.fail(`${step}: ${message}`)}`);
@@ -3849,43 +3935,9 @@ async function runNotificationsSetup(input) {
3849
3935
  step,
3850
3936
  message
3851
3937
  });
3938
+ return false;
3852
3939
  }
3853
3940
  };
3854
- for (const category of layout.categories ?? []) {
3855
- print(`\n${style.header(category.name)}`);
3856
- report.categories.push(category.name);
3857
- await attempt(`category ${category.name}`, async () => {
3858
- if (!notifications.ensureCategory) return;
3859
- if (dryRun) {
3860
- print(` ${style.skip(`would ensure category ${category.name}`)}`);
3861
- tally.skip++;
3862
- return;
3863
- }
3864
- const result = await notifications.ensureCategory(category.name);
3865
- print(` ${(result.created ? style.success : style.skip)(`${result.created ? "created" : "exists "} category ${category.name}`)}`);
3866
- made(result.created);
3867
- });
3868
- for (const channel of category.channels ?? []) await attempt(`channel ${channel.name}`, async () => {
3869
- const outcome = await provisionChannel({
3870
- notifications,
3871
- ...secrets ? { secrets } : {},
3872
- scope: scope ?? { kind: "repo" },
3873
- dryRun,
3874
- print,
3875
- tally
3876
- }, {
3877
- name: channel.name,
3878
- category: category.name,
3879
- ...channel.topic ? { topic: channel.topic } : {},
3880
- ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
3881
- });
3882
- report.channels.push(outcome);
3883
- if (outcome.webhook && !outcome.webhook.stored) report.failures.push({
3884
- step: `webhook ${outcome.webhook.name}`,
3885
- message: outcome.webhook.note
3886
- });
3887
- });
3888
- }
3889
3941
  if ((layout.roles ?? []).length > 0) print(`\n${style.header("Roles")}`);
3890
3942
  for (const role of layout.roles ?? []) await attempt(`role ${role.name}`, async () => {
3891
3943
  if (!notifications.ensureRole) throw new Error(`\`${notifications.providerName}\` cannot manage roles`);
@@ -3925,6 +3977,51 @@ async function runNotificationsSetup(input) {
3925
3977
  tally.ok++;
3926
3978
  }
3927
3979
  });
3980
+ for (const category of layout.categories ?? []) {
3981
+ print(`\n${style.header(category.name)}`);
3982
+ report.categories.push(category.name);
3983
+ const categoryOk = await attempt(`category ${category.name}`, async () => {
3984
+ if (!notifications.ensureCategory) return;
3985
+ if (dryRun) {
3986
+ print(` ${style.skip(`would ensure category ${category.name}`)}`);
3987
+ tally.skip++;
3988
+ return;
3989
+ }
3990
+ const result = await notifications.ensureCategory(category.name, { ...category.access ? { access: category.access } : {} });
3991
+ const changed = result.created || (result.updated?.length ?? 0) > 0;
3992
+ const verb = result.created ? "created" : changed ? "updated" : "exists ";
3993
+ const what = result.updated?.length ? `: ${result.updated.join(", ")}` : "";
3994
+ print(` ${(changed ? style.success : style.skip)(`${verb} category ${category.name}${what}`)}`);
3995
+ made(changed);
3996
+ });
3997
+ const channels = category.channels ?? [];
3998
+ if (!categoryOk && channels.length > 0) {
3999
+ print(` ${style.skip(`skipped ${channels.length} channel${channels.length === 1 ? "" : "s"}: the category failed`)}`);
4000
+ tally.skip += channels.length;
4001
+ continue;
4002
+ }
4003
+ for (const channel of channels) await attempt(`channel ${channel.name}`, async () => {
4004
+ const outcome = await provisionChannel({
4005
+ notifications,
4006
+ ...secrets ? { secrets } : {},
4007
+ scope: scope ?? { kind: "repo" },
4008
+ dryRun,
4009
+ print,
4010
+ tally
4011
+ }, {
4012
+ name: channel.name,
4013
+ category: category.name,
4014
+ ...channel.topic ? { topic: channel.topic } : {},
4015
+ ...effectiveAccess(category.access, channel.access) ? { access: effectiveAccess(category.access, channel.access) } : {},
4016
+ ...channel.webhook ? { webhook: channel.webhook === true ? {} : channel.webhook } : {}
4017
+ });
4018
+ report.channels.push(outcome);
4019
+ if (outcome.webhook && !outcome.webhook.stored) report.failures.push({
4020
+ step: `webhook ${outcome.webhook.name}`,
4021
+ message: outcome.webhook.note
4022
+ });
4023
+ });
4024
+ }
3928
4025
  if (report.failures.length > 0) report.status = "fail";
3929
4026
  else if (dryRun) report.status = "dry-run";
3930
4027
  print("");