spur-board 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,26 +1,18 @@
1
- import { o as OPERATIONS } from "./src-CqH_W7D8.mjs";
2
- import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-C1hJzdYE.mjs";
3
- import { n as loadEnrollmentConfig, t as loadClientConfig } from "./config-DQmktVi-.mjs";
1
+ import { F as configSchema, I as SLACK_BOT_SCOPES, s as OPERATIONS } from "./src-D3PJf7bc.mjs";
2
+ import { a as renderCliError, i as errorExitCode, n as requestTokenEnrollment, o as escapeTerminalControls, r as CliError, t as requestOperation } from "./client-8Y8bDaQw.mjs";
3
+ import { n as loadEnrollmentConfig, t as loadClientConfig } from "./config-xzIjNtfV.mjs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
- import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
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";
7
7
  import { dirname, isAbsolute, join, resolve } from "node:path";
8
8
  import { Command, CommanderError } from "@commander-js/extra-typings";
9
9
  import { z } from "zod";
10
10
  import YAML from "yaml";
11
- import { spawn } from "node:child_process";
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { parseEnv } from "node:util";
12
13
  import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
13
14
  import { fileURLToPath } from "node:url";
14
15
  //#region src/manifest.ts
15
- const botScopes = [
16
- "chat:write",
17
- "chat:write.customize",
18
- "groups:history",
19
- "groups:read",
20
- "app_mentions:read",
21
- "commands",
22
- "reactions:write"
23
- ];
24
16
  function renderSlackManifest(options = {}) {
25
17
  const appName = (options.appName ?? "spur-board").trim();
26
18
  const command = options.command ?? "/board";
@@ -44,7 +36,7 @@ function renderSlackManifest(options = {}) {
44
36
  should_escape: false
45
37
  }]
46
38
  },
47
- oauth_config: { scopes: { bot: [...botScopes] } },
39
+ oauth_config: { scopes: { bot: [...SLACK_BOT_SCOPES] } },
48
40
  settings: {
49
41
  event_subscriptions: { bot_events: ["message.groups", "app_mention"] },
50
42
  interactivity: { is_enabled: true },
@@ -53,6 +45,327 @@ function renderSlackManifest(options = {}) {
53
45
  }, { lineWidth: 0 });
54
46
  }
55
47
  //#endregion
48
+ //#region src/doctor.ts
49
+ function systemRun(command, args, env) {
50
+ const result = spawnSync(command, [...args], {
51
+ encoding: "utf8",
52
+ timeout: 5e3,
53
+ ...env === void 0 ? {} : { env }
54
+ });
55
+ return {
56
+ exitCode: result.status ?? 1,
57
+ stdout: result.stdout
58
+ };
59
+ }
60
+ function environmentOf(pid) {
61
+ try {
62
+ const content = readFileSync(`/proc/${pid}/environ`, "utf8");
63
+ return Object.fromEntries(content.split("\0").filter(Boolean).map((entry) => {
64
+ const split = entry.indexOf("=");
65
+ return [entry.slice(0, split), entry.slice(split + 1)];
66
+ }));
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+ function liveSessions() {
72
+ const sessions = [];
73
+ let pids;
74
+ try {
75
+ pids = readdirSync("/proc").filter((entry) => /^\d+$/.test(entry));
76
+ } catch {
77
+ return sessions;
78
+ }
79
+ for (const pid of pids) {
80
+ const env = environmentOf(pid);
81
+ if (!env?.["SPUR_SESSION"] || !env["PATH"]) continue;
82
+ const started = systemRun("ps", [
83
+ "-o",
84
+ "etimes=",
85
+ "-p",
86
+ pid
87
+ ]);
88
+ const elapsedSeconds = Number(started.stdout.trim());
89
+ const startAt = started.exitCode === 0 && Number.isFinite(elapsedSeconds) && elapsedSeconds >= 0 ? Date.now() - elapsedSeconds * 1e3 : NaN;
90
+ let cwd;
91
+ try {
92
+ cwd = readlinkSync(`/proc/${pid}/cwd`);
93
+ } catch {}
94
+ sessions.push({
95
+ id: env["SPUR_SESSION"],
96
+ ...env["SPUR_PROJECT"] ? { project: env["SPUR_PROJECT"] } : {},
97
+ path: env["PATH"],
98
+ ...env["CODEX_HOME"] ? { codexHome: env["CODEX_HOME"] } : {},
99
+ ...cwd ? { cwd } : {},
100
+ ...Number.isFinite(startAt) ? { startAt } : {}
101
+ });
102
+ }
103
+ return sessions;
104
+ }
105
+ const defaultPorts = {
106
+ readText: (path) => readFileSync(path, "utf8"),
107
+ secretFile: (path) => {
108
+ const resolved = realpathSync(path);
109
+ const stat = statSync(resolved);
110
+ return {
111
+ path: resolved,
112
+ isFile: stat.isFile(),
113
+ mode: stat.mode & 511
114
+ };
115
+ },
116
+ listDir: readdirSync,
117
+ exists: existsSync,
118
+ run: systemRun,
119
+ sessions: liveSessions,
120
+ fetcher: fetch,
121
+ productVersion: () => {
122
+ const value = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
123
+ if (value && typeof value === "object" && "version" in value && typeof value.version === "string") return value.version;
124
+ throw new Error("Invalid package version");
125
+ }
126
+ };
127
+ function result(id, ok, fix) {
128
+ return {
129
+ id,
130
+ status: ok ? "pass" : "fail",
131
+ blocking: !ok,
132
+ fix: ok ? null : fix
133
+ };
134
+ }
135
+ function warning(id, fix) {
136
+ return {
137
+ id,
138
+ status: "warning",
139
+ blocking: false,
140
+ fix
141
+ };
142
+ }
143
+ function configFile(env, home) {
144
+ const boardHome = env["BOARD_HOME"];
145
+ if (boardHome) return join(resolve(boardHome), "config.yaml");
146
+ const xdg = env["XDG_CONFIG_HOME"];
147
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "spur-board", "config.yaml");
148
+ }
149
+ function unitFile(env, home) {
150
+ const xdg = env["XDG_CONFIG_HOME"];
151
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), "systemd", "user", "spur-board.service");
152
+ }
153
+ function localSecrets(config, path, ports) {
154
+ let file;
155
+ try {
156
+ file = ports.secretFile(path);
157
+ } catch {
158
+ let candidate;
159
+ try {
160
+ candidate = ports.listDir(dirname(path)).find((name) => {
161
+ if (name === "secrets.env" || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.env$/.test(name)) return false;
162
+ try {
163
+ return ports.secretFile(join(dirname(path), name)).isFile;
164
+ } catch {
165
+ return false;
166
+ }
167
+ });
168
+ } catch {}
169
+ return {
170
+ ...result("secrets", false, candidate ? `Create secrets.env with mode 0600; rename ${candidate} to secrets.env if it contains the intended tokens.` : "Create secrets.env as a regular file with mode 0600."),
171
+ present: []
172
+ };
173
+ }
174
+ if (!file.isFile) return result("secrets", false, "Make secrets.env a regular file with mode 0600.");
175
+ if (file.mode !== 384) return result("secrets", false, "Set secrets.env mode to 0600 with chmod 600.");
176
+ let names;
177
+ try {
178
+ names = parseEnv(ports.readText(file.path));
179
+ } catch {
180
+ names = {};
181
+ }
182
+ const slackConfigured = Boolean(config.slack.channels.feed || config.slack.channels.orchestrators);
183
+ const required = [
184
+ "GITHUB_TOKEN",
185
+ ...config.projects.some((project) => project.tracker.type === "jira") ? ["JIRA_EMAIL", "JIRA_API_TOKEN"] : [],
186
+ ...slackConfigured ? ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] : []
187
+ ];
188
+ const missing = required.filter((name) => !names[name]);
189
+ return {
190
+ ...result("secrets", missing.length === 0, `Add ${missing.join(", ")} to the private secrets.env file.`),
191
+ present: required.filter((name) => Boolean(names[name]))
192
+ };
193
+ }
194
+ function stamp(ports, path) {
195
+ try {
196
+ const value = JSON.parse(ports.readText(path));
197
+ if (value && typeof value === "object" && "version" in value && typeof value.version === "string" && "installedAt" in value && typeof value.installedAt === "string") {
198
+ const installedAt = Date.parse(value.installedAt);
199
+ if (Number.isFinite(installedAt)) return {
200
+ version: value.version,
201
+ installedAt
202
+ };
203
+ }
204
+ } catch {}
205
+ return null;
206
+ }
207
+ function nodeFromUnit(unit) {
208
+ const line = /^ExecStart=(.*)$/m.exec(unit)?.[1];
209
+ if (!line?.startsWith("\"")) return null;
210
+ let path = "";
211
+ for (let index = 1; index < line.length; index++) {
212
+ const char = line[index];
213
+ if (char === "\"") return line[index + 1] === " " ? path : null;
214
+ if (char === "\\") {
215
+ const escaped = line[++index];
216
+ if (escaped !== "\\" && escaped !== "\"") return null;
217
+ path += escaped;
218
+ } else if (char === "%") {
219
+ if (line[++index] !== "%") return null;
220
+ path += "%";
221
+ } else {
222
+ if (char === void 0) return null;
223
+ path += char;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+ const remoteBaseIds = [
229
+ "secrets",
230
+ "jira.credentials",
231
+ "github.credentials",
232
+ "spur.api",
233
+ "operator.path"
234
+ ];
235
+ const remoteSlackIds = [
236
+ "slack.auth",
237
+ "slack.orchestrators_member",
238
+ "slack.feed_member",
239
+ "slack.command"
240
+ ];
241
+ const remoteIds = [
242
+ ...remoteBaseIds,
243
+ ...remoteSlackIds,
244
+ "slack.configured"
245
+ ];
246
+ function remoteIdSet(slackConfigured) {
247
+ return [...remoteBaseIds, ...slackConfigured ? remoteSlackIds : ["slack.configured"]];
248
+ }
249
+ function remoteChecks(value) {
250
+ if (!value || typeof value !== "object" || !("version" in value) || !("checks" in value)) return null;
251
+ if (typeof value.version !== "string" || !Array.isArray(value.checks)) return null;
252
+ const checks = [];
253
+ for (const entry of value.checks) {
254
+ if (entry === null || typeof entry !== "object" || !("id" in entry) || !("status" in entry) || !("blocking" in entry) || !("fix" in entry) || typeof entry.id !== "string" || !remoteIds.some((id) => id === entry.id) || typeof entry.status !== "string" || ![
255
+ "pass",
256
+ "fail",
257
+ "warning"
258
+ ].includes(entry.status) || typeof entry.blocking !== "boolean" || entry.fix !== null && typeof entry.fix !== "string") return null;
259
+ checks.push({
260
+ id: entry.id,
261
+ status: entry.status,
262
+ blocking: entry.blocking,
263
+ fix: entry.fix
264
+ });
265
+ }
266
+ const ids = new Set(checks.map((entry) => entry.id));
267
+ if (![true, false].some((slackConfigured) => {
268
+ const expected = remoteIdSet(slackConfigured);
269
+ return checks.length === expected.length && expected.every((id) => ids.has(id));
270
+ })) return null;
271
+ return {
272
+ version: value.version,
273
+ checks
274
+ };
275
+ }
276
+ /** Local preflight remains useful even when the daemon is stopped. */
277
+ async function runDoctor(options = {}) {
278
+ const env = options.env ?? process.env;
279
+ const home = options.home ?? homedir();
280
+ const ports = {
281
+ ...defaultPorts,
282
+ ...options.ports
283
+ };
284
+ const checks = [];
285
+ let config;
286
+ try {
287
+ const parsed = YAML.parseDocument(ports.readText(configFile(env, home)));
288
+ if (parsed.errors.length > 0) throw new Error("Invalid YAML");
289
+ const validated = configSchema.safeParse(parsed.toJS());
290
+ if (!validated.success) {
291
+ const path = validated.error.issues[0]?.path.map(String).join(".") || "root";
292
+ checks.push(result("config", false, `Correct ${path} in config.yaml using the configuration schema.`));
293
+ return checks;
294
+ }
295
+ config = validated.data;
296
+ checks.push(result("config", true, "Correct config.yaml."));
297
+ } catch {
298
+ checks.push(result("config", false, "Create or correct config.yaml using the configuration schema."));
299
+ return checks;
300
+ }
301
+ if (options.offline) return checks;
302
+ const unitPath = unitFile(env, home);
303
+ let unit = null;
304
+ try {
305
+ unit = ports.readText(unitPath);
306
+ } catch {}
307
+ checks.push(result("service.installed", unit !== null, "Run board service install."));
308
+ const node = unit === null ? null : nodeFromUnit(unit);
309
+ checks.push(result("service.node", node !== null && ports.exists(node), "Reinstall the service with a valid Node executable path."));
310
+ checks.push(result("service.running", ports.run("systemctl", [
311
+ "--user",
312
+ "is-active",
313
+ "spur-board.service"
314
+ ]).exitCode === 0, "Start or restart the user service and inspect its journal."));
315
+ checks.push(result("flock", ports.run("flock", ["--version"]).exitCode === 0, "Install flock from util-linux."));
316
+ const currentBoard = ports.run("board", ["--version"]);
317
+ checks.push(result("path.current", currentBoard.exitCode === 0, "Install board into the current PATH."));
318
+ const sessions = ports.sessions();
319
+ const live = sessions[0];
320
+ const spurBoard = live === void 0 ? null : ports.run("board", ["--version"], {
321
+ HOME: home,
322
+ PATH: live.path
323
+ });
324
+ checks.push(live === void 0 ? warning("path.spur", "Start a Spur session, then rerun board doctor.") : result("path.spur", spurBoard?.exitCode === 0, "Install board into the live Spur session PATH."));
325
+ const configured = new Set(config.projects.flatMap((project) => project.spurProjects.map((spur) => spur.name)));
326
+ const codexSessions = sessions.filter((session) => session.codexHome && session.project && configured.has(session.project));
327
+ checks.push(codexSessions.length === 0 ? warning("skills.codex_visibility", "Start a Codex session in a configured Spur project to verify skill visibility.") : result("skills.codex_visibility", codexSessions.some((session) => session.cwd !== void 0 && ports.exists(join(session.cwd, ".agents", "skills", "spur-board", "SKILL.md")) || session.codexHome !== void 0 && ports.exists(join(session.codexHome, "skills", "spur-board", "SKILL.md"))), "Add .agents/skills/spur-board to the Spur project's symlinks in its config and start a new session."));
328
+ const version = ports.productVersion();
329
+ const stamps = [".claude", ".codex"].flatMap((agent) => ["spur-board", "spur-board-setup"].map((skill) => stamp(ports, join(home, agent, "skills", skill, ".spur-board-install.json"))));
330
+ const latestInstall = Math.max(...stamps.flatMap((entry) => entry === null ? [] : [entry.installedAt]));
331
+ const stale = sessions.filter((session) => session.startAt !== void 0 && session.startAt < latestInstall);
332
+ const unknownStarts = sessions.some((session) => session.startAt === void 0);
333
+ const staleIds = stale.map((session) => session.id).filter((id) => /^[a-zA-Z0-9_-]+$/.test(id)).slice(0, 10);
334
+ checks.push(stale.length > 0 || unknownStarts ? warning("sessions.stale", `Restart sessions started before the last skill install${staleIds.length ? `: ${staleIds.join(", ")}` : ""}; verify sessions whose start time is unavailable.`) : result("sessions.stale", true, "Restart older sessions."));
335
+ let remote = null;
336
+ try {
337
+ const base = env["BOARD_DAEMON_URL"] || `http://127.0.0.1:${String(config.http.port)}`;
338
+ const response = await ports.fetcher(new URL("/v1/doctor", base), { signal: AbortSignal.timeout(8e3) });
339
+ if (response.ok) remote = remoteChecks(await response.json());
340
+ } catch {}
341
+ checks.push(result("daemon.available", remote !== null, "Start or restart the user service."));
342
+ const secrets = localSecrets(config, join(dirname(configFile(env, home)), "secrets.env"), ports);
343
+ const daemonSecrets = remote?.checks.find((check) => check.id === "secrets");
344
+ checks.push(secrets.status === "pass" && daemonSecrets?.status === "fail" ? {
345
+ ...secrets,
346
+ status: "fail",
347
+ blocking: true,
348
+ fix: daemonSecrets.fix ?? "Add the missing required keys to secrets.env and restart the daemon."
349
+ } : secrets);
350
+ if (remote === null) checks.push(...remoteIdSet(Boolean(config.slack.channels.feed || config.slack.channels.orchestrators)).filter((id) => id !== "secrets").map((id) => warning(id, id === "slack.configured" ? "Slack is not configured: questions addressed to you are only visible through board inbox. Run the spur-board-setup skill to configure slack.channels." : "Start the daemon, then rerun board doctor to complete this check.")));
351
+ else checks.push(...remote.checks.filter((check) => check.id !== "secrets"));
352
+ const availableVersions = [
353
+ ...currentBoard.exitCode === 0 ? [currentBoard.stdout.trim()] : [],
354
+ ...spurBoard?.exitCode === 0 ? [spurBoard.stdout.trim()] : [],
355
+ ...remote === null ? [] : [remote.version],
356
+ ...stamps.flatMap((entry) => entry === null ? [] : [entry.version])
357
+ ];
358
+ checks.push(result("versions", availableVersions.every((available) => available === version), "Install matching CLI and skills versions, then restart the daemon."));
359
+ return checks;
360
+ }
361
+ function renderDoctor(checks, json) {
362
+ if (json) return JSON.stringify(checks);
363
+ return checks.map((check) => {
364
+ const present = check.present === void 0 ? "" : ` (present: ${check.present.join(", ") || "none"})`;
365
+ return `${check.status.toUpperCase()} ${check.id}${present}${check.fix === null ? "" : `: ${check.fix}`}`;
366
+ }).join("\n");
367
+ }
368
+ //#endregion
56
369
  //#region src/service.ts
57
370
  const serviceName = "spur-board.service";
58
371
  function configHome(env, home) {
@@ -633,6 +946,21 @@ function registerSetupCommands(program, options, io, env) {
633
946
  throw error;
634
947
  }
635
948
  };
949
+ const doctor = commandFor(program, "doctor");
950
+ doctor.description("Check whether the installation is ready and show exact fixes.");
951
+ doctor.option("--offline", "Validate config only, without network or process checks.");
952
+ doctor.option("--json", "Print the check list as JSON.");
953
+ doctor.action(async () => {
954
+ const checks = await runDoctor({
955
+ env,
956
+ ...options.home === void 0 ? {} : { home: options.home },
957
+ ...doctor.opts()["offline"] ? { offline: true } : {},
958
+ ...options.doctor === void 0 ? {} : { ports: options.doctor }
959
+ });
960
+ const json = Boolean(doctor.opts()["json"] || program.opts()["json"]);
961
+ io.stdout(`${renderDoctor(checks, json)}\n`);
962
+ if (checks.some((check) => check.blocking)) throw new CommanderError(1, "board.error", "Doctor found blocking failures.");
963
+ });
636
964
  commandFor(program, "skills install").description("Install bundled skills for Claude Code and Codex.").action(async () => {
637
965
  print(await runSetup(() => installSkills(options.setup)));
638
966
  });
@@ -796,4 +1124,4 @@ async function runCli(argv, options = {}) {
796
1124
  }
797
1125
  }
798
1126
  //#endregion
799
- export { createProgram, runCli };
1127
+ export { createProgram, exampleCommand, runCli };
@@ -1,4 +1,4 @@
1
- import { A as errorEnvelopeSchema, O as ERRORS, c as renderJsonError, j as successEnvelopeSchema, k as errorContextSchema } from "./src-CqH_W7D8.mjs";
1
+ import { M as errorContextSchema, N as errorEnvelopeSchema, P as successEnvelopeSchema, j as ERRORS, l as renderJsonError } from "./src-D3PJf7bc.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { request } from "node:http";
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as requestTokenEnrollment, t as requestOperation } from "./client-C1hJzdYE.mjs";
1
+ import { n as requestTokenEnrollment, t as requestOperation } from "./client-8Y8bDaQw.mjs";
2
2
  export { requestOperation, requestTokenEnrollment };
@@ -1,4 +1,4 @@
1
- import { r as CliError } from "./client-C1hJzdYE.mjs";
1
+ import { r as CliError } from "./client-8Y8bDaQw.mjs";
2
2
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, sep } from "node:path";