spur-board 0.7.1 → 0.8.2
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 +137 -73
- 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/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { B as SLACK_BOT_SCOPES,
|
|
2
|
-
import { a as
|
|
3
|
-
import { i as resolveDaemonAddress, n as
|
|
1
|
+
import { B as requiredSecretKeys, V as SLACK_BOT_SCOPES, j as ERRORS, s as OPERATIONS, z as configSchema } from "./src-g7u1onIP.mjs";
|
|
2
|
+
import { a as errorExitCode, i as CliError, n as requestOperation, o as renderCliError, r as requestTokenEnrollment, s as escapeTerminalControls } from "./client-D4cveOHQ.mjs";
|
|
3
|
+
import { a as memoryVerdictCache, c as loadEnrollmentConfig, i as fileVerdictCache, l as resolveDaemonAddress, n as cliPackageVersion, o as configFile, s as loadClientConfig, t as checkCatalog } from "./config-B1fVOLzp.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";
|
|
@@ -1082,8 +1082,25 @@ function createProgram(options = {}) {
|
|
|
1082
1082
|
const config = options.clientConfig ?? loadClientConfig(env);
|
|
1083
1083
|
const key = op.kind === "mutation" ? keyOption || randomUUID() : void 0;
|
|
1084
1084
|
if (key) io.stderr(`key: ${escapeTerminalControls(key)}\n`);
|
|
1085
|
+
let instance;
|
|
1086
|
+
let outcome;
|
|
1085
1087
|
try {
|
|
1086
|
-
|
|
1088
|
+
outcome = { value: await (options.sendOperation ?? requestOperation)(op, input, key, config, (seen) => {
|
|
1089
|
+
instance = seen;
|
|
1090
|
+
}) };
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
outcome = { error };
|
|
1093
|
+
}
|
|
1094
|
+
const reportedMismatch = "error" in outcome && outcome.error instanceof CliError && outcome.error.code === "version_mismatch";
|
|
1095
|
+
if (instance !== void 0 && !reportedMismatch) {
|
|
1096
|
+
const cliVersion = cliPackageVersion();
|
|
1097
|
+
const cache = cliVersion === void 0 ? memoryVerdictCache() : fileVerdictCache(join(dirname(configFile(env, options.home ?? homedir())), "version-check.json"), cliVersion);
|
|
1098
|
+
const verdict = await checkCatalog(config, instance, cache);
|
|
1099
|
+
if (verdict !== void 0 && !verdict.matches) io.stderr(`warning: the daemon catalog ${verdict.serverVersion.slice(0, 12)} differs from this CLI's ${verdict.clientVersion.slice(0, 12)}. ${ERRORS.version_mismatch.hint}\n`);
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
if ("error" in outcome) throw outcome.error;
|
|
1103
|
+
const result = outcome.value;
|
|
1087
1104
|
io.stdout(`${json || !io.isTTY ? JSON.stringify(result) : formatHuman(result)}\n`);
|
|
1088
1105
|
} catch (error) {
|
|
1089
1106
|
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-
|
|
1
|
+
import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-g7u1onIP.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { request } from "node:http";
|
|
@@ -84,9 +84,15 @@ function decodeResponse(output, response, raw) {
|
|
|
84
84
|
}
|
|
85
85
|
throw problemError(response.statusCode ?? 0, response.headers["content-type"], body);
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
const instanceHeader = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
88
|
+
/** The daemon instance a response came from, when it sends a well-formed one. */
|
|
89
|
+
function responseInstance(response) {
|
|
90
|
+
const value = response.headers["x-board-instance"];
|
|
91
|
+
return typeof value === "string" && instanceHeader.test(value) ? value : void 0;
|
|
92
|
+
}
|
|
93
|
+
function send({ method, path, body, config, anonymous, decode, observe }) {
|
|
88
94
|
const target = new URL(path, config.address);
|
|
89
|
-
const payload = JSON.stringify(body);
|
|
95
|
+
const payload = method === "POST" ? JSON.stringify(body) : void 0;
|
|
90
96
|
const transport = target.protocol === "https:" ? request$1 : request;
|
|
91
97
|
return new Promise((resolve, reject) => {
|
|
92
98
|
let connected = false;
|
|
@@ -100,15 +106,19 @@ async function post(path, body, config, output, anonymous) {
|
|
|
100
106
|
else resolve(result.value);
|
|
101
107
|
};
|
|
102
108
|
const req = transport(target, {
|
|
103
|
-
method
|
|
109
|
+
method,
|
|
104
110
|
agent: false,
|
|
105
111
|
headers: {
|
|
106
|
-
"content-type": "application/json",
|
|
107
112
|
accept: "application/json, application/problem+json",
|
|
108
|
-
|
|
113
|
+
...payload === void 0 ? {} : {
|
|
114
|
+
"content-type": "application/json",
|
|
115
|
+
"content-length": Buffer.byteLength(payload)
|
|
116
|
+
},
|
|
109
117
|
...anonymous ? {} : config.session ? { "x-board-session": config.session } : { authorization: `Bearer ${config.token ?? ""}` }
|
|
110
118
|
}
|
|
111
119
|
}, (response) => {
|
|
120
|
+
const instance = responseInstance(response);
|
|
121
|
+
if (instance !== void 0) observe?.(instance);
|
|
112
122
|
const chunks = [];
|
|
113
123
|
response.on("data", (chunk) => {
|
|
114
124
|
chunks.push(chunk);
|
|
@@ -118,7 +128,7 @@ async function post(path, body, config, output, anonymous) {
|
|
|
118
128
|
});
|
|
119
129
|
response.once("end", () => {
|
|
120
130
|
try {
|
|
121
|
-
settle({ value:
|
|
131
|
+
settle({ value: decode(response, Buffer.concat(chunks).toString()) });
|
|
122
132
|
} catch (error) {
|
|
123
133
|
settle({ error: error instanceof CliError ? error : new CliError("outcome_unknown") });
|
|
124
134
|
}
|
|
@@ -142,12 +152,48 @@ async function post(path, body, config, output, anonymous) {
|
|
|
142
152
|
});
|
|
143
153
|
}
|
|
144
154
|
/** Shared daemon HTTP client. The CLI owns output; this module owns transport and wire parsing. */
|
|
145
|
-
async function requestOperation(op, input, key, config) {
|
|
155
|
+
async function requestOperation(op, input, key, config, observe) {
|
|
146
156
|
if (!config.session && !config.token) throw new CliError("config");
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
157
|
+
return send({
|
|
158
|
+
method: "POST",
|
|
159
|
+
path: `/v1/ops/${encodeURIComponent(op.id)}`,
|
|
160
|
+
body: {
|
|
161
|
+
input,
|
|
162
|
+
...key ? { key } : {}
|
|
163
|
+
},
|
|
164
|
+
config,
|
|
165
|
+
anonymous: false,
|
|
166
|
+
decode: (response, raw) => decodeResponse(op.output, response, raw),
|
|
167
|
+
observe
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const schemaVersion = z.looseObject({ version: z.string().regex(/^[0-9a-f]{64}$/) });
|
|
171
|
+
/**
|
|
172
|
+
* The daemon's catalog version and instance. `/v1/schema` answers with the bare snapshot, not a
|
|
173
|
+
* success envelope.
|
|
174
|
+
*/
|
|
175
|
+
function fetchCatalogVersion(config) {
|
|
176
|
+
return send({
|
|
177
|
+
method: "GET",
|
|
178
|
+
path: "/v1/schema",
|
|
179
|
+
config,
|
|
180
|
+
anonymous: true,
|
|
181
|
+
decode: (response, raw) => {
|
|
182
|
+
let body;
|
|
183
|
+
try {
|
|
184
|
+
body = JSON.parse(raw);
|
|
185
|
+
} catch {
|
|
186
|
+
throw new CliError("outcome_unknown");
|
|
187
|
+
}
|
|
188
|
+
if (response.statusCode !== 200) throw problemError(response.statusCode ?? 0, response.headers["content-type"], body);
|
|
189
|
+
const parsed = schemaVersion.safeParse(body);
|
|
190
|
+
if (!parsed.success) throw new CliError("outcome_unknown");
|
|
191
|
+
return {
|
|
192
|
+
version: parsed.data.version,
|
|
193
|
+
instance: responseInstance(response)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
});
|
|
151
197
|
}
|
|
152
198
|
const enrollmentOutput = z.strictObject({
|
|
153
199
|
name: z.string(),
|
|
@@ -156,7 +202,14 @@ const enrollmentOutput = z.strictObject({
|
|
|
156
202
|
});
|
|
157
203
|
/** Bootstrap sends a hash and no authorization header. */
|
|
158
204
|
function requestTokenEnrollment(input, config) {
|
|
159
|
-
return
|
|
205
|
+
return send({
|
|
206
|
+
method: "POST",
|
|
207
|
+
path: "/v1/tokens/requests",
|
|
208
|
+
body: input,
|
|
209
|
+
config,
|
|
210
|
+
anonymous: true,
|
|
211
|
+
decode: (response, raw) => decodeResponse(enrollmentOutput, response, raw)
|
|
212
|
+
});
|
|
160
213
|
}
|
|
161
214
|
//#endregion
|
|
162
|
-
export {
|
|
215
|
+
export { errorExitCode as a, CliError as i, requestOperation as n, renderCliError as o, requestTokenEnrollment as r, escapeTerminalControls as s, fetchCatalogVersion as t };
|
package/dist/client.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as requestTokenEnrollment, t as
|
|
2
|
-
export { requestOperation, requestTokenEnrollment };
|
|
1
|
+
import { n as requestOperation, r as requestTokenEnrollment, t as fetchCatalogVersion } from "./client-D4cveOHQ.mjs";
|
|
2
|
+
export { fetchCatalogVersion, requestOperation, requestTokenEnrollment };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { a as buildCatalogSnapshot, s as OPERATIONS, z as configSchema } from "./src-g7u1onIP.mjs";
|
|
2
|
+
import { i as CliError, t as fetchCatalogVersion } from "./client-D4cveOHQ.mjs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import YAML from "yaml";
|
|
9
|
+
//#region src/config.ts
|
|
10
|
+
const CONNECT_TIMEOUT_MS = 2e3;
|
|
11
|
+
const RESPONSE_TIMEOUT_MS = 3e4;
|
|
12
|
+
function configFile(env, home) {
|
|
13
|
+
const boardHome = env["BOARD_HOME"];
|
|
14
|
+
if (boardHome) return join(resolve(boardHome), "config.yaml");
|
|
15
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
16
|
+
return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "spur-board", "config.yaml");
|
|
17
|
+
}
|
|
18
|
+
function resolveDaemonAddress(env = process.env, home = homedir(), configuredPort) {
|
|
19
|
+
const rawAddress = env["BOARD_DAEMON_URL"];
|
|
20
|
+
if (rawAddress === void 0) {
|
|
21
|
+
let port = configuredPort ?? 4311;
|
|
22
|
+
if (configuredPort === void 0) try {
|
|
23
|
+
const parsed = YAML.parseDocument(readFileSync(configFile(env, home), "utf8"));
|
|
24
|
+
if (parsed.errors.length === 0) {
|
|
25
|
+
const config = configSchema.safeParse(parsed.toJS());
|
|
26
|
+
if (config.success) port = config.data.http.port;
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
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.");
|
|
30
|
+
return new URL(`http://127.0.0.1:${String(port)}`);
|
|
31
|
+
}
|
|
32
|
+
let address;
|
|
33
|
+
try {
|
|
34
|
+
address = new URL(rawAddress);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
|
|
37
|
+
}
|
|
38
|
+
if (!["http:", "https:"].includes(address.protocol) || address.username || address.password) throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
|
|
39
|
+
return address;
|
|
40
|
+
}
|
|
41
|
+
function tokenInFolder(folder, warn) {
|
|
42
|
+
const directory = join(folder, ".spur-board");
|
|
43
|
+
const file = join(directory, "token");
|
|
44
|
+
try {
|
|
45
|
+
const owner = process.getuid?.();
|
|
46
|
+
const directoryInfo = lstatSync(directory);
|
|
47
|
+
if (!directoryInfo.isDirectory() || directoryInfo.uid !== owner || (directoryInfo.mode & 63) !== 0) throw new Error("unsafe token directory");
|
|
48
|
+
const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
49
|
+
let value;
|
|
50
|
+
try {
|
|
51
|
+
const fileInfo = fstatSync(descriptor);
|
|
52
|
+
if (!fileInfo.isFile() || fileInfo.uid !== owner || (fileInfo.mode & 63) !== 0) throw new Error("unsafe token file");
|
|
53
|
+
value = readFileSync(descriptor, "utf8").trimEnd();
|
|
54
|
+
} finally {
|
|
55
|
+
closeSync(descriptor);
|
|
56
|
+
}
|
|
57
|
+
if (value === "") throw new Error("empty token file");
|
|
58
|
+
return value;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
|
|
61
|
+
warn("Ignoring an unsafe .spur-board/token candidate.\n");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function findTokenFile(cwd, home, warn = (message) => {
|
|
66
|
+
process.stderr.write(message);
|
|
67
|
+
}) {
|
|
68
|
+
const current = realpathSync(cwd);
|
|
69
|
+
const boundary = realpathSync(home);
|
|
70
|
+
if (current === boundary) return void 0;
|
|
71
|
+
const toCurrent = relative(boundary, current);
|
|
72
|
+
const outsideHome = toCurrent === ".." || toCurrent.startsWith(`..${sep}`) || isAbsolute(toCurrent);
|
|
73
|
+
let folder = current;
|
|
74
|
+
while (folder !== boundary) {
|
|
75
|
+
if (outsideHome) {
|
|
76
|
+
const info = statSync(folder);
|
|
77
|
+
if (info.uid !== process.getuid?.() || (info.mode & 2) !== 0) break;
|
|
78
|
+
}
|
|
79
|
+
const token = tokenInFolder(folder, warn);
|
|
80
|
+
if (token !== void 0) return token;
|
|
81
|
+
const parent = dirname(folder);
|
|
82
|
+
if (parent === folder) break;
|
|
83
|
+
folder = parent;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function loadClientConfig(env = process.env, paths = {
|
|
87
|
+
cwd: process.cwd(),
|
|
88
|
+
home: homedir()
|
|
89
|
+
}) {
|
|
90
|
+
const address = resolveDaemonAddress(env, paths.home);
|
|
91
|
+
const session = env["SPUR_SESSION"] || void 0;
|
|
92
|
+
const token = session ? void 0 : env["BOARD_TOKEN"] === void 0 ? findTokenFile(paths.cwd, paths.home) : env["BOARD_TOKEN"];
|
|
93
|
+
if (!session && !token) throw new CliError("config", { environmentVariable: "BOARD_TOKEN" });
|
|
94
|
+
return {
|
|
95
|
+
address,
|
|
96
|
+
session,
|
|
97
|
+
token,
|
|
98
|
+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
99
|
+
responseTimeoutMs: RESPONSE_TIMEOUT_MS
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Enrollment sends only a hash and works before any operator credential exists. */
|
|
103
|
+
function loadEnrollmentConfig(env = process.env) {
|
|
104
|
+
return {
|
|
105
|
+
address: resolveDaemonAddress(env),
|
|
106
|
+
session: void 0,
|
|
107
|
+
token: void 0,
|
|
108
|
+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
109
|
+
responseTimeoutMs: RESPONSE_TIMEOUT_MS
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/version.ts
|
|
114
|
+
let clientVersion;
|
|
115
|
+
/** The catalog version this client was built with; the snapshot is built once per process. */
|
|
116
|
+
function clientCatalogVersion() {
|
|
117
|
+
if (clientVersion === void 0) {
|
|
118
|
+
const pending = buildCatalogSnapshot(OPERATIONS).then((snapshot) => snapshot.version);
|
|
119
|
+
clientVersion = pending;
|
|
120
|
+
pending.catch(() => {
|
|
121
|
+
if (clientVersion === pending) clientVersion = void 0;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return clientVersion;
|
|
125
|
+
}
|
|
126
|
+
/** The CLI package version, or `undefined` when its manifest cannot be read. */
|
|
127
|
+
function cliPackageVersion() {
|
|
128
|
+
try {
|
|
129
|
+
const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))?.version;
|
|
130
|
+
return typeof version === "string" && version ? version : void 0;
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Compares the client catalog with the daemon's; rejects when the daemon cannot answer. */
|
|
136
|
+
async function compareCatalogs(config) {
|
|
137
|
+
const [clientVersion, served] = await Promise.all([clientCatalogVersion(), fetchCatalogVersion(config)]);
|
|
138
|
+
return {
|
|
139
|
+
clientVersion,
|
|
140
|
+
serverVersion: served.version,
|
|
141
|
+
matches: clientVersion === served.version,
|
|
142
|
+
instance: served.instance
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Compares the client catalog with the daemon instance's catalog once per cache entry. Any
|
|
147
|
+
* failure returns `undefined`, so the caller carries on unchanged.
|
|
148
|
+
*/
|
|
149
|
+
function checkCatalog(config, instance, cache) {
|
|
150
|
+
return cache.check(config.address, instance, () => compareCatalogs(config).catch(() => void 0));
|
|
151
|
+
}
|
|
152
|
+
/** In-process cache that shares an in-flight check between overlapping callers. */
|
|
153
|
+
function memoryVerdictCache() {
|
|
154
|
+
const entries = /* @__PURE__ */ new Map();
|
|
155
|
+
const keyOf = (address, instance) => `${address.href} ${instance}`;
|
|
156
|
+
return {
|
|
157
|
+
check(address, instance, run) {
|
|
158
|
+
const key = keyOf(address, instance);
|
|
159
|
+
const held = entries.get(key);
|
|
160
|
+
if (held !== void 0) return held;
|
|
161
|
+
const pending = run();
|
|
162
|
+
entries.set(key, pending);
|
|
163
|
+
pending.then((verdict) => {
|
|
164
|
+
if (verdict === void 0 && entries.get(key) === pending) entries.delete(key);
|
|
165
|
+
});
|
|
166
|
+
return pending;
|
|
167
|
+
},
|
|
168
|
+
remember(address, instance, verdict) {
|
|
169
|
+
entries.set(keyOf(address, instance), Promise.resolve(verdict));
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const fileEntrySchema = z.object({
|
|
174
|
+
cliPackageVersion: z.string(),
|
|
175
|
+
address: z.string(),
|
|
176
|
+
instance: z.string(),
|
|
177
|
+
clientVersion: z.string(),
|
|
178
|
+
serverVersion: z.string()
|
|
179
|
+
});
|
|
180
|
+
const fileSchema = z.object({ entries: z.array(fileEntrySchema) });
|
|
181
|
+
function readEntries(path) {
|
|
182
|
+
try {
|
|
183
|
+
const parsed = fileSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
184
|
+
return parsed.success ? parsed.data.entries : [];
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function writeEntries(path, entries) {
|
|
190
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(dirname(path), {
|
|
193
|
+
recursive: true,
|
|
194
|
+
mode: 448
|
|
195
|
+
});
|
|
196
|
+
writeFileSync(temporary, `${JSON.stringify({ entries })}\n`, { mode: 384 });
|
|
197
|
+
renameSync(temporary, path);
|
|
198
|
+
} catch {
|
|
199
|
+
try {
|
|
200
|
+
unlinkSync(temporary);
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Verdicts kept across CLI invocations, keyed by CLI package version, address and instance. */
|
|
205
|
+
function fileVerdictCache(path, cliVersion) {
|
|
206
|
+
return { async check(address, instance, run) {
|
|
207
|
+
const matchesKey = (entry) => entry.cliPackageVersion === cliVersion && entry.address === address.href && entry.instance === instance;
|
|
208
|
+
const held = readEntries(path).find(matchesKey);
|
|
209
|
+
if (held !== void 0) return {
|
|
210
|
+
clientVersion: held.clientVersion,
|
|
211
|
+
serverVersion: held.serverVersion,
|
|
212
|
+
matches: held.clientVersion === held.serverVersion
|
|
213
|
+
};
|
|
214
|
+
const verdict = await run();
|
|
215
|
+
if (verdict !== void 0) {
|
|
216
|
+
const entry = {
|
|
217
|
+
cliPackageVersion: cliVersion,
|
|
218
|
+
address: address.href,
|
|
219
|
+
instance,
|
|
220
|
+
clientVersion: verdict.clientVersion,
|
|
221
|
+
serverVersion: verdict.serverVersion
|
|
222
|
+
};
|
|
223
|
+
writeEntries(path, [...readEntries(path).filter((item) => !matchesKey(item)), entry].slice(-16));
|
|
224
|
+
}
|
|
225
|
+
return verdict;
|
|
226
|
+
} };
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
export { memoryVerdictCache as a, loadEnrollmentConfig as c, fileVerdictCache as i, resolveDaemonAddress as l, cliPackageVersion as n, configFile as o, compareCatalogs as r, loadClientConfig as s, checkCatalog as t };
|
package/dist/daemon.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as taskKeySchema, B as
|
|
1
|
+
import { A as taskKeySchema, B as requiredSecretKeys, C as sessionFactsSchema, D as errorCodeOf, E as encodeActorId, L as DEFAULT_SLACK_COMMAND, O as parseActorId, P as successEnvelopeSchema, R as RESERVED_PROJECT, S as prRefSchema, T as actorIdSchema, V as SLACK_BOT_SCOPES, _ 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 configSchema } from "./src-g7u1onIP.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";
|
|
@@ -7661,18 +7661,92 @@ async function scanTransaction(store, fn) {
|
|
|
7661
7661
|
throw new Error("Gateway secret scan replay limit exceeded");
|
|
7662
7662
|
}
|
|
7663
7663
|
//#endregion
|
|
7664
|
+
//#region ../core/src/app/board-utils.ts
|
|
7665
|
+
function trackerSource(project) {
|
|
7666
|
+
return project.tracker.type === "jira" ? `jira:${project.id}` : `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
|
|
7667
|
+
}
|
|
7668
|
+
function projectForKey(config, key) {
|
|
7669
|
+
const lowerKey = key.toLowerCase();
|
|
7670
|
+
const matched = config.projects.filter((project) => {
|
|
7671
|
+
const tracker = project.tracker;
|
|
7672
|
+
if (tracker.type === "github-issues") {
|
|
7673
|
+
const prefix = `${tracker.repo.toLowerCase()}#`;
|
|
7674
|
+
const short = tracker.repo.split("/")[1];
|
|
7675
|
+
return lowerKey.startsWith(prefix) || short !== void 0 && lowerKey.startsWith(`${short.toLowerCase()}#`);
|
|
7676
|
+
}
|
|
7677
|
+
const prefix = /^([A-Za-z][A-Za-z0-9_]*)-/.exec(key)?.[1]?.toUpperCase();
|
|
7678
|
+
return prefix !== void 0 && (tracker.projectKeys.length === 0 || tracker.projectKeys.includes(prefix));
|
|
7679
|
+
});
|
|
7680
|
+
return matched.length === 1 ? matched[0] ?? null : null;
|
|
7681
|
+
}
|
|
7682
|
+
async function emit(trx, kind, project, taskId, at, payload, actor = "system") {
|
|
7683
|
+
const parsed = parseActorId(actor);
|
|
7684
|
+
if (parsed === null) throw new Error("Invalid journal actor");
|
|
7685
|
+
await trx.insertInto("event").values({
|
|
7686
|
+
at,
|
|
7687
|
+
project,
|
|
7688
|
+
task_id: taskId,
|
|
7689
|
+
kind,
|
|
7690
|
+
actor: parsed.actor,
|
|
7691
|
+
actor_ref: parsed.actor === "agent" ? JSON.stringify(parsed.actorRef) : null,
|
|
7692
|
+
payload: toJson(eventPayloadSchemas[kind], payload),
|
|
7693
|
+
v: 1,
|
|
7694
|
+
request_id: null
|
|
7695
|
+
}).execute();
|
|
7696
|
+
}
|
|
7697
|
+
function taskView(task) {
|
|
7698
|
+
return {
|
|
7699
|
+
key: task.key,
|
|
7700
|
+
project: task.project,
|
|
7701
|
+
column: task.column,
|
|
7702
|
+
membership: task.membership
|
|
7703
|
+
};
|
|
7704
|
+
}
|
|
7705
|
+
function linkView(link) {
|
|
7706
|
+
return {
|
|
7707
|
+
kind: link.entity,
|
|
7708
|
+
ref: link.entity === "pr" ? `${link.repo ?? ""}#${String(link.number ?? "")}` : link.entity === "session" ? link.session_id ?? "" : link.ticket_key ?? "",
|
|
7709
|
+
role: link.role
|
|
7710
|
+
};
|
|
7711
|
+
}
|
|
7712
|
+
//#endregion
|
|
7664
7713
|
//#region ../core/src/app/denied-event.ts
|
|
7665
7714
|
const actorRefSchema$2 = z.strictObject({
|
|
7666
7715
|
project: z.string().min(1),
|
|
7667
7716
|
session: z.string().min(1)
|
|
7668
7717
|
});
|
|
7718
|
+
/**
|
|
7719
|
+
* The journal project of a denial with no resolved target, in order: the configured project
|
|
7720
|
+
* the input's `task` key or `project` names; else the only configured project listing the agent
|
|
7721
|
+
* actor's Spur project; else `RESERVED_PROJECT`. Never throws.
|
|
7722
|
+
*/
|
|
7723
|
+
function deniedProject(config, actor, input) {
|
|
7724
|
+
if (typeof input === "object" && input !== null) {
|
|
7725
|
+
const hints = input;
|
|
7726
|
+
if (typeof hints.task === "string") {
|
|
7727
|
+
const project = projectForKey(config, hints.task);
|
|
7728
|
+
if (project !== null) return project.id;
|
|
7729
|
+
}
|
|
7730
|
+
if (typeof hints.project === "string") {
|
|
7731
|
+
const named = hints.project;
|
|
7732
|
+
if (config.projects.some((project) => project.id === named)) return named;
|
|
7733
|
+
}
|
|
7734
|
+
}
|
|
7735
|
+
const parsed = parseActorId(actor);
|
|
7736
|
+
if (parsed?.actor === "agent") {
|
|
7737
|
+
const owners = config.projects.filter((project) => project.spurProjects.some((spur) => spur.name === parsed.actorRef.project));
|
|
7738
|
+
if (owners.length === 1 && owners[0] !== void 0) return owners[0].id;
|
|
7739
|
+
}
|
|
7740
|
+
return RESERVED_PROJECT;
|
|
7741
|
+
}
|
|
7669
7742
|
/** Persist an authorization refusal through the caller's transaction. */
|
|
7670
|
-
async function writeDeniedEvent(trx,
|
|
7743
|
+
async function writeDeniedEvent(trx, denial) {
|
|
7744
|
+
const { config, actor, operation, input, target, at } = denial;
|
|
7671
7745
|
const parsed = parseActorId(actor);
|
|
7672
7746
|
if (parsed === null) throw new Error("Invalid actor");
|
|
7673
7747
|
await trx.insertInto("event").values({
|
|
7674
7748
|
at,
|
|
7675
|
-
project: target.kind === "found" ? target.value.project :
|
|
7749
|
+
project: target.kind === "found" ? target.value.project : deniedProject(config, actor, input),
|
|
7676
7750
|
task_id: target.kind === "found" ? target.value.task?.id ?? null : null,
|
|
7677
7751
|
kind: "operation_denied",
|
|
7678
7752
|
actor: parsed.actor,
|
|
@@ -7787,7 +7861,14 @@ function createApply(deps) {
|
|
|
7787
7861
|
...mode === void 0 ? {} : { mode }
|
|
7788
7862
|
});
|
|
7789
7863
|
if (decision.status === "denied") {
|
|
7790
|
-
await writeDeniedEvent(trx,
|
|
7864
|
+
await writeDeniedEvent(trx, {
|
|
7865
|
+
config,
|
|
7866
|
+
actor,
|
|
7867
|
+
operation: entry.id,
|
|
7868
|
+
input: parsedInput,
|
|
7869
|
+
target,
|
|
7870
|
+
at
|
|
7871
|
+
});
|
|
7791
7872
|
deniedHint = decision.hint;
|
|
7792
7873
|
return;
|
|
7793
7874
|
}
|
|
@@ -7849,7 +7930,14 @@ function createQuery(deps) {
|
|
|
7849
7930
|
target
|
|
7850
7931
|
});
|
|
7851
7932
|
if (decision.status === "denied") {
|
|
7852
|
-
await writeDeniedEvent(trx,
|
|
7933
|
+
await writeDeniedEvent(trx, {
|
|
7934
|
+
config,
|
|
7935
|
+
actor,
|
|
7936
|
+
operation: entry.id,
|
|
7937
|
+
input: parsedInput,
|
|
7938
|
+
target,
|
|
7939
|
+
at: new Date(clock.now()).toISOString()
|
|
7940
|
+
});
|
|
7853
7941
|
return {
|
|
7854
7942
|
status: "denied",
|
|
7855
7943
|
hint: decision.hint
|
|
@@ -8490,11 +8578,13 @@ const NO_FEED = /* @__PURE__ */ new Set([
|
|
|
8490
8578
|
"answer_undelivered",
|
|
8491
8579
|
"transport_health"
|
|
8492
8580
|
]);
|
|
8581
|
+
/** Display label of a feed line with no task; not part of the line's identity. */
|
|
8582
|
+
const TASKLESS_FEED_KEY = "spur-board";
|
|
8493
8583
|
async function journal$2(trx, item, kind, at, rule) {
|
|
8494
8584
|
const task = item.task_id === null ? void 0 : await trx.selectFrom("task").select(["project"]).where("id", "=", item.task_id).executeTakeFirstOrThrow();
|
|
8495
8585
|
await trx.insertInto("event").values({
|
|
8496
8586
|
at: at.toISOString(),
|
|
8497
|
-
project: task?.project ?? "
|
|
8587
|
+
project: task?.project ?? "_board",
|
|
8498
8588
|
task_id: item.task_id,
|
|
8499
8589
|
kind,
|
|
8500
8590
|
actor: "system",
|
|
@@ -8531,7 +8621,7 @@ async function raiseItem(trx, input) {
|
|
|
8531
8621
|
}).returningAll().executeTakeFirstOrThrow();
|
|
8532
8622
|
await journal$2(trx, item, "from_you_raised", input.at, input.rule);
|
|
8533
8623
|
if (!NO_FEED.has(item.kind) && !input.suppressFeed) {
|
|
8534
|
-
const key = (item.task_id === null ? null : await trx.selectFrom("task").select("key").where("id", "=", item.task_id).executeTakeFirstOrThrow())?.key ??
|
|
8624
|
+
const key = (item.task_id === null ? null : await trx.selectFrom("task").select("key").where("id", "=", item.task_id).executeTakeFirstOrThrow())?.key ?? TASKLESS_FEED_KEY;
|
|
8535
8625
|
const line = await enqueueFeedLine(trx, {
|
|
8536
8626
|
itemId: item.id,
|
|
8537
8627
|
itemKey: item.item_key,
|
|
@@ -8580,7 +8670,7 @@ async function clearItem(trx, itemId, at) {
|
|
|
8580
8670
|
itemKey: item.item_key,
|
|
8581
8671
|
raise: item.raise_no,
|
|
8582
8672
|
lineId: item.line_outbox_id,
|
|
8583
|
-
key: task?.key ??
|
|
8673
|
+
key: task?.key ?? TASKLESS_FEED_KEY,
|
|
8584
8674
|
taskId: item.task_id,
|
|
8585
8675
|
at
|
|
8586
8676
|
});
|
|
@@ -8595,7 +8685,7 @@ function effectiveStatus(row, at) {
|
|
|
8595
8685
|
async function journalToken(trx, kind, name, at) {
|
|
8596
8686
|
await trx.insertInto("event").values({
|
|
8597
8687
|
at: at.toISOString(),
|
|
8598
|
-
project:
|
|
8688
|
+
project: RESERVED_PROJECT,
|
|
8599
8689
|
task_id: null,
|
|
8600
8690
|
kind,
|
|
8601
8691
|
actor: kind === "token_approved" || kind === "token_denied" || kind === "token_revoked" ? "operator" : "system",
|
|
@@ -8821,12 +8911,15 @@ async function createApiServer(options) {
|
|
|
8821
8911
|
trustProxy: false
|
|
8822
8912
|
});
|
|
8823
8913
|
const snapshot = await buildCatalogSnapshot(OPERATIONS);
|
|
8914
|
+
/** Lets clients notice a daemon restart and check its catalog again. */
|
|
8915
|
+
const instance = randomUUID();
|
|
8824
8916
|
const callers = /* @__PURE__ */ new WeakMap();
|
|
8825
8917
|
let doctorInFlight = null;
|
|
8826
8918
|
let doctorCached = null;
|
|
8827
8919
|
let doctorNextProbeAt = 0;
|
|
8828
8920
|
app.addHook("onRequest", async (request, reply) => {
|
|
8829
8921
|
reply.header("x-request-id", request.id);
|
|
8922
|
+
reply.header("x-board-instance", instance);
|
|
8830
8923
|
if (request.headers.origin !== void 0) throw new CommandError("forbidden", "Browser origins are not accepted.");
|
|
8831
8924
|
const address = app.server.address();
|
|
8832
8925
|
const port = address !== null && typeof address === "object" ? address.port : options.port;
|
|
@@ -8912,7 +9005,7 @@ async function createApiServer(options) {
|
|
|
8912
9005
|
const caller = callers.get(request);
|
|
8913
9006
|
if (caller === void 0) throw new Error("Operation identity was not resolved.");
|
|
8914
9007
|
const operation = OPERATIONS.find((entry) => entry.id === request.params.id);
|
|
8915
|
-
if (operation === void 0) throw new CommandError("
|
|
9008
|
+
if (operation === void 0) throw new CommandError("version_mismatch", "This daemon does not know the operation. Run `board service restart` to update the daemon, then retry.");
|
|
8916
9009
|
const body = inputBody(request.body);
|
|
8917
9010
|
const parsed = operation.input.safeParse(body.input);
|
|
8918
9011
|
if (!parsed.success) throw new CommandError(errorCodeOf(parsed.error), "Fix the operation input.");
|
|
@@ -9530,55 +9623,6 @@ function createBlockMutations(config) {
|
|
|
9530
9623
|
};
|
|
9531
9624
|
}
|
|
9532
9625
|
//#endregion
|
|
9533
|
-
//#region ../core/src/app/board-utils.ts
|
|
9534
|
-
function trackerSource(project) {
|
|
9535
|
-
return project.tracker.type === "jira" ? `jira:${project.id}` : `github-issues:${project.id}:${project.tracker.repo.toLowerCase()}`;
|
|
9536
|
-
}
|
|
9537
|
-
function projectForKey(config, key) {
|
|
9538
|
-
const lowerKey = key.toLowerCase();
|
|
9539
|
-
const matched = config.projects.filter((project) => {
|
|
9540
|
-
const tracker = project.tracker;
|
|
9541
|
-
if (tracker.type === "github-issues") {
|
|
9542
|
-
const prefix = `${tracker.repo.toLowerCase()}#`;
|
|
9543
|
-
const short = tracker.repo.split("/")[1];
|
|
9544
|
-
return lowerKey.startsWith(prefix) || short !== void 0 && lowerKey.startsWith(`${short.toLowerCase()}#`);
|
|
9545
|
-
}
|
|
9546
|
-
const prefix = /^([A-Za-z][A-Za-z0-9_]*)-/.exec(key)?.[1]?.toUpperCase();
|
|
9547
|
-
return prefix !== void 0 && (tracker.projectKeys.length === 0 || tracker.projectKeys.includes(prefix));
|
|
9548
|
-
});
|
|
9549
|
-
return matched.length === 1 ? matched[0] ?? null : null;
|
|
9550
|
-
}
|
|
9551
|
-
async function emit(trx, kind, project, taskId, at, payload, actor = "system") {
|
|
9552
|
-
const parsed = parseActorId(actor);
|
|
9553
|
-
if (parsed === null) throw new Error("Invalid journal actor");
|
|
9554
|
-
await trx.insertInto("event").values({
|
|
9555
|
-
at,
|
|
9556
|
-
project,
|
|
9557
|
-
task_id: taskId,
|
|
9558
|
-
kind,
|
|
9559
|
-
actor: parsed.actor,
|
|
9560
|
-
actor_ref: parsed.actor === "agent" ? JSON.stringify(parsed.actorRef) : null,
|
|
9561
|
-
payload: toJson(eventPayloadSchemas[kind], payload),
|
|
9562
|
-
v: 1,
|
|
9563
|
-
request_id: null
|
|
9564
|
-
}).execute();
|
|
9565
|
-
}
|
|
9566
|
-
function taskView(task) {
|
|
9567
|
-
return {
|
|
9568
|
-
key: task.key,
|
|
9569
|
-
project: task.project,
|
|
9570
|
-
column: task.column,
|
|
9571
|
-
membership: task.membership
|
|
9572
|
-
};
|
|
9573
|
-
}
|
|
9574
|
-
function linkView(link) {
|
|
9575
|
-
return {
|
|
9576
|
-
kind: link.entity,
|
|
9577
|
-
ref: link.entity === "pr" ? `${link.repo ?? ""}#${String(link.number ?? "")}` : link.entity === "session" ? link.session_id ?? "" : link.ticket_key ?? "",
|
|
9578
|
-
role: link.role
|
|
9579
|
-
};
|
|
9580
|
-
}
|
|
9581
|
-
//#endregion
|
|
9582
9626
|
//#region ../core/src/app/membership.ts
|
|
9583
9627
|
function canonicalKey(config, key) {
|
|
9584
9628
|
const parsed = parseRef$1(key, "task");
|
|
@@ -9988,6 +10032,7 @@ const EVENT_SUMMARIES = {
|
|
|
9988
10032
|
doorbell_read: "Doorbell read",
|
|
9989
10033
|
doorbell_undelivered: "Doorbell delivery exhausted",
|
|
9990
10034
|
inbound_failed: "Inbound message failed",
|
|
10035
|
+
inbound_skipped: "Inbound message skipped",
|
|
9991
10036
|
from_you_raised: "Operator item raised",
|
|
9992
10037
|
from_you_cleared: "Operator item cleared",
|
|
9993
10038
|
flag_raised: "Flag raised",
|
|
@@ -11100,7 +11145,7 @@ function createObservationCore(deps) {
|
|
|
11100
11145
|
key,
|
|
11101
11146
|
value: "true"
|
|
11102
11147
|
}).execute();
|
|
11103
|
-
await emit(trx, "spur_unknown_state",
|
|
11148
|
+
await emit(trx, "spur_unknown_state", RESERVED_PROJECT, null, new Date(clock.now()).toISOString(), {
|
|
11104
11149
|
kind,
|
|
11105
11150
|
value
|
|
11106
11151
|
});
|
|
@@ -11343,18 +11388,12 @@ async function journal(trx, input) {
|
|
|
11343
11388
|
async function taskContext(trx, event, gateway) {
|
|
11344
11389
|
if (!("conversation" in event) || event.conversation === void 0) return {
|
|
11345
11390
|
taskId: null,
|
|
11346
|
-
project:
|
|
11347
|
-
taskKey: null
|
|
11391
|
+
project: null
|
|
11348
11392
|
};
|
|
11349
|
-
const row = await trx.selectFrom("conversation").leftJoin("task", "task.id", "conversation.task_id").select([
|
|
11350
|
-
"conversation.task_id as taskId",
|
|
11351
|
-
"task.project",
|
|
11352
|
-
"task.key"
|
|
11353
|
-
]).where("conversation.gateway", "=", gateway).where("conversation.ref", "=", event.conversation).executeTakeFirst();
|
|
11393
|
+
const row = await trx.selectFrom("conversation").leftJoin("task", "task.id", "conversation.task_id").select(["conversation.task_id as taskId", "task.project"]).where("conversation.gateway", "=", gateway).where("conversation.ref", "=", event.conversation).executeTakeFirst();
|
|
11354
11394
|
return {
|
|
11355
11395
|
taskId: row?.taskId ?? null,
|
|
11356
|
-
project: row?.project ??
|
|
11357
|
-
taskKey: row?.key ?? null
|
|
11396
|
+
project: row?.project ?? null
|
|
11358
11397
|
};
|
|
11359
11398
|
}
|
|
11360
11399
|
async function raiseInboundItem(trx, input) {
|
|
@@ -11370,6 +11409,10 @@ async function raiseInboundItem(trx, input) {
|
|
|
11370
11409
|
if (input.retry && item.inbound_event_json === null) await trx.updateTable("operator_item").set({ inbound_event_json: toJson(inboundEventSchema, input.event) }).where("id", "=", item.id).execute();
|
|
11371
11410
|
return item;
|
|
11372
11411
|
}
|
|
11412
|
+
/** The operation input a parsed query command carries; legacy stored commands carry none. */
|
|
11413
|
+
function commandInput(event) {
|
|
11414
|
+
return event.kind === "command" && event.command?.kind === "query" ? event.command.input : void 0;
|
|
11415
|
+
}
|
|
11373
11416
|
async function deny(trx, input) {
|
|
11374
11417
|
const context = await taskContext(trx, input.event, input.gateway);
|
|
11375
11418
|
await journal(trx, {
|
|
@@ -11382,7 +11425,7 @@ async function deny(trx, input) {
|
|
|
11382
11425
|
},
|
|
11383
11426
|
actor: input.actor,
|
|
11384
11427
|
taskId: context.taskId,
|
|
11385
|
-
project: context.project,
|
|
11428
|
+
project: context.project ?? deniedProject(input.config, input.actor, commandInput(input.event)),
|
|
11386
11429
|
at: input.at
|
|
11387
11430
|
});
|
|
11388
11431
|
return input.privateResponse ? {
|
|
@@ -11390,6 +11433,24 @@ async function deny(trx, input) {
|
|
|
11390
11433
|
response: "not allowed"
|
|
11391
11434
|
} : { status: "ignored" };
|
|
11392
11435
|
}
|
|
11436
|
+
/** Journal an inbound message the board does not act on; no gateway response. */
|
|
11437
|
+
async function skip(trx, input) {
|
|
11438
|
+
const context = await taskContext(trx, input.event, input.gateway);
|
|
11439
|
+
await journal(trx, {
|
|
11440
|
+
kind: "inbound_skipped",
|
|
11441
|
+
payload: {
|
|
11442
|
+
gateway: input.gateway,
|
|
11443
|
+
reason: input.reason,
|
|
11444
|
+
normalizedEvent: input.event,
|
|
11445
|
+
..."author" in input.event ? { platformUser: input.event.author } : {}
|
|
11446
|
+
},
|
|
11447
|
+
actor: input.actor,
|
|
11448
|
+
taskId: context.taskId,
|
|
11449
|
+
project: context.project ?? "_board",
|
|
11450
|
+
at: input.at
|
|
11451
|
+
});
|
|
11452
|
+
return { status: "ignored" };
|
|
11453
|
+
}
|
|
11393
11454
|
async function linkedComment(trx, event) {
|
|
11394
11455
|
return trx.selectFrom("comment").selectAll().where("conversation_ref", "=", event.conversation).where("message_ref", "=", event.message).executeTakeFirst();
|
|
11395
11456
|
}
|
|
@@ -11470,7 +11531,7 @@ function createInbound(input) {
|
|
|
11470
11531
|
}
|
|
11471
11532
|
async function processMessage(trx, event, at) {
|
|
11472
11533
|
const conversation = await sourceContext(trx, event, at);
|
|
11473
|
-
if (conversation?.block_id === null || conversation === void 0) return
|
|
11534
|
+
if (conversation?.block_id === null || conversation === void 0) return skip(trx, {
|
|
11474
11535
|
event,
|
|
11475
11536
|
reason: "Unknown board conversation.",
|
|
11476
11537
|
actor: "operator",
|
|
@@ -11498,7 +11559,7 @@ function createInbound(input) {
|
|
|
11498
11559
|
return { status: "applied" };
|
|
11499
11560
|
}
|
|
11500
11561
|
const comment = await linkedComment(trx, event);
|
|
11501
|
-
if (comment === void 0 || comment.author !== "operator") return
|
|
11562
|
+
if (comment === void 0 || comment.author !== "operator") return skip(trx, {
|
|
11502
11563
|
event,
|
|
11503
11564
|
reason: "Original operator comment not found.",
|
|
11504
11565
|
actor: "system",
|
|
@@ -11547,6 +11608,7 @@ function createInbound(input) {
|
|
|
11547
11608
|
}
|
|
11548
11609
|
async function runUser(trx, event, at) {
|
|
11549
11610
|
if (event.kind === "command" && event.name !== void 0 && event.name !== config.slack.command) return deny(trx, {
|
|
11611
|
+
config,
|
|
11550
11612
|
event,
|
|
11551
11613
|
reason: "Command name does not match the configured command.",
|
|
11552
11614
|
actor: "system",
|
|
@@ -11554,6 +11616,7 @@ function createInbound(input) {
|
|
|
11554
11616
|
at
|
|
11555
11617
|
});
|
|
11556
11618
|
if (event.kind !== "message_deleted" && event.author !== config.operator.slack) return deny(trx, {
|
|
11619
|
+
config,
|
|
11557
11620
|
event,
|
|
11558
11621
|
reason: "Only the configured operator may act.",
|
|
11559
11622
|
actor: "system",
|
|
@@ -11563,6 +11626,7 @@ function createInbound(input) {
|
|
|
11563
11626
|
});
|
|
11564
11627
|
if (event.kind === "action" || event.kind === "command") {
|
|
11565
11628
|
if (!(event.kind === "action" ? event.sourceSpace !== null : true)) return deny(trx, {
|
|
11629
|
+
config,
|
|
11566
11630
|
event,
|
|
11567
11631
|
reason: "Interaction came from outside a board space.",
|
|
11568
11632
|
actor: "operator",
|
|
@@ -11663,7 +11727,7 @@ function createInbound(input) {
|
|
|
11663
11727
|
},
|
|
11664
11728
|
actor: "system",
|
|
11665
11729
|
taskId: context.taskId,
|
|
11666
|
-
project: context.project,
|
|
11730
|
+
project: context.project ?? "_board",
|
|
11667
11731
|
at
|
|
11668
11732
|
});
|
|
11669
11733
|
await raiseInboundItem(trx, {
|
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 };
|