spur-board 0.4.0 → 0.5.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 { F as configSchema, I as SLACK_BOT_SCOPES, s as OPERATIONS } from "./src-Cf78bzK5.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-sGJ4fzQ4.mjs";
3
- import { n as loadEnrollmentConfig, t as loadClientConfig } from "./config-DPXFxZQy.mjs";
1
+ import { R as configSchema, s as OPERATIONS, z as SLACK_BOT_SCOPES } from "./src-VWyX7Nwm.mjs";
2
+ import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-BnkZmz1i.mjs";
3
+ import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-UyXnaekw.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";
@@ -140,12 +157,6 @@ function warning(id, fix) {
140
157
  fix
141
158
  };
142
159
  }
143
- function configFile(env, home) {
144
- const boardHome = env["BOARD_HOME"];
145
- if (boardHome) return join(resolve(boardHome), "config.yaml");
146
- const xdg = env["XDG_CONFIG_HOME"];
147
- return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "spur-board", "config.yaml");
148
- }
149
160
  function unitFile(env, home) {
150
161
  const xdg = env["XDG_CONFIG_HOME"];
151
162
  return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "systemd", "user", "spur-board.service");
@@ -334,8 +345,8 @@ async function runDoctor(options = {}) {
334
345
  checks.push(stale.length > 0 || unknownStarts ? warning("sessions.stale", `Restart sessions started before the last skill install${staleIds.length ? `: ${staleIds.join(", ")}` : ""}; verify sessions whose start time is unavailable.`) : result("sessions.stale", true, "Restart older sessions."));
335
346
  let remote = null;
336
347
  try {
337
- const base = env["BOARD_DAEMON_URL"] || `http://127.0.0.1:${String(config.http.port)}`;
338
- const response = await ports.fetcher(new URL("/v1/doctor", base), { signal: AbortSignal.timeout(8e3) });
348
+ const address = resolveDaemonAddress(env, home, config.http.port);
349
+ const response = await ports.fetcher(new URL("/v1/doctor", address), { signal: AbortSignal.timeout(8e3) });
339
350
  if (response.ok) remote = remoteChecks(await response.json());
340
351
  } catch {}
341
352
  checks.push(result("daemon.available", remote !== null, "Start or restart the user service."));
@@ -994,7 +1005,9 @@ function registerSetupCommands(program, options, io, env) {
994
1005
  const appName = manifest.opts()["appName"];
995
1006
  const command = manifest.opts()["command"];
996
1007
  if (appName !== void 0 && typeof appName !== "string" || command !== void 0 && typeof command !== "string") throw new CliError("usage");
1008
+ const defaults = appName === void 0 || command === void 0 ? loadSlackManifestDefaults(env, options.home ?? homedir()) : {};
997
1009
  io.stdout(renderSlackManifest({
1010
+ ...defaults,
998
1011
  ...appName === void 0 ? {} : { appName },
999
1012
  ...command === void 0 ? {} : { command }
1000
1013
  }));
@@ -1032,7 +1045,7 @@ function createProgram(options = {}) {
1032
1045
  const key = op.kind === "mutation" ? keyOption || randomUUID() : void 0;
1033
1046
  if (key) io.stderr(`key: ${escapeTerminalControls(key)}\n`);
1034
1047
  try {
1035
- const result = await requestOperation(op, input, key, config);
1048
+ const result = await (options.sendOperation ?? requestOperation)(op, input, key, config);
1036
1049
  io.stdout(`${json || !io.isTTY ? JSON.stringify(result) : formatHuman(result)}\n`);
1037
1050
  } catch (error) {
1038
1051
  if (error instanceof CliError) {
@@ -1,4 +1,4 @@
1
- import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-Cf78bzK5.mjs";
1
+ import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-VWyX7Nwm.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-sGJ4fzQ4.mjs";
1
+ import { n as requestTokenEnrollment, t as requestOperation } from "./client-BnkZmz1i.mjs";
2
2
  export { requestOperation, requestTokenEnrollment };
@@ -1,13 +1,32 @@
1
- import { r as CliError } from "./client-sGJ4fzQ4.mjs";
1
+ import { R as configSchema } from "./src-VWyX7Nwm.mjs";
2
+ import { r as CliError } from "./client-BnkZmz1i.mjs";
2
3
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
3
4
  import { homedir } from "node:os";
4
- import { dirname, isAbsolute, join, relative, sep } from "node:path";
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import YAML from "yaml";
5
7
  //#region src/config.ts
6
8
  const CONNECT_TIMEOUT_MS = 2e3;
7
9
  const RESPONSE_TIMEOUT_MS = 3e4;
8
- function daemonAddress(env) {
10
+ function configFile(env, home) {
11
+ const boardHome = env["BOARD_HOME"];
12
+ if (boardHome) return join(resolve(boardHome), "config.yaml");
13
+ const xdg = env["XDG_CONFIG_HOME"];
14
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "spur-board", "config.yaml");
15
+ }
16
+ function resolveDaemonAddress(env = process.env, home = homedir(), configuredPort) {
9
17
  const rawAddress = env["BOARD_DAEMON_URL"];
10
- if (!rawAddress) throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
18
+ if (rawAddress === void 0) {
19
+ let port = configuredPort ?? 4311;
20
+ if (configuredPort === void 0) try {
21
+ const parsed = YAML.parseDocument(readFileSync(configFile(env, home), "utf8"));
22
+ if (parsed.errors.length === 0) {
23
+ const config = configSchema.safeParse(parsed.toJS());
24
+ if (config.success) port = config.data.http.port;
25
+ }
26
+ } catch {}
27
+ if (port === 0) throw new CliError("config", void 0, void 0, "http.port: 0 assigns a runtime port that clients cannot discover. Set BOARD_DAEMON_URL to the daemon listening address.");
28
+ return new URL(`http://127.0.0.1:${String(port)}`);
29
+ }
11
30
  let address;
12
31
  try {
13
32
  address = new URL(rawAddress);
@@ -66,7 +85,7 @@ function loadClientConfig(env = process.env, paths = {
66
85
  cwd: process.cwd(),
67
86
  home: homedir()
68
87
  }) {
69
- const address = daemonAddress(env);
88
+ const address = resolveDaemonAddress(env, paths.home);
70
89
  const session = env["SPUR_SESSION"] || void 0;
71
90
  const token = session ? void 0 : env["BOARD_TOKEN"] === void 0 ? findTokenFile(paths.cwd, paths.home) : env["BOARD_TOKEN"];
72
91
  if (!session && !token) throw new CliError("config", { environmentVariable: "BOARD_TOKEN" });
@@ -81,7 +100,7 @@ function loadClientConfig(env = process.env, paths = {
81
100
  /** Enrollment sends only a hash and works before any operator credential exists. */
82
101
  function loadEnrollmentConfig(env = process.env) {
83
102
  return {
84
- address: daemonAddress(env),
103
+ address: resolveDaemonAddress(env),
85
104
  session: void 0,
86
105
  token: void 0,
87
106
  connectTimeoutMs: CONNECT_TIMEOUT_MS,
@@ -89,4 +108,4 @@ function loadEnrollmentConfig(env = process.env) {
89
108
  };
90
109
  }
91
110
  //#endregion
92
- export { loadEnrollmentConfig as n, loadClientConfig as t };
111
+ export { resolveDaemonAddress as i, loadClientConfig as n, loadEnrollmentConfig as r, configFile as t };
package/dist/daemon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as taskKeySchema, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, F as configSchema, I 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-Cf78bzK5.mjs";
1
+ import { A as taskKeySchema, 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 SLACK_BOT_SCOPES } from "./src-VWyX7Nwm.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.");
@@ -14896,7 +14904,7 @@ function createDeliveryGateway(client, channels) {
14896
14904
  function descriptors(commandName) {
14897
14905
  return OPERATIONS.filter((operation) => operation.kind === "query" && operation.slack?.kind === "slash-command").map((operation) => ({
14898
14906
  operation,
14899
- usage: operation.slack?.kind === "slash-command" ? operation.slack.usage.replace(/^\/board\b/, commandName) : ""
14907
+ usage: operation.slack?.kind === "slash-command" ? operation.slack.usage.replace(/^\/\S+/, commandName) : ""
14900
14908
  }));
14901
14909
  }
14902
14910
  function commandUsage(commandName) {
@@ -15071,7 +15079,7 @@ function normalizeMessage(channel, payload, context) {
15071
15079
  return events;
15072
15080
  }
15073
15081
  /** Actions and commands share the same stable trigger identity as their Slack retry. */
15074
- function normalizeInteraction(type, payload, channels, root, commandName = "/board") {
15082
+ function normalizeInteraction(type, payload, channels, root, commandName = DEFAULT_SLACK_COMMAND) {
15075
15083
  const body = record$1(payload);
15076
15084
  if (body === null) return [];
15077
15085
  const triggerId = string$1(body.trigger_id);
@@ -15080,16 +15088,18 @@ function normalizeInteraction(type, payload, channels, root, commandName = "/boa
15080
15088
  if (user === void 0) return [];
15081
15089
  const author = userIdSchema.parse(user);
15082
15090
  if (type === "slash_commands") {
15083
- if (body.command !== commandName) return [];
15091
+ const name = string$1(body.command);
15092
+ if (name === void 0) return [];
15084
15093
  const channel = string$1(body.channel_id);
15085
15094
  if (channel === void 0) return [];
15086
15095
  return [{
15087
15096
  kind: "command",
15097
+ name,
15088
15098
  dedupeKey: `slack:${triggerId}`,
15089
15099
  interaction: interactionRef(channel, user, triggerId),
15090
15100
  author,
15091
15101
  text: text(body.text) ?? "",
15092
- command: parseCommand(text(body.text) ?? "", commandName),
15102
+ ...name === commandName ? { command: parseCommand(text(body.text) ?? "", commandName) } : {},
15093
15103
  sourceUrl: sourceUrl(channel),
15094
15104
  sourceSpace: sourceSpace(channel, channels)
15095
15105
  }];
package/dist/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-Cf78bzK5.mjs";
2
- import { r as CliError, t as requestOperation } from "./client-sGJ4fzQ4.mjs";
3
- import { t as loadClientConfig } from "./config-DPXFxZQy.mjs";
1
+ import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-VWyX7Nwm.mjs";
2
+ import { r as CliError, t as requestOperation } from "./client-BnkZmz1i.mjs";
3
+ import { n as loadClientConfig } from "./config-UyXnaekw.mjs";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { z } from "zod";
@@ -110,6 +110,9 @@ const SLACK_BOT_SCOPES = [
110
110
  ];
111
111
  //#endregion
112
112
  //#region ../contracts/src/config.ts
113
+ const DEFAULT_HTTP_PORT = 4311;
114
+ const DEFAULT_SLACK_APP_NAME = "spur-board";
115
+ const DEFAULT_SLACK_COMMAND = "/board";
113
116
  const WEEKDAYS = [
114
117
  "monday",
115
118
  "tuesday",
@@ -246,7 +249,8 @@ const configSchema = z.strictObject({
246
249
  }),
247
250
  operator: z.strictObject({ slack: nonEmpty }),
248
251
  slack: z.strictObject({
249
- 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),
250
254
  appId: z.string().regex(/^A[A-Z0-9]+$/, "Expected a Slack app ID").optional(),
251
255
  channels: z.strictObject({
252
256
  orchestrators: nonEmpty.optional(),
@@ -276,7 +280,7 @@ const configSchema = z.strictObject({
276
280
  message: "Final unanswered pause must exceed the second retry pause"
277
281
  });
278
282
  }).prefault({}),
279
- http: z.strictObject({ port: z.int().min(0).max(65535).default(4311).describe("Local HTTP API port (0 assigns a free port).") }).prefault({}),
283
+ http: z.strictObject({ port: z.int().min(0).max(65535).default(DEFAULT_HTTP_PORT).describe("Local HTTP API port (0 assigns a free port).") }).prefault({}),
280
284
  adapters: z.strictObject({
281
285
  spur: spurAdapterSection,
282
286
  github: adapterSection(6e4),
@@ -1113,6 +1117,7 @@ const actionEventSchema = z.strictObject({
1113
1117
  /** `text` is the command's argument text. */
1114
1118
  const commandEventSchema = z.strictObject({
1115
1119
  kind: z.literal("command"),
1120
+ name: z.string().min(1).optional(),
1116
1121
  dedupeKey,
1117
1122
  interaction: interactionRefSchema,
1118
1123
  author: userIdSchema,
@@ -2630,4 +2635,4 @@ var AdapterError = class extends Error {
2630
2635
  }
2631
2636
  };
2632
2637
  //#endregion
2633
- export { taskKeySchema as A, sessionFactsSchema as C, errorCodeOf as D, encodeActorId as E, configSchema as F, SLACK_BOT_SCOPES as I, 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 };
2638
+ export { taskKeySchema as A, 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, SLACK_BOT_SCOPES as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spur-board",
3
- "version": "0.4.0",
3
+ "version": "0.5.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.