spur-board 0.4.1 → 0.6.0

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
@@ -1,6 +1,6 @@
1
- import { I as configSchema, L as SLACK_BOT_SCOPES, s as OPERATIONS } from "./src-BEo3mvRC.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-CHohYNxa.mjs";
3
- import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-BuxxS_er.mjs";
1
+ import { B as SLACK_BOT_SCOPES, R as configSchema, s as OPERATIONS, z as requiredSecretKeys } from "./src-Cy74ZQfR.mjs";
2
+ import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-Dw9ZovyI.mjs";
3
+ import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-CeDZQlvy.mjs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir, hostname, userInfo } from "node:os";
@@ -13,6 +13,23 @@ import { parseEnv } from "node:util";
13
13
  import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
14
14
  import { fileURLToPath } from "node:url";
15
15
  //#region src/manifest.ts
16
+ function loadSlackManifestDefaults(env, home) {
17
+ let contents;
18
+ try {
19
+ contents = readFileSync(configFile(env, home), "utf8");
20
+ } catch (error) {
21
+ if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return {};
22
+ throw new CliError("config", void 0, void 0, "Read config.yaml before generating a manifest.");
23
+ }
24
+ const document = YAML.parseDocument(contents);
25
+ if (document.errors.length > 0) throw new CliError("config", void 0, void 0, "Fix the YAML in config.yaml.");
26
+ const parsed = configSchema.safeParse(document.toJS());
27
+ if (!parsed.success) throw new CliError("config", void 0, void 0, `Fix ${parsed.error.issues[0]?.path.map(String).join(".") || "config.yaml"} in config.yaml.`);
28
+ return {
29
+ appName: parsed.data.slack.appName,
30
+ command: parsed.data.slack.command
31
+ };
32
+ }
16
33
  function renderSlackManifest(options = {}) {
17
34
  const appName = (options.appName ?? "spur-board").trim();
18
35
  const command = options.command ?? "/board";
@@ -173,12 +190,7 @@ function localSecrets(config, path, ports) {
173
190
  } catch {
174
191
  names = {};
175
192
  }
176
- const slackConfigured = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
177
- const required = [
178
- "GITHUB_TOKEN",
179
- ...config.projects.some((project) => project.tracker.type === "jira") ? ["JIRA_EMAIL", "JIRA_API_TOKEN"] : [],
180
- ...slackConfigured ? ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] : []
181
- ];
193
+ const required = requiredSecretKeys(config);
182
194
  const missing = required.filter((name) => !names[name]);
183
195
  return {
184
196
  ...result("secrets", missing.length === 0, `Add ${missing.join(", ")} to the private secrets.env file.`),
@@ -988,7 +1000,9 @@ function registerSetupCommands(program, options, io, env) {
988
1000
  const appName = manifest.opts()["appName"];
989
1001
  const command = manifest.opts()["command"];
990
1002
  if (appName !== void 0 && typeof appName !== "string" || command !== void 0 && typeof command !== "string") throw new CliError("usage");
1003
+ const defaults = appName === void 0 || command === void 0 ? loadSlackManifestDefaults(env, options.home ?? homedir()) : {};
991
1004
  io.stdout(renderSlackManifest({
1005
+ ...defaults,
992
1006
  ...appName === void 0 ? {} : { appName },
993
1007
  ...command === void 0 ? {} : { command }
994
1008
  }));
@@ -1,4 +1,4 @@
1
- import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-BEo3mvRC.mjs";
1
+ import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-Cy74ZQfR.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { request } from "node:http";
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as requestTokenEnrollment, t as requestOperation } from "./client-CHohYNxa.mjs";
1
+ import { n as requestTokenEnrollment, t as requestOperation } from "./client-Dw9ZovyI.mjs";
2
2
  export { requestOperation, requestTokenEnrollment };
@@ -1,5 +1,5 @@
1
- import { I as configSchema } from "./src-BEo3mvRC.mjs";
2
- import { r as CliError } from "./client-CHohYNxa.mjs";
1
+ import { R as configSchema } from "./src-Cy74ZQfR.mjs";
2
+ import { r as CliError } from "./client-Dw9ZovyI.mjs";
3
3
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
package/dist/daemon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as taskKeySchema, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, I as configSchema, L as SLACK_BOT_SCOPES, O as parseActorId, P as successEnvelopeSchema, S as prRefSchema, T as actorIdSchema, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES } from "./src-BEo3mvRC.mjs";
1
+ import { A as taskKeySchema, B as SLACK_BOT_SCOPES, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as configSchema, S as prRefSchema, T as actorIdSchema, _ as EVENT_KINDS, a as buildCatalogSnapshot, b as SESSION_STATUSES, c as renderHttpProblem, d as SPACE_IDS, f as conversationRefSchema, g as userIdSchema, h as messageRefSchema, i as sourceCursorSchema, j as ERRORS, k as parseRef$1, m as interactionRefSchema, n as batchSchema, o as roleCanUseOperation, p as inboundEventSchema, r as entityRefSchema, s as OPERATIONS, t as AdapterError, v as eventPayloadSchemas, w as ticketFactsSchema, x as prFactsSchema, y as SESSION_HEALTH_STATES, z as requiredSecretKeys } from "./src-Cy74ZQfR.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { closeSync, existsSync, fstatSync, ftruncateSync, mkdirSync, openSync, readFileSync, readdirSync, writeSync } from "node:fs";
@@ -11546,6 +11546,13 @@ function createInbound(input) {
11546
11546
  await clearItem(trx, item.id, new Date(at));
11547
11547
  }
11548
11548
  async function runUser(trx, event, at) {
11549
+ if (event.kind === "command" && event.name !== void 0 && event.name !== config.slack.command) return deny(trx, {
11550
+ event,
11551
+ reason: "Command name does not match the configured command.",
11552
+ actor: "system",
11553
+ gateway: gateway.kind,
11554
+ at
11555
+ });
11549
11556
  if (event.kind !== "message_deleted" && event.author !== config.operator.slack) return deny(trx, {
11550
11557
  event,
11551
11558
  reason: "Only the configured operator may act.",
@@ -11731,7 +11738,7 @@ function createInbound(input) {
11731
11738
  } catch {}
11732
11739
  }
11733
11740
  async function applyUser(event) {
11734
- if ((event.kind === "command" || event.kind === "action") && event.author === config.operator.slack && (event.kind === "command" || event.sourceSpace !== null)) {
11741
+ if ((event.kind === "command" || event.kind === "action") && event.author === config.operator.slack && (event.kind !== "command" || event.name === void 0 || event.name === config.slack.command) && (event.kind === "command" || event.sourceSpace !== null)) {
11735
11742
  await dispatchInteraction(event);
11736
11743
  return;
11737
11744
  }
@@ -11811,6 +11818,7 @@ function createInbound(input) {
11811
11818
  const item = await store.db.selectFrom("operator_item").select(["cleared_at", "inbound_event_json"]).where("id", "=", itemId).executeTakeFirst();
11812
11819
  if (item === void 0 || item.cleared_at !== null || item.inbound_event_json === null) throw new CommandError("retry_not_applicable", "This item has no stored event to retry.");
11813
11820
  let event = inboundEventSchema.parse(JSON.parse(item.inbound_event_json));
11821
+ if (event.kind === "command" && event.name !== void 0 && event.name !== config.slack.command) throw new CommandError("retry_not_applicable", "This interaction cannot be retried.");
11814
11822
  if (event.kind === "command" && event.command === void 0) {
11815
11823
  const command = legacyCommand(event.text);
11816
11824
  if (command === null) throw new CommandError("retry_not_applicable", "This interaction cannot be retried.");
@@ -12728,6 +12736,11 @@ function retryAt(headers, now) {
12728
12736
  }
12729
12737
  //#endregion
12730
12738
  //#region ../adapters/src/github/client.ts
12739
+ function teamNameScopeDenied(error, query) {
12740
+ if (error.type !== "INSUFFICIENT_SCOPES" || !query.includes("... on Team { name }")) return false;
12741
+ if (error.path !== void 0) return error.path.includes("reviewRequests") && error.path.includes("requestedReviewer") && error.path.at(-1) === "name";
12742
+ return /\bname\b/i.test(error.message) && /\bread:(?:org|discussion)\b/i.test(error.message);
12743
+ }
12731
12744
  function record$2(value) {
12732
12745
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new AdapterError("transient", "Malformed GitHub response");
12733
12746
  return value;
@@ -12816,6 +12829,8 @@ var GithubClient = class {
12816
12829
  status: 429,
12817
12830
  headers: response.headers
12818
12831
  }, Date.now());
12832
+ if (errors.some((error) => teamNameScopeDenied(error, query))) throw new AdapterError("auth", "GitHub token needs read:org for team review requests");
12833
+ if (errors.some((error) => error.type === "INSUFFICIENT_SCOPES")) throw new AdapterError("auth", "GitHub token lacks a required GraphQL read scope");
12819
12834
  const data = record$2(wire.data);
12820
12835
  const rate = record$2(data.rateLimit);
12821
12836
  const remaining = rate.remaining;
@@ -13048,6 +13063,12 @@ function githubSources(config, options) {
13048
13063
  function scoped(errors, root) {
13049
13064
  return errors.filter((error) => error.path?.[0] === root);
13050
13065
  }
13066
+ function checkAccessDenied(errors) {
13067
+ return errors.some((error) => error.type === "FORBIDDEN" && error.path?.includes("statusCheckRollup") === true && error.path.includes("contexts"));
13068
+ }
13069
+ function requireCheckAccess(errors) {
13070
+ if (checkAccessDenied(errors)) throw new AdapterError("auth", "GitHub token lacks Checks read permission");
13071
+ }
13051
13072
  function rejectUnscoped(errors, roots) {
13052
13073
  if (errors.some((error) => error.path === void 0 || !roots.includes(String(error.path[0])))) throw new AdapterError("transient", "Unscoped GitHub GraphQL error");
13053
13074
  }
@@ -13089,6 +13110,7 @@ async function fullPr(client, pr, owner, name, number, signal, budget) {
13089
13110
  }, signal);
13090
13111
  budget(response.budget);
13091
13112
  rejectUnscoped(response.errors, ["repository"]);
13113
+ requireCheckAccess(response.errors);
13092
13114
  if (response.errors.length > 0) return void 0;
13093
13115
  const pagePr = record$2(nested(response.data, "repository", "pullRequest"));
13094
13116
  const page = field === "contexts" ? contexts(pagePr) : record$2(pagePr[field]);
@@ -13146,6 +13168,7 @@ async function pollRepository({ id, repo, login, prefixes, client, ctx }) {
13146
13168
  const found = discover(nodes, ctx.cursor, login);
13147
13169
  const bootstrapCursor = bootstrap && discoveryOk ? latestUpdatedAt(connectionNodes(nested(response.data, "repository", "mergedBaseline")), found.cursor) ?? sourceCursorSchema.parse("1970-01-01T00:00:00.000Z") : void 0;
13148
13170
  const process = async (number, wire, errors, ref) => {
13171
+ requireCheckAccess(errors);
13149
13172
  const error = errors[0];
13150
13173
  if (error !== void 0) {
13151
13174
  const direct = error.path === void 0 || error.path.length <= 2;
@@ -13449,6 +13472,7 @@ function createGithubIssuesAdapter(options) {
13449
13472
  //#endregion
13450
13473
  //#region ../adapters/src/jira/jira.ts
13451
13474
  const SEARCH_PAGE_LIMIT = 20;
13475
+ const MAX_GET_SEARCH_URL_LENGTH = 2e3;
13452
13476
  const OUTSIDE_BATCH_SIZE = 50;
13453
13477
  const FALLBACK_REQUEST_LIMIT = 100;
13454
13478
  const SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint";
@@ -13563,6 +13587,7 @@ function createJiraAdapter(options) {
13563
13587
  if (signal.aborted) throw new AdapterError("transient", "Jira poll aborted");
13564
13588
  throw new AdapterError("transient", "Jira request failed");
13565
13589
  }
13590
+ if (response.status === 414 && options.searchMethod === "GET") throw new AdapterError("config", "Jira GET search URL is too long");
13566
13591
  if (!response.ok) throw new JiraHttpError(response.status, classifyHttpFailure({
13567
13592
  status: response.status,
13568
13593
  headers: response.headers
@@ -13604,19 +13629,28 @@ function createJiraAdapter(options) {
13604
13629
  const seen = /* @__PURE__ */ new Set();
13605
13630
  for (let page = 0; page < SEARCH_PAGE_LIMIT; page += 1) {
13606
13631
  beforePage?.();
13607
- const result = object$1(await request(tracker.baseUrl, "/rest/api/3/search/jql", signal, {
13632
+ const fields = [
13633
+ "summary",
13634
+ "status",
13635
+ "assignee",
13636
+ "labels",
13637
+ "updated",
13638
+ ...sprintField === null ? [] : [sprintField]
13639
+ ];
13640
+ const body = {
13608
13641
  jql,
13609
13642
  maxResults: 100,
13610
- fields: [
13611
- "summary",
13612
- "status",
13613
- "assignee",
13614
- "labels",
13615
- "updated",
13616
- ...sprintField === null ? [] : [sprintField]
13617
- ],
13643
+ fields,
13618
13644
  ...token === void 0 ? {} : { nextPageToken: token }
13619
- }));
13645
+ };
13646
+ const path = options.searchMethod === "GET" ? `/rest/api/3/search/jql?${new URLSearchParams({
13647
+ jql,
13648
+ maxResults: "100",
13649
+ fields: fields.join(","),
13650
+ ...token === void 0 ? {} : { nextPageToken: token }
13651
+ }).toString()}` : "/rest/api/3/search/jql";
13652
+ if (options.searchMethod === "GET" && new URL(path, tracker.baseUrl).href.length > MAX_GET_SEARCH_URL_LENGTH) throw new AdapterError("config", `Jira GET search URL is too long (over ${String(MAX_GET_SEARCH_URL_LENGTH)} characters)`);
13653
+ const result = object$1(await request(tracker.baseUrl, path, signal, options.searchMethod === "GET" ? void 0 : body));
13620
13654
  issues.push(...array(result["issues"]));
13621
13655
  const next = result["nextPageToken"];
13622
13656
  if (next == null || next === "") {
@@ -13870,9 +13904,15 @@ function applyEntityOutcomes(input) {
13870
13904
  function startRunner(options) {
13871
13905
  const startedAt = (options.now ?? Date.now)();
13872
13906
  const sources = options.entries.map((entry) => runSource(entry, options, startedAt));
13873
- return { async stop() {
13874
- await Promise.all(sources.map((source) => source.stop()));
13875
- } };
13907
+ return {
13908
+ firstAttempts: Promise.all(sources.map(async (source) => ({
13909
+ source: source.id,
13910
+ outcome: await source.firstAttempt
13911
+ }))),
13912
+ async stop() {
13913
+ await Promise.all(sources.map((source) => source.stop()));
13914
+ }
13915
+ };
13876
13916
  }
13877
13917
  function runSource(entry, options, runnerStartMs) {
13878
13918
  const { adapter, reserveRemaining } = entry;
@@ -13946,7 +13986,7 @@ function runSource(entry, options, runnerStartMs) {
13946
13986
  if (stopping) return;
13947
13987
  pollTimer = setTimeout(() => {
13948
13988
  pollTimer = void 0;
13949
- current = poll();
13989
+ current = poll().then(() => void 0);
13950
13990
  }, delayMs);
13951
13991
  }
13952
13992
  async function poll() {
@@ -14042,22 +14082,28 @@ function runSource(entry, options, runnerStartMs) {
14042
14082
  now: now()
14043
14083
  });
14044
14084
  failures = delay.failures;
14045
- armDeadline();
14085
+ if (!options.oneShot) armDeadline();
14046
14086
  await report();
14047
- schedule(delay.delayMs);
14087
+ if (!options.oneShot) schedule(delay.delayMs);
14088
+ return outcome;
14048
14089
  }
14049
- armDeadline();
14050
- current = poll();
14051
- return { async stop() {
14052
- stopping = true;
14053
- clearTimeout(pollTimer);
14054
- clearTimeout(deadlineTimer);
14055
- clearTimeout(hardBoundTimer);
14056
- pollTimer = deadlineTimer = hardBoundTimer = void 0;
14057
- controller?.abort();
14058
- await current;
14059
- await reports;
14060
- } };
14090
+ if (!options.oneShot) armDeadline();
14091
+ const firstAttempt = poll();
14092
+ current = firstAttempt.then(() => void 0);
14093
+ return {
14094
+ id: source,
14095
+ firstAttempt,
14096
+ async stop() {
14097
+ stopping = true;
14098
+ clearTimeout(pollTimer);
14099
+ clearTimeout(deadlineTimer);
14100
+ clearTimeout(hardBoundTimer);
14101
+ pollTimer = deadlineTimer = hardBoundTimer = void 0;
14102
+ controller?.abort();
14103
+ await current;
14104
+ await reports;
14105
+ }
14106
+ };
14061
14107
  }
14062
14108
  /**
14063
14109
  * Settles with `work`, or with undefined as soon as `signal` aborts, so `stop()` never waits
@@ -14317,7 +14363,8 @@ function startAdapters(config, core, options = {}) {
14317
14363
  },
14318
14364
  intervalMs: config.adapters.jira.intervalMs,
14319
14365
  ...options.fetch === void 0 ? {} : { fetch: options.fetch },
14320
- warn: options.warn ?? (() => void 0)
14366
+ warn: options.warn ?? (() => void 0),
14367
+ ...options.jiraSearchMethod === void 0 ? {} : { searchMethod: options.jiraSearchMethod }
14321
14368
  }) : blindSource(id, config.adapters.jira.intervalMs, "Jira credential is missing") });
14322
14369
  } else {
14323
14370
  const id = `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
@@ -14354,12 +14401,16 @@ function startAdapters(config, core, options = {}) {
14354
14401
  }) }],
14355
14402
  core,
14356
14403
  config,
14357
- ...options.now === void 0 ? {} : { now: options.now }
14404
+ ...options.now === void 0 ? {} : { now: options.now },
14405
+ ...options.oneShot === void 0 ? {} : { oneShot: options.oneShot }
14358
14406
  });
14359
- return { async stop() {
14360
- await runner.stop();
14361
- await Promise.allSettled([...notices]);
14362
- } };
14407
+ return {
14408
+ firstAttempts: runner.firstAttempts,
14409
+ async stop() {
14410
+ await runner.stop();
14411
+ await Promise.allSettled([...notices]);
14412
+ }
14413
+ };
14363
14414
  }
14364
14415
  //#endregion
14365
14416
  //#region ../gateway-slack/src/api.ts
@@ -14896,7 +14947,7 @@ function createDeliveryGateway(client, channels) {
14896
14947
  function descriptors(commandName) {
14897
14948
  return OPERATIONS.filter((operation) => operation.kind === "query" && operation.slack?.kind === "slash-command").map((operation) => ({
14898
14949
  operation,
14899
- usage: operation.slack?.kind === "slash-command" ? operation.slack.usage.replace(/^\/board\b/, commandName) : ""
14950
+ usage: operation.slack?.kind === "slash-command" ? operation.slack.usage.replace(/^\/\S+/, commandName) : ""
14900
14951
  }));
14901
14952
  }
14902
14953
  function commandUsage(commandName) {
@@ -15071,7 +15122,7 @@ function normalizeMessage(channel, payload, context) {
15071
15122
  return events;
15072
15123
  }
15073
15124
  /** Actions and commands share the same stable trigger identity as their Slack retry. */
15074
- function normalizeInteraction(type, payload, channels, root, commandName = "/board") {
15125
+ function normalizeInteraction(type, payload, channels, root, commandName = DEFAULT_SLACK_COMMAND) {
15075
15126
  const body = record$1(payload);
15076
15127
  if (body === null) return [];
15077
15128
  const triggerId = string$1(body.trigger_id);
@@ -15080,16 +15131,18 @@ function normalizeInteraction(type, payload, channels, root, commandName = "/boa
15080
15131
  if (user === void 0) return [];
15081
15132
  const author = userIdSchema.parse(user);
15082
15133
  if (type === "slash_commands") {
15083
- if (body.command !== commandName) return [];
15134
+ const name = string$1(body.command);
15135
+ if (name === void 0) return [];
15084
15136
  const channel = string$1(body.channel_id);
15085
15137
  if (channel === void 0) return [];
15086
15138
  return [{
15087
15139
  kind: "command",
15140
+ name,
15088
15141
  dedupeKey: `slack:${triggerId}`,
15089
15142
  interaction: interactionRef(channel, user, triggerId),
15090
15143
  author,
15091
15144
  text: text(body.text) ?? "",
15092
- command: parseCommand(text(body.text) ?? "", commandName),
15145
+ ...name === commandName ? { command: parseCommand(text(body.text) ?? "", commandName) } : {},
15093
15146
  sourceUrl: sourceUrl(channel),
15094
15147
  sourceSpace: sourceSpace(channel, channels)
15095
15148
  }];
@@ -15508,11 +15561,7 @@ async function runDaemonDoctor(ctx, productVersion, fetcher = fetch) {
15508
15561
  const checks = [];
15509
15562
  const jiraTrackers = config.projects.flatMap((project) => project.tracker.type === "jira" ? [project.tracker] : []);
15510
15563
  const slackConfigured = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
15511
- const missing = [
15512
- "GITHUB_TOKEN",
15513
- ...jiraTrackers.length > 0 ? ["JIRA_EMAIL", "JIRA_API_TOKEN"] : [],
15514
- ...slackConfigured ? ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] : []
15515
- ].filter((name) => !secrets[name]);
15564
+ const missing = requiredSecretKeys(config).filter((name) => !secrets[name]);
15516
15565
  checks.push(check("secrets", missing.length === 0, `Add ${missing.join(", ")} to the private secrets.env file.`));
15517
15566
  const jiraEmail = secrets["JIRA_EMAIL"];
15518
15567
  const jiraToken = secrets["JIRA_API_TOKEN"];
package/dist/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-BEo3mvRC.mjs";
2
- import { r as CliError, t as requestOperation } from "./client-CHohYNxa.mjs";
3
- import { n as loadClientConfig } from "./config-BuxxS_er.mjs";
1
+ import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-Cy74ZQfR.mjs";
2
+ import { r as CliError, t as requestOperation } from "./client-Dw9ZovyI.mjs";
3
+ import { n as loadClientConfig } from "./config-CeDZQlvy.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { z } from "zod";
@@ -111,6 +111,8 @@ const SLACK_BOT_SCOPES = [
111
111
  //#endregion
112
112
  //#region ../contracts/src/config.ts
113
113
  const DEFAULT_HTTP_PORT = 4311;
114
+ const DEFAULT_SLACK_APP_NAME = "spur-board";
115
+ const DEFAULT_SLACK_COMMAND = "/board";
114
116
  const WEEKDAYS = [
115
117
  "monday",
116
118
  "tuesday",
@@ -247,7 +249,8 @@ const configSchema = z.strictObject({
247
249
  }),
248
250
  operator: z.strictObject({ slack: nonEmpty }),
249
251
  slack: z.strictObject({
250
- command: z.string().regex(/^\/[a-z0-9_-]+$/, "Expected a slash command such as /board").default("/board"),
252
+ appName: z.string().trim().min(1).default(DEFAULT_SLACK_APP_NAME),
253
+ command: z.string().regex(/^\/[a-z0-9_-]+$/, "Expected a slash command such as /board").default(DEFAULT_SLACK_COMMAND),
251
254
  appId: z.string().regex(/^A[A-Z0-9]+$/, "Expected a Slack app ID").optional(),
252
255
  channels: z.strictObject({
253
256
  orchestrators: nonEmpty.optional(),
@@ -300,6 +303,49 @@ const configSchema = z.strictObject({
300
303
  }).prefault({})
301
304
  }).prefault({})
302
305
  });
306
+ /** Recognized names in the private secrets.env file; values never belong in config.yaml. */
307
+ const SECRET_ENV_KEYS = [
308
+ {
309
+ name: "GITHUB_TOKEN",
310
+ requiredWhen: "always",
311
+ purpose: "Read GitHub repositories and check runs."
312
+ },
313
+ {
314
+ name: "JIRA_EMAIL",
315
+ requiredWhen: "jira",
316
+ purpose: "Jira account email."
317
+ },
318
+ {
319
+ name: "JIRA_API_TOKEN",
320
+ requiredWhen: "jira",
321
+ purpose: "Jira API token."
322
+ },
323
+ {
324
+ name: "SLACK_BOT_TOKEN",
325
+ requiredWhen: "slack",
326
+ purpose: "Slack bot Web API token."
327
+ },
328
+ {
329
+ name: "SLACK_APP_TOKEN",
330
+ requiredWhen: "slack",
331
+ purpose: "Slack app-level Socket Mode token."
332
+ },
333
+ {
334
+ name: "SLACK_APP_CONFIG_TOKEN",
335
+ requiredWhen: "optional",
336
+ purpose: "Verify the installed slash command through the Slack app manifest API."
337
+ },
338
+ {
339
+ name: "SLACK_API_URL",
340
+ requiredWhen: "optional",
341
+ purpose: "Override the Slack API URL for local testing."
342
+ }
343
+ ];
344
+ function requiredSecretKeys(config) {
345
+ const jira = config.projects.some((project) => project.tracker.type === "jira");
346
+ const slack = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
347
+ return SECRET_ENV_KEYS.filter(({ requiredWhen }) => requiredWhen === "always" || requiredWhen === "jira" && jira || requiredWhen === "slack" && slack).map(({ name }) => name);
348
+ }
303
349
  //#endregion
304
350
  //#region ../contracts/src/errors.ts
305
351
  /**
@@ -1114,6 +1160,7 @@ const actionEventSchema = z.strictObject({
1114
1160
  /** `text` is the command's argument text. */
1115
1161
  const commandEventSchema = z.strictObject({
1116
1162
  kind: z.literal("command"),
1163
+ name: z.string().min(1).optional(),
1117
1164
  dedupeKey,
1118
1165
  interaction: interactionRefSchema,
1119
1166
  author: userIdSchema,
@@ -2631,4 +2678,4 @@ var AdapterError = class extends Error {
2631
2678
  }
2632
2679
  };
2633
2680
  //#endregion
2634
- export { taskKeySchema as A, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, configSchema as I, SLACK_BOT_SCOPES as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, prRefSchema as S, actorIdSchema as T, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y };
2681
+ export { taskKeySchema as A, SLACK_BOT_SCOPES as B, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, DEFAULT_HTTP_PORT as F, DEFAULT_SLACK_APP_NAME as I, DEFAULT_SLACK_COMMAND as L, errorContextSchema as M, errorEnvelopeSchema as N, parseActorId as O, successEnvelopeSchema as P, configSchema as R, prRefSchema as S, actorIdSchema as T, EVENT_KINDS as _, buildCatalogSnapshot as a, SESSION_STATUSES as b, renderHttpProblem as c, SPACE_IDS as d, conversationRefSchema as f, userIdSchema as g, messageRefSchema as h, sourceCursorSchema as i, ERRORS as j, parseRef as k, renderJsonError as l, interactionRefSchema as m, batchSchema as n, roleCanUseOperation as o, inboundEventSchema as p, entityRefSchema as r, OPERATIONS as s, AdapterError as t, renderMcpError as u, eventPayloadSchemas as v, ticketFactsSchema as w, prFactsSchema as x, SESSION_HEALTH_STATES as y, requiredSecretKeys as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "^24.12.0 || ^26.0.0"
@@ -4,6 +4,6 @@ Use this when the orchestrators or feed channel is not configured, or the app-le
4
4
 
5
5
  1. Check the bot's current channel membership. List the private channels it can see and ask the operator, one choice at a time, which should serve orchestrators and feed. An empty list does not prove no channels exist: the bot sees only private channels to which it was invited. Store channel IDs in config, never guessed names.
6
6
  2. If no channel fits, ask for an operator-chosen prefix and suggest available names using it. The operator creates or invites the app to channels; ask before any app or channel change.
7
- 3. Check whether an app and slash command already exist. If either is missing, use `board slack manifest --app-name <name> --command <slash-command>` to prepare a manifest with operator-chosen names. The operator creates the Slack app; do not claim the manifest creates it.
8
- 4. The manifest cannot create the app-level token with `connections:write`. Guide the operator through creating that token in Slack, then use the private entry procedure in [secret files](secrets.md). Never ask for a token value in chat.
7
+ 3. Check whether an app and slash command already exist. If either is missing, use `board slack manifest --app-name <name> --command <slash-command>` to prepare a manifest with operator-chosen names. The operator creates the Slack app; do not claim the manifest creates it. Set `slack.appName` and `slack.command` in config to the selected names.
8
+ 4. The manifest cannot create the app-level token with `connections:write`. In the Slack app settings, open **Basic Information → App-Level Tokens** and create a token with that scope. Then use the private entry procedure in [secret files](secrets.md). Never ask for a token value in chat.
9
9
  5. Check channel membership and command configuration with online doctor. If a check fails, apply its reported fix after inspecting state.