spur-board 0.7.0 → 0.8.1
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 +21 -4
- package/dist/{client-Ln76BdDz.mjs → client-D4cveOHQ.mjs} +67 -14
- package/dist/client.mjs +2 -2
- package/dist/config-B1fVOLzp.mjs +229 -0
- package/dist/daemon.mjs +265 -105
- package/dist/mcp.mjs +52 -4
- package/dist/{src-BFgTh55w.mjs → src-g7u1onIP.mjs} +28 -10
- package/package.json +1 -1
- package/dist/config-B_6fA4Au.mjs +0 -111
package/dist/mcp.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { j as ERRORS, s as OPERATIONS, u as renderMcpError } from "./src-g7u1onIP.mjs";
|
|
2
|
+
import { i as CliError, n as requestOperation } from "./client-D4cveOHQ.mjs";
|
|
3
|
+
import { a as memoryVerdictCache, r as compareCatalogs, s as loadClientConfig, t as checkCatalog } from "./config-B1fVOLzp.mjs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { z } from "zod";
|
|
@@ -79,6 +79,32 @@ function createMcpServer(env = process.env, loadConfig = loadClientConfig) {
|
|
|
79
79
|
version
|
|
80
80
|
});
|
|
81
81
|
const agent = Boolean(env["SPUR_SESSION"]);
|
|
82
|
+
const verdicts = memoryVerdictCache();
|
|
83
|
+
/** Addresses whose daemon served a different catalog; each call probes before sending. */
|
|
84
|
+
const mismatched = /* @__PURE__ */ new Map();
|
|
85
|
+
/** Refuses the call unless the daemon at a marked address now serves the client's catalog. */
|
|
86
|
+
const probe = async (config) => {
|
|
87
|
+
if (mismatched.get(config.address.href) === void 0) return;
|
|
88
|
+
const { clientVersion, serverVersion, matches, instance } = await compareCatalogs(config).catch((cause) => {
|
|
89
|
+
throw cause instanceof CliError && ERRORS[cause.code].recovery.sideEffects === "none" ? cause : new CliError("daemon_unavailable");
|
|
90
|
+
});
|
|
91
|
+
if (!matches) {
|
|
92
|
+
mismatched.set(config.address.href, {
|
|
93
|
+
clientVersion,
|
|
94
|
+
serverVersion
|
|
95
|
+
});
|
|
96
|
+
throw new CliError("version_mismatch", {
|
|
97
|
+
clientVersion,
|
|
98
|
+
serverVersion
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
mismatched.delete(config.address.href);
|
|
102
|
+
if (instance !== void 0) verdicts.remember(config.address, instance, {
|
|
103
|
+
clientVersion,
|
|
104
|
+
serverVersion,
|
|
105
|
+
matches: true
|
|
106
|
+
});
|
|
107
|
+
};
|
|
82
108
|
for (const op of OPERATIONS) server.registerTool(op.mcp.name, {
|
|
83
109
|
description: toolDescription(op),
|
|
84
110
|
inputSchema: fromJsonSchema(toolInputSchema(op, agent), handlerValidator),
|
|
@@ -93,7 +119,29 @@ function createMcpServer(env = process.env, loadConfig = loadClientConfig) {
|
|
|
93
119
|
const parsed = parseArguments(op, argumentsValue, agent);
|
|
94
120
|
key = parsed.key;
|
|
95
121
|
const config = loadConfig(env);
|
|
96
|
-
|
|
122
|
+
await probe(config);
|
|
123
|
+
let instance;
|
|
124
|
+
let outcome;
|
|
125
|
+
try {
|
|
126
|
+
outcome = { value: await requestOperation(op, parsed.input, key, config, (seen) => {
|
|
127
|
+
instance = seen;
|
|
128
|
+
}) };
|
|
129
|
+
} catch (error) {
|
|
130
|
+
outcome = { error };
|
|
131
|
+
}
|
|
132
|
+
if (instance !== void 0) {
|
|
133
|
+
const verdict = await checkCatalog(config, instance, verdicts);
|
|
134
|
+
if (verdict !== void 0 && !verdict.matches) {
|
|
135
|
+
const context = {
|
|
136
|
+
clientVersion: verdict.clientVersion,
|
|
137
|
+
serverVersion: verdict.serverVersion
|
|
138
|
+
};
|
|
139
|
+
mismatched.set(config.address.href, context);
|
|
140
|
+
if (op.kind === "query") throw new CliError("version_mismatch", context);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if ("error" in outcome) throw outcome.error;
|
|
144
|
+
const value = outcome.value;
|
|
97
145
|
return {
|
|
98
146
|
content: [{
|
|
99
147
|
type: "text",
|
|
@@ -113,6 +113,12 @@ const SLACK_BOT_SCOPES = [
|
|
|
113
113
|
const DEFAULT_HTTP_PORT = 4311;
|
|
114
114
|
const DEFAULT_SLACK_APP_NAME = "spur-board";
|
|
115
115
|
const DEFAULT_SLACK_COMMAND = "/board";
|
|
116
|
+
/**
|
|
117
|
+
* The journal project of board-wide events that belong to no configured project: token
|
|
118
|
+
* lifecycle, unknown Spur states and denials with no resolvable project. It sits outside the
|
|
119
|
+
* project id pattern, so no configured project can take it.
|
|
120
|
+
*/
|
|
121
|
+
const RESERVED_PROJECT = "_board";
|
|
116
122
|
const WEEKDAYS = [
|
|
117
123
|
"monday",
|
|
118
124
|
"tuesday",
|
|
@@ -195,7 +201,10 @@ function checkBoard(items, ctx) {
|
|
|
195
201
|
});
|
|
196
202
|
}
|
|
197
203
|
const projectSchema = z.strictObject({
|
|
198
|
-
id: z.string().
|
|
204
|
+
id: z.string().refine((id) => id !== RESERVED_PROJECT, {
|
|
205
|
+
message: `"${RESERVED_PROJECT}" is reserved for board-wide journal events; choose another project id`,
|
|
206
|
+
abort: true
|
|
207
|
+
}).regex(/^[a-z][a-z0-9-]*$/, "Expected lowercase letters, digits and dashes"),
|
|
199
208
|
tracker: z.discriminatedUnion("type", [jiraTracker, githubIssuesTracker]),
|
|
200
209
|
repos: z.array(repoSlugSchema).min(1),
|
|
201
210
|
operatorGithubLogin: z.string().regex(/^[A-Za-z0-9-]{1,39}$/, "Expected a GitHub login"),
|
|
@@ -522,7 +531,11 @@ const ERRORS = {
|
|
|
522
531
|
exit: EXIT_CODES.notApplied,
|
|
523
532
|
message: "The client and daemon catalog versions do not match; nothing was applied.",
|
|
524
533
|
hint: "Run `board service restart` to update the daemon, then retry.",
|
|
525
|
-
recovery: noRetry("Run `board service restart`, then retry the command.")
|
|
534
|
+
recovery: noRetry("Run `board service restart`, then retry the command."),
|
|
535
|
+
mcp: {
|
|
536
|
+
hint: "Restart your agent session so `board mcp` loads the daemon's catalog; if the mismatch remains, run `board service restart`.",
|
|
537
|
+
recovery: noRetry("Restart your agent session; if the mismatch remains, run `board service restart`. Then retry the tool call.")
|
|
538
|
+
}
|
|
526
539
|
}),
|
|
527
540
|
daemon_unavailable: spec("daemon_unavailable", {
|
|
528
541
|
status: null,
|
|
@@ -894,6 +907,7 @@ const EVENT_KINDS = [
|
|
|
894
907
|
"doorbell_read",
|
|
895
908
|
"doorbell_undelivered",
|
|
896
909
|
"inbound_failed",
|
|
910
|
+
"inbound_skipped",
|
|
897
911
|
"from_you_raised",
|
|
898
912
|
"from_you_cleared",
|
|
899
913
|
"flag_raised",
|
|
@@ -1034,6 +1048,10 @@ const eventPayloadSchemas = {
|
|
|
1034
1048
|
gateway: S,
|
|
1035
1049
|
reason: S
|
|
1036
1050
|
}),
|
|
1051
|
+
inbound_skipped: z.looseObject({
|
|
1052
|
+
gateway: S,
|
|
1053
|
+
reason: S
|
|
1054
|
+
}),
|
|
1037
1055
|
from_you_raised: z.looseObject({
|
|
1038
1056
|
item: S,
|
|
1039
1057
|
rule: S.optional()
|
|
@@ -1242,9 +1260,10 @@ const errorInputSchema = z.strictObject({
|
|
|
1242
1260
|
requestId: z.string().min(1),
|
|
1243
1261
|
context: errorContextSchema.optional()
|
|
1244
1262
|
});
|
|
1245
|
-
function fields(input) {
|
|
1263
|
+
function fields(input, transport = "default") {
|
|
1246
1264
|
const { code, requestId, context } = errorInputSchema.parse(input);
|
|
1247
1265
|
const spec = ERRORS[code];
|
|
1266
|
+
const guidance = (transport === "mcp" ? spec.mcp : void 0) ?? spec;
|
|
1248
1267
|
const trace = code === "unexpected" ? {
|
|
1249
1268
|
requestId,
|
|
1250
1269
|
logLocation: "daemon log"
|
|
@@ -1253,9 +1272,9 @@ function fields(input) {
|
|
|
1253
1272
|
code,
|
|
1254
1273
|
requestId,
|
|
1255
1274
|
message: spec.message,
|
|
1256
|
-
hint:
|
|
1275
|
+
hint: guidance.hint,
|
|
1257
1276
|
docs: spec.docs,
|
|
1258
|
-
recovery:
|
|
1277
|
+
recovery: guidance.recovery,
|
|
1259
1278
|
context: context ?? {},
|
|
1260
1279
|
...trace ? { trace } : {}
|
|
1261
1280
|
};
|
|
@@ -1275,12 +1294,12 @@ function renderHttpProblem(input) {
|
|
|
1275
1294
|
};
|
|
1276
1295
|
}
|
|
1277
1296
|
function renderMcpError(input) {
|
|
1278
|
-
const content = fields(input);
|
|
1297
|
+
const content = fields(input, "mcp");
|
|
1279
1298
|
return {
|
|
1280
1299
|
isError: true,
|
|
1281
1300
|
content: [{
|
|
1282
1301
|
type: "text",
|
|
1283
|
-
text:
|
|
1302
|
+
text: renderLines(content)
|
|
1284
1303
|
}],
|
|
1285
1304
|
structuredContent: content
|
|
1286
1305
|
};
|
|
@@ -1292,8 +1311,7 @@ function renderJsonError(input) {
|
|
|
1292
1311
|
error: fields(input)
|
|
1293
1312
|
};
|
|
1294
1313
|
}
|
|
1295
|
-
function
|
|
1296
|
-
const value = fields(input);
|
|
1314
|
+
function renderLines(value) {
|
|
1297
1315
|
const context = Object.entries(value.context);
|
|
1298
1316
|
return [
|
|
1299
1317
|
`error: ${value.code}: ${value.message}`,
|
|
@@ -2678,4 +2696,4 @@ var AdapterError = class extends Error {
|
|
|
2678
2696
|
}
|
|
2679
2697
|
};
|
|
2680
2698
|
//#endregion
|
|
2681
|
-
export { taskKeySchema as A,
|
|
2699
|
+
export { taskKeySchema as A, requiredSecretKeys 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, RESERVED_PROJECT as R, prRefSchema as S, actorIdSchema as T, SLACK_BOT_SCOPES as V, 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, configSchema as z };
|
package/package.json
CHANGED
package/dist/config-B_6fA4Au.mjs
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { R as configSchema } from "./src-BFgTh55w.mjs";
|
|
2
|
-
import { r as CliError } from "./client-Ln76BdDz.mjs";
|
|
3
|
-
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
-
import YAML from "yaml";
|
|
7
|
-
//#region src/config.ts
|
|
8
|
-
const CONNECT_TIMEOUT_MS = 2e3;
|
|
9
|
-
const RESPONSE_TIMEOUT_MS = 3e4;
|
|
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) {
|
|
17
|
-
const rawAddress = env["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
|
-
}
|
|
30
|
-
let address;
|
|
31
|
-
try {
|
|
32
|
-
address = new URL(rawAddress);
|
|
33
|
-
} catch {
|
|
34
|
-
throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
|
|
35
|
-
}
|
|
36
|
-
if (!["http:", "https:"].includes(address.protocol) || address.username || address.password) throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
|
|
37
|
-
return address;
|
|
38
|
-
}
|
|
39
|
-
function tokenInFolder(folder, warn) {
|
|
40
|
-
const directory = join(folder, ".spur-board");
|
|
41
|
-
const file = join(directory, "token");
|
|
42
|
-
try {
|
|
43
|
-
const owner = process.getuid?.();
|
|
44
|
-
const directoryInfo = lstatSync(directory);
|
|
45
|
-
if (!directoryInfo.isDirectory() || directoryInfo.uid !== owner || (directoryInfo.mode & 63) !== 0) throw new Error("unsafe token directory");
|
|
46
|
-
const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
47
|
-
let value;
|
|
48
|
-
try {
|
|
49
|
-
const fileInfo = fstatSync(descriptor);
|
|
50
|
-
if (!fileInfo.isFile() || fileInfo.uid !== owner || (fileInfo.mode & 63) !== 0) throw new Error("unsafe token file");
|
|
51
|
-
value = readFileSync(descriptor, "utf8").trimEnd();
|
|
52
|
-
} finally {
|
|
53
|
-
closeSync(descriptor);
|
|
54
|
-
}
|
|
55
|
-
if (value === "") throw new Error("empty token file");
|
|
56
|
-
return value;
|
|
57
|
-
} catch (error) {
|
|
58
|
-
if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
|
|
59
|
-
warn("Ignoring an unsafe .spur-board/token candidate.\n");
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
function findTokenFile(cwd, home, warn = (message) => {
|
|
64
|
-
process.stderr.write(message);
|
|
65
|
-
}) {
|
|
66
|
-
const current = realpathSync(cwd);
|
|
67
|
-
const boundary = realpathSync(home);
|
|
68
|
-
if (current === boundary) return void 0;
|
|
69
|
-
const toCurrent = relative(boundary, current);
|
|
70
|
-
const outsideHome = toCurrent === ".." || toCurrent.startsWith(`..${sep}`) || isAbsolute(toCurrent);
|
|
71
|
-
let folder = current;
|
|
72
|
-
while (folder !== boundary) {
|
|
73
|
-
if (outsideHome) {
|
|
74
|
-
const info = statSync(folder);
|
|
75
|
-
if (info.uid !== process.getuid?.() || (info.mode & 2) !== 0) break;
|
|
76
|
-
}
|
|
77
|
-
const token = tokenInFolder(folder, warn);
|
|
78
|
-
if (token !== void 0) return token;
|
|
79
|
-
const parent = dirname(folder);
|
|
80
|
-
if (parent === folder) break;
|
|
81
|
-
folder = parent;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
function loadClientConfig(env = process.env, paths = {
|
|
85
|
-
cwd: process.cwd(),
|
|
86
|
-
home: homedir()
|
|
87
|
-
}) {
|
|
88
|
-
const address = resolveDaemonAddress(env, paths.home);
|
|
89
|
-
const session = env["SPUR_SESSION"] || void 0;
|
|
90
|
-
const token = session ? void 0 : env["BOARD_TOKEN"] === void 0 ? findTokenFile(paths.cwd, paths.home) : env["BOARD_TOKEN"];
|
|
91
|
-
if (!session && !token) throw new CliError("config", { environmentVariable: "BOARD_TOKEN" });
|
|
92
|
-
return {
|
|
93
|
-
address,
|
|
94
|
-
session,
|
|
95
|
-
token,
|
|
96
|
-
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
97
|
-
responseTimeoutMs: RESPONSE_TIMEOUT_MS
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
/** Enrollment sends only a hash and works before any operator credential exists. */
|
|
101
|
-
function loadEnrollmentConfig(env = process.env) {
|
|
102
|
-
return {
|
|
103
|
-
address: resolveDaemonAddress(env),
|
|
104
|
-
session: void 0,
|
|
105
|
-
token: void 0,
|
|
106
|
-
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
107
|
-
responseTimeoutMs: RESPONSE_TIMEOUT_MS
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
//#endregion
|
|
111
|
-
export { resolveDaemonAddress as i, loadClientConfig as n, loadEnrollmentConfig as r, configFile as t };
|