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 CHANGED
@@ -1,6 +1,6 @@
1
- import { B as SLACK_BOT_SCOPES, R as configSchema, s as OPERATIONS, z as requiredSecretKeys } from "./src-BFgTh55w.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-Ln76BdDz.mjs";
3
- import { i as resolveDaemonAddress, n as loadClientConfig, r as loadEnrollmentConfig, t as configFile } from "./config-B_6fA4Au.mjs";
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
- const result = await (options.sendOperation ?? requestOperation)(op, input, key, config);
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-BFgTh55w.mjs";
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
- async function post(path, body, config, output, anonymous) {
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: "POST",
109
+ method,
104
110
  agent: false,
105
111
  headers: {
106
- "content-type": "application/json",
107
112
  accept: "application/json, application/problem+json",
108
- "content-length": Buffer.byteLength(payload),
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: decodeResponse(output, response, Buffer.concat(chunks).toString()) });
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 post(`/v1/ops/${encodeURIComponent(op.id)}`, {
148
- input,
149
- ...key ? { key } : {}
150
- }, config, op.output, false);
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 post("/v1/tokens/requests", input, config, enrollmentOutput, true);
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 { renderCliError as a, errorExitCode as i, requestTokenEnrollment as n, escapeTerminalControls as o, CliError as r, requestOperation as t };
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 requestOperation } from "./client-Ln76BdDz.mjs";
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 };