spur-board 0.1.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/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/bin.mjs +28 -0
- package/dist/cli.mjs +799 -0
- package/dist/client-C1hJzdYE.mjs +162 -0
- package/dist/client.mjs +2 -0
- package/dist/config-DQmktVi-.mjs +92 -0
- package/dist/daemon.mjs +7602 -0
- package/dist/mcp.mjs +123 -0
- package/dist/migrations/0001_init.sql +197 -0
- package/dist/migrations/0002_delivery.sql +49 -0
- package/dist/migrations/0003_delivery_search.sql +3 -0
- package/dist/migrations/0004_attempt_started.sql +3 -0
- package/dist/migrations/0005_doorbells_items.sql +39 -0
- package/dist/migrations/0006_notification_exhausted.sql +1 -0
- package/dist/migrations/0007_inbound_item_event.sql +1 -0
- package/dist/migrations/0008_observation.sql +49 -0
- package/dist/migrations/0009_operator_tokens.sql +15 -0
- package/dist/src-CqH_W7D8.mjs +2475 -0
- package/package.json +47 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { A as errorEnvelopeSchema, O as ERRORS, c as renderJsonError, j as successEnvelopeSchema, k as errorContextSchema } from "./src-CqH_W7D8.mjs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { request } from "node:http";
|
|
5
|
+
import { request as request$1 } from "node:https";
|
|
6
|
+
//#region src/terminal.ts
|
|
7
|
+
/** Show control bytes as text so server data cannot change terminal state. */
|
|
8
|
+
function escapeTerminalControls(text) {
|
|
9
|
+
return Array.from(text, (character) => {
|
|
10
|
+
const code = character.charCodeAt(0);
|
|
11
|
+
return code < 32 || code >= 127 && code <= 159 ? `\\u${code.toString(16).padStart(4, "0")}` : character;
|
|
12
|
+
}).join("");
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/exit-codes.ts
|
|
16
|
+
var CliError = class extends Error {
|
|
17
|
+
code;
|
|
18
|
+
context;
|
|
19
|
+
requestId;
|
|
20
|
+
hint;
|
|
21
|
+
constructor(code, context, requestId = `client-${randomUUID()}`, hint) {
|
|
22
|
+
super(ERRORS[code].message);
|
|
23
|
+
this.name = "CliError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.context = context;
|
|
26
|
+
this.requestId = requestId;
|
|
27
|
+
this.hint = hint;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function errorExitCode(error) {
|
|
31
|
+
return ERRORS[error.code].exit;
|
|
32
|
+
}
|
|
33
|
+
function renderCliError(error, json, key) {
|
|
34
|
+
const envelope = renderJsonError({
|
|
35
|
+
code: error.code,
|
|
36
|
+
requestId: error.requestId,
|
|
37
|
+
...error.context ? { context: error.context } : {}
|
|
38
|
+
});
|
|
39
|
+
const recoveryHint = key && ERRORS[error.code].exit === 5 ? `retry with --key ${key}` : null;
|
|
40
|
+
const hint = [error.hint ?? envelope.error.hint, recoveryHint].filter(Boolean).join(" ");
|
|
41
|
+
const value = errorEnvelopeSchema.parse({
|
|
42
|
+
...envelope,
|
|
43
|
+
error: {
|
|
44
|
+
...envelope.error,
|
|
45
|
+
hint
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
if (json) return JSON.stringify(value);
|
|
49
|
+
return [
|
|
50
|
+
["error", `${value.error.code}: ${value.error.message}`],
|
|
51
|
+
["fix", value.error.hint],
|
|
52
|
+
["side effects", value.error.recovery.sideEffects],
|
|
53
|
+
["recovery", value.error.recovery.action],
|
|
54
|
+
["requestId", value.requestId]
|
|
55
|
+
].map(([label, text]) => `${label}: ${escapeTerminalControls(text)}`).join("\n");
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/client.ts
|
|
59
|
+
function problemError(status, contentType, body) {
|
|
60
|
+
if (!body || typeof body !== "object") return new CliError("outcome_unknown");
|
|
61
|
+
const problem = body;
|
|
62
|
+
const requestId = typeof problem["requestId"] === "string" && problem["requestId"] ? problem["requestId"] : void 0;
|
|
63
|
+
if (status === 503 && contentType?.startsWith("application/problem+json") && problem["status"] === 503 && problem["code"] === "starting") return new CliError("daemon_unavailable", void 0, requestId);
|
|
64
|
+
const code = problem["code"];
|
|
65
|
+
if (typeof code === "string" && Object.hasOwn(ERRORS, code) && ERRORS[code].status === status) {
|
|
66
|
+
const context = errorContextSchema.safeParse(problem["context"]);
|
|
67
|
+
return new CliError(code, context.success ? context.data : void 0, requestId);
|
|
68
|
+
}
|
|
69
|
+
return new CliError("outcome_unknown", void 0, requestId);
|
|
70
|
+
}
|
|
71
|
+
function decodeResponse(output, response, raw) {
|
|
72
|
+
let body;
|
|
73
|
+
try {
|
|
74
|
+
body = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new CliError("outcome_unknown");
|
|
77
|
+
}
|
|
78
|
+
if (response.statusCode === 200) {
|
|
79
|
+
const envelope = successEnvelopeSchema.safeParse(body);
|
|
80
|
+
if (!envelope.success) throw new CliError("outcome_unknown");
|
|
81
|
+
const parsed = output.safeParse(envelope.data.value);
|
|
82
|
+
if (!parsed.success) throw new CliError("outcome_unknown");
|
|
83
|
+
return parsed.data;
|
|
84
|
+
}
|
|
85
|
+
throw problemError(response.statusCode ?? 0, response.headers["content-type"], body);
|
|
86
|
+
}
|
|
87
|
+
async function post(path, body, config, output, anonymous) {
|
|
88
|
+
const target = new URL(path, config.address);
|
|
89
|
+
const payload = JSON.stringify(body);
|
|
90
|
+
const transport = target.protocol === "https:" ? request$1 : request;
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
let connected = false;
|
|
93
|
+
let settled = false;
|
|
94
|
+
let timer;
|
|
95
|
+
const settle = (result) => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
if (timer) clearTimeout(timer);
|
|
99
|
+
if ("error" in result) reject(result.error);
|
|
100
|
+
else resolve(result.value);
|
|
101
|
+
};
|
|
102
|
+
const req = transport(target, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
agent: false,
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "application/json",
|
|
107
|
+
accept: "application/json, application/problem+json",
|
|
108
|
+
"content-length": Buffer.byteLength(payload),
|
|
109
|
+
...anonymous ? {} : config.session ? { "x-board-session": config.session } : { authorization: `Bearer ${config.token ?? ""}` }
|
|
110
|
+
}
|
|
111
|
+
}, (response) => {
|
|
112
|
+
const chunks = [];
|
|
113
|
+
response.on("data", (chunk) => {
|
|
114
|
+
chunks.push(chunk);
|
|
115
|
+
});
|
|
116
|
+
response.once("error", () => {
|
|
117
|
+
settle({ error: new CliError("outcome_unknown") });
|
|
118
|
+
});
|
|
119
|
+
response.once("end", () => {
|
|
120
|
+
try {
|
|
121
|
+
settle({ value: decodeResponse(output, response, Buffer.concat(chunks).toString()) });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
settle({ error: error instanceof CliError ? error : new CliError("outcome_unknown") });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
const onConnected = () => {
|
|
128
|
+
if (settled || connected) return;
|
|
129
|
+
connected = true;
|
|
130
|
+
if (timer) clearTimeout(timer);
|
|
131
|
+
timer = setTimeout(() => req.destroy(/* @__PURE__ */ new Error("response timeout")), config.responseTimeoutMs);
|
|
132
|
+
};
|
|
133
|
+
req.once("socket", (socket) => {
|
|
134
|
+
if (!socket.connecting && !("secureConnecting" in socket && socket.secureConnecting)) onConnected();
|
|
135
|
+
else socket.once(target.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
|
136
|
+
});
|
|
137
|
+
req.once("error", () => {
|
|
138
|
+
settle({ error: new CliError(connected ? "outcome_unknown" : "daemon_unavailable") });
|
|
139
|
+
});
|
|
140
|
+
timer = setTimeout(() => req.destroy(/* @__PURE__ */ new Error("connection timeout")), config.connectTimeoutMs);
|
|
141
|
+
req.end(payload);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Shared daemon HTTP client. The CLI owns output; this module owns transport and wire parsing. */
|
|
145
|
+
async function requestOperation(op, input, key, config) {
|
|
146
|
+
if (!config.session && !config.token) throw new CliError("config");
|
|
147
|
+
return post(`/v1/ops/${encodeURIComponent(op.id)}`, {
|
|
148
|
+
input,
|
|
149
|
+
...key ? { key } : {}
|
|
150
|
+
}, config, op.output, false);
|
|
151
|
+
}
|
|
152
|
+
const enrollmentOutput = z.strictObject({
|
|
153
|
+
name: z.string(),
|
|
154
|
+
status: z.literal("pending"),
|
|
155
|
+
expiresAt: z.iso.datetime()
|
|
156
|
+
});
|
|
157
|
+
/** Bootstrap sends a hash and no authorization header. */
|
|
158
|
+
function requestTokenEnrollment(input, config) {
|
|
159
|
+
return post("/v1/tokens/requests", input, config, enrollmentOutput, true);
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
export { renderCliError as a, errorExitCode as i, requestTokenEnrollment as n, escapeTerminalControls as o, CliError as r, requestOperation as t };
|
package/dist/client.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { r as CliError } from "./client-C1hJzdYE.mjs";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
//#region src/config.ts
|
|
6
|
+
const CONNECT_TIMEOUT_MS = 2e3;
|
|
7
|
+
const RESPONSE_TIMEOUT_MS = 3e4;
|
|
8
|
+
function daemonAddress(env) {
|
|
9
|
+
const rawAddress = env["BOARD_DAEMON_URL"];
|
|
10
|
+
if (!rawAddress) throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
|
|
11
|
+
let address;
|
|
12
|
+
try {
|
|
13
|
+
address = new URL(rawAddress);
|
|
14
|
+
} catch {
|
|
15
|
+
throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
|
|
16
|
+
}
|
|
17
|
+
if (!["http:", "https:"].includes(address.protocol) || address.username || address.password) throw new CliError("config", { environmentVariable: "BOARD_DAEMON_URL" });
|
|
18
|
+
return address;
|
|
19
|
+
}
|
|
20
|
+
function tokenInFolder(folder, warn) {
|
|
21
|
+
const directory = join(folder, ".spur-board");
|
|
22
|
+
const file = join(directory, "token");
|
|
23
|
+
try {
|
|
24
|
+
const owner = process.getuid?.();
|
|
25
|
+
const directoryInfo = lstatSync(directory);
|
|
26
|
+
if (!directoryInfo.isDirectory() || directoryInfo.uid !== owner || (directoryInfo.mode & 63) !== 0) throw new Error("unsafe token directory");
|
|
27
|
+
const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
28
|
+
let value;
|
|
29
|
+
try {
|
|
30
|
+
const fileInfo = fstatSync(descriptor);
|
|
31
|
+
if (!fileInfo.isFile() || fileInfo.uid !== owner || (fileInfo.mode & 63) !== 0) throw new Error("unsafe token file");
|
|
32
|
+
value = readFileSync(descriptor, "utf8").trimEnd();
|
|
33
|
+
} finally {
|
|
34
|
+
closeSync(descriptor);
|
|
35
|
+
}
|
|
36
|
+
if (value === "") throw new Error("empty token file");
|
|
37
|
+
return value;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
|
|
40
|
+
warn("Ignoring an unsafe .spur-board/token candidate.\n");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function findTokenFile(cwd, home, warn = (message) => {
|
|
45
|
+
process.stderr.write(message);
|
|
46
|
+
}) {
|
|
47
|
+
const current = realpathSync(cwd);
|
|
48
|
+
const boundary = realpathSync(home);
|
|
49
|
+
if (current === boundary) return void 0;
|
|
50
|
+
const toCurrent = relative(boundary, current);
|
|
51
|
+
const outsideHome = toCurrent === ".." || toCurrent.startsWith(`..${sep}`) || isAbsolute(toCurrent);
|
|
52
|
+
let folder = current;
|
|
53
|
+
while (folder !== boundary) {
|
|
54
|
+
if (outsideHome) {
|
|
55
|
+
const info = statSync(folder);
|
|
56
|
+
if (info.uid !== process.getuid?.() || (info.mode & 2) !== 0) break;
|
|
57
|
+
}
|
|
58
|
+
const token = tokenInFolder(folder, warn);
|
|
59
|
+
if (token !== void 0) return token;
|
|
60
|
+
const parent = dirname(folder);
|
|
61
|
+
if (parent === folder) break;
|
|
62
|
+
folder = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function loadClientConfig(env = process.env, paths = {
|
|
66
|
+
cwd: process.cwd(),
|
|
67
|
+
home: homedir()
|
|
68
|
+
}) {
|
|
69
|
+
const address = daemonAddress(env);
|
|
70
|
+
const session = env["SPUR_SESSION"] || void 0;
|
|
71
|
+
const token = session ? void 0 : env["BOARD_TOKEN"] === void 0 ? findTokenFile(paths.cwd, paths.home) : env["BOARD_TOKEN"];
|
|
72
|
+
if (!session && !token) throw new CliError("config", { environmentVariable: "BOARD_TOKEN" });
|
|
73
|
+
return {
|
|
74
|
+
address,
|
|
75
|
+
session,
|
|
76
|
+
token,
|
|
77
|
+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
78
|
+
responseTimeoutMs: RESPONSE_TIMEOUT_MS
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Enrollment sends only a hash and works before any operator credential exists. */
|
|
82
|
+
function loadEnrollmentConfig(env = process.env) {
|
|
83
|
+
return {
|
|
84
|
+
address: daemonAddress(env),
|
|
85
|
+
session: void 0,
|
|
86
|
+
token: void 0,
|
|
87
|
+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
88
|
+
responseTimeoutMs: RESPONSE_TIMEOUT_MS
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { loadEnrollmentConfig as n, loadClientConfig as t };
|