@luuthuong/devboard 0.0.0-stage → 1.0.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.
Files changed (80) hide show
  1. package/.claude/skills/devboard/SKILL.md +111 -0
  2. package/bin/devboard.mjs +59 -0
  3. package/dist/assets/CardDetailView-BZE_MfjM.js +24 -0
  4. package/dist/assets/DiffPane-2WnoRvZ0.js +22 -0
  5. package/dist/assets/DiffPane-CpoDDQrA.css +1 -0
  6. package/dist/assets/KnowledgeView-C4WPFAUM.js +3 -0
  7. package/dist/assets/ListBoardView-DEE_eK4h.js +3 -0
  8. package/dist/assets/NewTaskDialog-D8cpc7Bc.js +1 -0
  9. package/dist/assets/OnboardingWizard-9ZkFPGvH.js +1 -0
  10. package/dist/assets/ProcessManagerView-DJOr44DY.js +1 -0
  11. package/dist/assets/ProposalCard-B8jqFtL8.js +1 -0
  12. package/dist/assets/SearchOverlay-qGxzmM_v.js +1 -0
  13. package/dist/assets/SettingsView-C3GkCbTz.js +1 -0
  14. package/dist/assets/SetupProjectView-CSx6ax5r.js +1 -0
  15. package/dist/assets/Surface-BQKeigoq.js +1 -0
  16. package/dist/assets/TaskDescriptionEditor-D0_b81zY.js +58 -0
  17. package/dist/assets/Terminal-BrP-ENHg.css +1 -0
  18. package/dist/assets/Terminal-yq90NOBX.js +80 -0
  19. package/dist/assets/deepEqual-BiJVGDfM.js +1 -0
  20. package/dist/assets/index-BtXUncAU.css +1 -0
  21. package/dist/assets/index-CQnL8JJ5.js +18 -0
  22. package/dist/assets/jsx-runtime-QmcfnBgR.js +1 -0
  23. package/dist/assets/plus-Cecrm8sZ.js +1 -0
  24. package/dist/assets/react-dom-DjtJUrx3.js +1 -0
  25. package/dist/assets/x-B6wVesqk.js +1 -0
  26. package/dist/index.html +22 -0
  27. package/package.json +81 -4
  28. package/server-dist/server/assets/remote-shell.sh +239 -0
  29. package/server-dist/server/board.js +486 -0
  30. package/server-dist/server/commands.js +390 -0
  31. package/server-dist/server/context.js +34 -0
  32. package/server-dist/server/coordination.js +442 -0
  33. package/server-dist/server/editor.js +101 -0
  34. package/server-dist/server/events.js +17 -0
  35. package/server-dist/server/fsBrowse.js +143 -0
  36. package/server-dist/server/git.js +258 -0
  37. package/server-dist/server/harness/config.js +90 -0
  38. package/server-dist/server/harness/entry.js +98 -0
  39. package/server-dist/server/harness/intents.js +268 -0
  40. package/server-dist/server/harness/kbIndex.js +291 -0
  41. package/server-dist/server/harness/kbStore.js +258 -0
  42. package/server-dist/server/harness/librarian.js +147 -0
  43. package/server-dist/server/harness/pack.js +182 -0
  44. package/server-dist/server/harness/proposals.js +113 -0
  45. package/server-dist/server/harness/repo.js +150 -0
  46. package/server-dist/server/harness/scrub.js +30 -0
  47. package/server-dist/server/harness/service.js +683 -0
  48. package/server-dist/server/harness/template/README.md +38 -0
  49. package/server-dist/server/harness/template/agents/code-reviewer.md +23 -0
  50. package/server-dist/server/harness/template/agents/security-reviewer.md +20 -0
  51. package/server-dist/server/harness/template/agents/test-writer.md +16 -0
  52. package/server-dist/server/harness/template/agents.yaml +41 -0
  53. package/server-dist/server/harness/template/harness.json +11 -0
  54. package/server-dist/server/harness/template/kb/team/README.md +4 -0
  55. package/server-dist/server/harness/template/skills/aidlc-design/SKILL.md +41 -0
  56. package/server-dist/server/harness/template/skills/aidlc-implement/SKILL.md +34 -0
  57. package/server-dist/server/harness/template/skills/aidlc-postmortem/SKILL.md +35 -0
  58. package/server-dist/server/harness/template/skills/aidlc-pr/SKILL.md +22 -0
  59. package/server-dist/server/harness/template/skills/aidlc-requirements/SKILL.md +40 -0
  60. package/server-dist/server/harness/template/skills/aidlc-units/SKILL.md +36 -0
  61. package/server-dist/server/harness/template/skills/project-kb/SKILL.md +41 -0
  62. package/server-dist/server/harness/usage.js +82 -0
  63. package/server-dist/server/hooks.js +222 -0
  64. package/server-dist/server/http.js +266 -0
  65. package/server-dist/server/index.js +77 -0
  66. package/server-dist/server/integrations.js +292 -0
  67. package/server-dist/server/pollers.js +332 -0
  68. package/server-dist/server/processes.js +123 -0
  69. package/server-dist/server/psmux.js +120 -0
  70. package/server-dist/server/pty.js +93 -0
  71. package/server-dist/server/remote.js +216 -0
  72. package/server-dist/server/sessionReaper.js +101 -0
  73. package/server-dist/server/sessions.js +315 -0
  74. package/server-dist/server/settings.js +125 -0
  75. package/server-dist/server/skill.js +29 -0
  76. package/server-dist/server/tmux.js +217 -0
  77. package/server-dist/server/transcripts.js +334 -0
  78. package/server-dist/server/util.js +286 -0
  79. package/server-dist/src/types/index.js +53 -0
  80. package/README.md +0 -3
@@ -0,0 +1,222 @@
1
+ // Claude Code hook plumbing: installs `~/.devboard/hook.sh` (on Windows,
2
+ // inside WSL) and wires it into `~/.claude/settings.json`, then tails the
3
+ // events file the script appends to. Ports hook_manager.rs and
4
+ // hook_event_store.rs.
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { IS_WINDOWS, devboardDataDir, log, run } from "./util.js";
9
+ import { windowsPathToWsl } from "./tmux.js";
10
+ const HOOK_EVENTS = [
11
+ "Stop",
12
+ "Notification",
13
+ "SessionStart",
14
+ "SessionEnd",
15
+ "UserPromptSubmit",
16
+ "PreCompact",
17
+ "SubagentStop",
18
+ ];
19
+ export const eventsFilePath = () => path.join(devboardDataDir(), "hook-events.jsonl");
20
+ /** Bash script Claude runs per hook event; the events path is baked in. */
21
+ function hookScriptContent(eventsPath) {
22
+ return [
23
+ "#!/usr/bin/env bash",
24
+ "# Managed by DevBoard. Edits here will be overwritten.",
25
+ "set -u",
26
+ `EVENTS_FILE='${eventsPath.replaceAll("'", "'\\''")}'`,
27
+ 'mkdir -p "$(dirname "$EVENTS_FILE")" 2>/dev/null || true',
28
+ "PAYLOAD=$(cat)",
29
+ "TS=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)",
30
+ "# Single-line JSON envelope so the server can parse line-by-line.",
31
+ `printf '{"timestamp":"%s","payload":%s}\\n' "$TS" "$PAYLOAD" >> "$EVENTS_FILE"`,
32
+ "",
33
+ ].join("\n");
34
+ }
35
+ /** Merge our hook command into a Claude settings object, idempotently. */
36
+ export function mergeHooksIntoSettings(data, hookCmd) {
37
+ data ??= {};
38
+ const hooks = (data.hooks ??= {});
39
+ for (const ev of HOOK_EVENTS) {
40
+ const groups = (hooks[ev] ??= []);
41
+ if (groups.length === 0)
42
+ groups.push({ hooks: [] });
43
+ const inner = (groups[0].hooks ??= []);
44
+ if (!inner.some((h) => h?.command === hookCmd))
45
+ inner.push({ type: "command", command: hookCmd });
46
+ }
47
+ return data;
48
+ }
49
+ // The merge, run inside WSL where $HOME is the user's Linux home. python3 is
50
+ // present on every modern distro; if it's missing we warn instead of failing.
51
+ const WSL_MERGE_PY = `
52
+ import json, os
53
+ events = ${JSON.stringify(HOOK_EVENTS)}
54
+ hook_cmd = os.path.expanduser('~/.devboard/hook.sh')
55
+ path = os.path.join(os.path.expanduser('~'), '.claude', 'settings.json')
56
+ os.makedirs(os.path.dirname(path), exist_ok=True)
57
+ try:
58
+ with open(path) as f:
59
+ data = json.load(f)
60
+ except FileNotFoundError:
61
+ data = {}
62
+ except json.JSONDecodeError:
63
+ os.rename(path, path + '.bak')
64
+ data = {}
65
+ hooks = data.setdefault('hooks', {})
66
+ for ev in events:
67
+ groups = hooks.setdefault(ev, [])
68
+ if not groups:
69
+ groups.append({'hooks': []})
70
+ inner = groups[0].setdefault('hooks', [])
71
+ if not any(h.get('command') == hook_cmd for h in inner):
72
+ inner.append({'type': 'command', 'command': hook_cmd})
73
+ with open(path, 'w') as f:
74
+ json.dump(data, f, indent=2)
75
+ print('ok')
76
+ `;
77
+ /** Native install: script + settings.json on this machine's own filesystem. */
78
+ async function installNative() {
79
+ // Claude Code on Windows runs hook commands through Git Bash, which wants forward slashes.
80
+ const fwd = (p) => (IS_WINDOWS ? p.replaceAll("\\", "/") : p);
81
+ const dir = path.join(os.homedir(), ".devboard");
82
+ const script = path.join(dir, "hook.sh");
83
+ await fs.promises.mkdir(dir, { recursive: true });
84
+ await fs.promises.writeFile(script, hookScriptContent(fwd(eventsFilePath())), { mode: 0o755 });
85
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
86
+ await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
87
+ let data = {};
88
+ try {
89
+ data = JSON.parse(await fs.promises.readFile(settingsPath, "utf8"));
90
+ }
91
+ catch (e) {
92
+ if (e?.code !== "ENOENT")
93
+ await fs.promises.rename(settingsPath, `${settingsPath}.bak`).catch(() => { });
94
+ }
95
+ await fs.promises.writeFile(settingsPath, JSON.stringify(mergeHooksIntoSettings(data, fwd(script)), null, 2));
96
+ log.info("hooks", `installed hook.sh + settings.json entries — events → ${eventsFilePath()}`);
97
+ }
98
+ /** WSL install (Windows only): the same script inside the distro. Resolves false when WSL isn't reachable. */
99
+ async function installWsl() {
100
+ if (!(await run("wsl.exe", ["--", "bash", "-lc", "true"], { timeoutMs: 20_000 })).ok) {
101
+ log.warn("hooks", "wsl.exe is not responding — skipping WSL hook install; will retry on next launch");
102
+ return false;
103
+ }
104
+ const eventsWsl = await windowsPathToWsl(eventsFilePath());
105
+ if (!eventsWsl)
106
+ throw new Error("could not translate events path to WSL");
107
+ const write = await run("wsl.exe", ["--", "bash", "-lc", "mkdir -p ~/.devboard && cat > ~/.devboard/hook.sh && chmod +x ~/.devboard/hook.sh"], { input: hookScriptContent(eventsWsl) });
108
+ if (!write.ok)
109
+ throw new Error(`hook.sh install failed: ${write.stderr.trim()}`);
110
+ const merge = await run("wsl.exe", ["--", "python3", "-"], { input: WSL_MERGE_PY });
111
+ if (!merge.ok) {
112
+ throw new Error(`settings.json merge failed (is python3 installed in WSL?): ${merge.stderr.trim()}`);
113
+ }
114
+ log.info("hooks", `installed hook.sh + settings.json entries in WSL — events → ${eventsWsl}`);
115
+ return true;
116
+ }
117
+ /** Install (or refresh) the hook script + settings entries. On Windows this covers both the Windows
118
+ * runtime (native) and WSL; resolves `false` only when nothing could be installed. */
119
+ export async function installIfNeeded() {
120
+ if (!IS_WINDOWS) {
121
+ await installNative();
122
+ return true;
123
+ }
124
+ let ok = false;
125
+ try {
126
+ await installNative();
127
+ ok = true;
128
+ }
129
+ catch (e) {
130
+ log.warn("hooks", `native hook install failed: ${e instanceof Error ? e.message : e}`);
131
+ }
132
+ try {
133
+ if (await installWsl())
134
+ ok = true;
135
+ }
136
+ catch (e) {
137
+ log.warn("hooks", `WSL hook install failed: ${e instanceof Error ? e.message : e}`);
138
+ }
139
+ return ok;
140
+ }
141
+ export function parseHookLine(line) {
142
+ let env;
143
+ try {
144
+ env = JSON.parse(line);
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ const payload = env?.payload;
150
+ if (!payload || typeof payload !== "object")
151
+ return null;
152
+ const str = (v) => (typeof v === "string" ? v : undefined);
153
+ const sessionId = str(payload.session_id) ?? str(payload.sessionId) ?? "";
154
+ const eventName = str(payload.hook_event_name) ?? str(payload.event_name) ?? "";
155
+ if (!sessionId && !eventName)
156
+ return null;
157
+ const ts = str(env.timestamp);
158
+ const parsed = ts ? Date.parse(ts) : NaN;
159
+ return {
160
+ sessionId,
161
+ eventName,
162
+ transcriptPath: str(payload.transcript_path),
163
+ notificationType: str(payload.notification_type) ?? str(payload.type),
164
+ timestamp: Number.isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString(),
165
+ };
166
+ }
167
+ /** Tails the events file from a persistent byte offset; never re-emits. */
168
+ export class HookEventStore {
169
+ offset = 0;
170
+ path = eventsFilePath();
171
+ touch() {
172
+ fs.mkdirSync(path.dirname(this.path), { recursive: true });
173
+ fs.closeSync(fs.openSync(this.path, "a"));
174
+ }
175
+ /** Jump to EOF so events from previous runs aren't replayed. */
176
+ skipToTail() {
177
+ try {
178
+ this.offset = fs.statSync(this.path).size;
179
+ }
180
+ catch {
181
+ // no file yet
182
+ }
183
+ }
184
+ async readNewEvents() {
185
+ let handle;
186
+ try {
187
+ handle = await fs.promises.open(this.path, "r");
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ try {
193
+ const { size } = await handle.stat();
194
+ if (size < this.offset)
195
+ this.offset = 0; // truncated
196
+ if (size === this.offset)
197
+ return [];
198
+ const buf = Buffer.alloc(size - this.offset);
199
+ await handle.read(buf, 0, buf.length, this.offset);
200
+ // Only consume complete lines; a half-written tail waits for the next tick.
201
+ const lastNl = buf.lastIndexOf(0x0a);
202
+ if (lastNl < 0)
203
+ return [];
204
+ this.offset += lastNl + 1;
205
+ const events = [];
206
+ for (const line of buf
207
+ .subarray(0, lastNl + 1)
208
+ .toString("utf8")
209
+ .split("\n")) {
210
+ if (!line.trim())
211
+ continue;
212
+ const ev = parseHookLine(line);
213
+ if (ev)
214
+ events.push(ev);
215
+ }
216
+ return events;
217
+ }
218
+ finally {
219
+ await handle.close();
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,266 @@
1
+ // HTTP + WebSocket front door: static UI, JSON-RPC (`/api/invoke/:cmd`),
2
+ // binary uploads, image serving, the event stream (`/ws`) and terminals (`/pty`).
3
+ //
4
+ // This server can spawn processes (PTY) and read files, so it is locked down:
5
+ // loopback-only by default, Host/Origin checks against DNS-rebinding and
6
+ // cross-site requests, and an optional shared token.
7
+ import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import http from "node:http";
10
+ import path from "node:path";
11
+ import { WebSocketServer } from "ws";
12
+ import { buildCommands, uploads } from "./commands.js";
13
+ import { subscribe } from "./events.js";
14
+ import { allowedImageRoots } from "./integrations.js";
15
+ import { handlePtySocket } from "./pty.js";
16
+ import { errorMessage, log } from "./util.js";
17
+ const MIME = {
18
+ ".html": "text/html; charset=utf-8",
19
+ ".js": "text/javascript; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8",
21
+ ".json": "application/json; charset=utf-8",
22
+ ".svg": "image/svg+xml",
23
+ ".png": "image/png",
24
+ ".jpg": "image/jpeg",
25
+ ".jpeg": "image/jpeg",
26
+ ".gif": "image/gif",
27
+ ".webp": "image/webp",
28
+ ".ico": "image/x-icon",
29
+ ".woff": "font/woff",
30
+ ".woff2": "font/woff2",
31
+ ".map": "application/json",
32
+ };
33
+ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]);
34
+ const MAX_JSON_BYTES = 10 * 1024 * 1024;
35
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
36
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
37
+ const hostnameOf = (hostHeader) => {
38
+ if (!hostHeader)
39
+ return "";
40
+ // [::1]:1421 → [::1]; localhost:1421 → localhost
41
+ const m = hostHeader.match(/^(\[[^\]]+\]|[^:]+)/);
42
+ return (m?.[1] ?? "").toLowerCase();
43
+ };
44
+ function readBody(req, limit) {
45
+ return new Promise((resolve, reject) => {
46
+ const chunks = [];
47
+ let size = 0;
48
+ req.on("data", (c) => {
49
+ size += c.length;
50
+ if (size > limit) {
51
+ reject(new Error("request body too large"));
52
+ req.destroy();
53
+ return;
54
+ }
55
+ chunks.push(c);
56
+ });
57
+ req.on("end", () => resolve(Buffer.concat(chunks)));
58
+ req.on("error", reject);
59
+ });
60
+ }
61
+ function sendJson(res, status, body) {
62
+ const data = JSON.stringify(body ?? null);
63
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
64
+ res.end(data);
65
+ }
66
+ const isUnder = (child, root) => {
67
+ const rel = path.relative(root, child);
68
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
69
+ };
70
+ export function startServer(ctx, opts) {
71
+ const commands = buildCommands(ctx);
72
+ const allowed = new Set([...LOOPBACK, ...opts.allowedHosts.map((h) => h.toLowerCase())]);
73
+ const tokenOk = (req, url) => {
74
+ if (!opts.token)
75
+ return true;
76
+ const expected = Buffer.from(opts.token);
77
+ const candidates = [
78
+ req.headers.authorization?.replace(/^Bearer\s+/i, ""),
79
+ /(?:^|;\s*)devboard_token=([^;]+)/.exec(req.headers.cookie ?? "")?.[1],
80
+ url.searchParams.get("token") ?? undefined,
81
+ ];
82
+ return candidates.some((c) => {
83
+ if (!c)
84
+ return false;
85
+ const got = Buffer.from(decodeURIComponent(c));
86
+ return got.length === expected.length && crypto.timingSafeEqual(got, expected);
87
+ });
88
+ };
89
+ /** Host must be ours (anti DNS-rebinding); a present Origin must match Host. */
90
+ const originOk = (req) => {
91
+ if (!allowed.has(hostnameOf(req.headers.host)))
92
+ return false;
93
+ const origin = req.headers.origin;
94
+ if (!origin)
95
+ return true;
96
+ try {
97
+ return new URL(origin).host.toLowerCase() === (req.headers.host ?? "").toLowerCase();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ };
103
+ const server = http.createServer(async (req, res) => {
104
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
105
+ try {
106
+ if (!originOk(req)) {
107
+ sendJson(res, 403, { error: "forbidden origin" });
108
+ return;
109
+ }
110
+ if (!tokenOk(req, url)) {
111
+ res.writeHead(401, { "content-type": "text/plain" });
112
+ res.end("Unauthorized — open the URL printed at startup (it carries ?token=…).");
113
+ return;
114
+ }
115
+ // A valid ?token= sets a cookie so the SPA's own requests are authorised.
116
+ if (opts.token && url.searchParams.get("token")) {
117
+ res.setHeader("set-cookie", `devboard_token=${encodeURIComponent(opts.token)}; Path=/; HttpOnly; SameSite=Strict`);
118
+ }
119
+ if (url.pathname.startsWith("/api/")) {
120
+ await handleApi(req, res, url);
121
+ return;
122
+ }
123
+ serveStatic(res, url);
124
+ }
125
+ catch (e) {
126
+ log.warn("http", `${req.method} ${url.pathname} failed: ${errorMessage(e)}`);
127
+ if (!res.headersSent)
128
+ sendJson(res, 500, { error: errorMessage(e) });
129
+ else
130
+ res.end();
131
+ }
132
+ });
133
+ async function handleApi(req, res, url) {
134
+ // JSON-RPC: POST /api/invoke/<command>
135
+ if (req.method === "POST" && url.pathname.startsWith("/api/invoke/")) {
136
+ // Requiring JSON forces a CORS preflight for cross-origin callers.
137
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
138
+ sendJson(res, 415, { error: "content-type must be application/json" });
139
+ return;
140
+ }
141
+ const name = decodeURIComponent(url.pathname.slice("/api/invoke/".length));
142
+ const cmd = Object.hasOwn(commands, name) ? commands[name] : undefined;
143
+ if (!cmd) {
144
+ sendJson(res, 404, { error: `unknown command: ${name}` });
145
+ return;
146
+ }
147
+ const raw = (await readBody(req, MAX_JSON_BYTES)).toString("utf8");
148
+ const args = raw ? JSON.parse(raw) : {};
149
+ try {
150
+ sendJson(res, 200, { result: (await cmd(args ?? {})) ?? null });
151
+ }
152
+ catch (e) {
153
+ log.debug("rpc", `${name} failed: ${errorMessage(e)}`);
154
+ sendJson(res, 500, { error: errorMessage(e) });
155
+ }
156
+ return;
157
+ }
158
+ // Binary uploads (pasted / picked images)
159
+ if (req.method === "POST" && url.pathname === "/api/upload/image") {
160
+ const bytes = await readBody(req, MAX_UPLOAD_BYTES);
161
+ sendJson(res, 200, { path: await uploads.image(bytes) });
162
+ return;
163
+ }
164
+ if (req.method === "POST" && url.pathname === "/api/upload/file") {
165
+ const bytes = await readBody(req, MAX_UPLOAD_BYTES);
166
+ sendJson(res, 200, { path: await uploads.file(bytes, url.searchParams.get("name") ?? "file") });
167
+ return;
168
+ }
169
+ // Image bytes for previews. Only image files under our own directories.
170
+ if (req.method === "GET" && url.pathname === "/api/file") {
171
+ const p = path.resolve(url.searchParams.get("path") ?? "");
172
+ const ext = path.extname(p).toLowerCase();
173
+ const ok = IMAGE_EXTS.has(ext) && allowedImageRoots().some((root) => isUnder(p, path.resolve(root)));
174
+ if (!ok) {
175
+ sendJson(res, 403, { error: "path not allowed" });
176
+ return;
177
+ }
178
+ const st = await fs.promises.stat(p).catch(() => null);
179
+ if (!st?.isFile() || st.size > MAX_UPLOAD_BYTES) {
180
+ sendJson(res, 404, { error: "not found" });
181
+ return;
182
+ }
183
+ res.writeHead(200, {
184
+ "content-type": MIME[ext] ?? "application/octet-stream",
185
+ "content-length": st.size,
186
+ "cache-control": "private, max-age=3600",
187
+ // SVGs can carry script; render them inert.
188
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
189
+ });
190
+ fs.createReadStream(p).pipe(res);
191
+ return;
192
+ }
193
+ sendJson(res, 404, { error: "not found" });
194
+ }
195
+ function serveStatic(res, url) {
196
+ const root = path.resolve(opts.distDir);
197
+ let rel = decodeURIComponent(url.pathname);
198
+ if (rel === "/")
199
+ rel = "/index.html";
200
+ let file = path.join(root, rel);
201
+ // Contain to dist/, and SPA-fallback for client-routed paths without an extension.
202
+ if (!isUnder(file, root) && file !== root) {
203
+ sendJson(res, 403, { error: "forbidden" });
204
+ return;
205
+ }
206
+ if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
207
+ if (path.extname(rel)) {
208
+ res.writeHead(404, { "content-type": "text/plain" });
209
+ res.end("Not found");
210
+ return;
211
+ }
212
+ file = path.join(root, "index.html");
213
+ }
214
+ if (!fs.existsSync(file)) {
215
+ res.writeHead(503, { "content-type": "text/plain" });
216
+ res.end("UI not built yet — run `npm run build` (or use `npm run dev`).");
217
+ return;
218
+ }
219
+ const ext = path.extname(file).toLowerCase();
220
+ res.writeHead(200, {
221
+ "content-type": MIME[ext] ?? "application/octet-stream",
222
+ // Hashed assets are immutable; the HTML shell must always revalidate.
223
+ "cache-control": rel.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache",
224
+ });
225
+ fs.createReadStream(file).pipe(res);
226
+ }
227
+ // ── WebSockets ─────────────────────────────────────────────────────────────
228
+ const events = new WebSocketServer({ noServer: true });
229
+ const ptys = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
230
+ const sockets = new Set();
231
+ subscribe((event, payload) => {
232
+ if (sockets.size === 0)
233
+ return;
234
+ const msg = JSON.stringify({ event, payload });
235
+ for (const ws of sockets)
236
+ if (ws.readyState === ws.OPEN)
237
+ ws.send(msg);
238
+ });
239
+ events.on("connection", (ws) => {
240
+ sockets.add(ws);
241
+ ws.on("close", () => sockets.delete(ws));
242
+ ws.on("error", () => sockets.delete(ws));
243
+ });
244
+ ptys.on("connection", handlePtySocket);
245
+ server.on("upgrade", (req, socket, head) => {
246
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
247
+ // Browsers always send Origin on WebSocket handshakes, so a missing or
248
+ // foreign one is rejected outright.
249
+ if (!req.headers.origin || !originOk(req) || !tokenOk(req, url)) {
250
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
251
+ socket.destroy();
252
+ return;
253
+ }
254
+ const target = url.pathname === "/ws" ? events : url.pathname === "/pty" ? ptys : null;
255
+ if (!target) {
256
+ socket.destroy();
257
+ return;
258
+ }
259
+ target.handleUpgrade(req, socket, head, (ws) => target.emit("connection", ws, req));
260
+ });
261
+ server.listen(opts.port, opts.host, () => {
262
+ const shown = opts.host === "0.0.0.0" || opts.host === "::" ? "localhost" : opts.host;
263
+ log.info("http", `DevBoard listening on http://${shown}:${opts.port}${opts.token ? "/?token=…" : ""}`);
264
+ });
265
+ return server;
266
+ }
@@ -0,0 +1,77 @@
1
+ // DevBoard server entry point.
2
+ //
3
+ // DEVBOARD_HOST bind address (default 127.0.0.1)
4
+ // DEVBOARD_PORT listen port (default 1421)
5
+ // DEVBOARD_TOKEN shared secret (required when not on loopback)
6
+ // DEVBOARD_ALLOWED_HOSTS extra Host names, comma-separated (e.g. a LAN/VPN name)
7
+ // DEVBOARD_DATA_DIR override the data directory
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { linkDevboardSkill } from "./skill.js";
12
+ import { createContext } from "./context.js";
13
+ import { deployRemoteShell } from "./remote.js";
14
+ import { startServer } from "./http.js";
15
+ import { startPollers } from "./pollers.js";
16
+ import { errorMessage, devboardDataDir, log } from "./util.js";
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ // Package root: the nearest ancestor with a package.json (works from `server/` and from the compiled `server-dist/server/`).
19
+ const pkgRoot = (() => {
20
+ let dir = here;
21
+ while (!fs.existsSync(path.join(dir, "package.json")) && path.dirname(dir) !== dir)
22
+ dir = path.dirname(dir);
23
+ return dir;
24
+ })();
25
+ // Published package: the skill ships in `<pkg>/.claude`. In the repo it lives one level higher.
26
+ const repoRoot = fs.existsSync(path.join(pkgRoot, ".claude")) ? pkgRoot : path.resolve(pkgRoot, "..");
27
+ const host = process.env.DEVBOARD_HOST ?? "127.0.0.1";
28
+ const port = Number(process.env.DEVBOARD_PORT ?? 1421);
29
+ const token = process.env.DEVBOARD_TOKEN || undefined;
30
+ const loopback = ["127.0.0.1", "::1", "localhost"].includes(host);
31
+ if (!loopback && !token) {
32
+ console.error(`Refusing to listen on ${host} without DEVBOARD_TOKEN: this server can run commands and read files.\n` +
33
+ "Set DEVBOARD_TOKEN to a long random string, or bind to 127.0.0.1.");
34
+ process.exit(1);
35
+ }
36
+ process.on("unhandledRejection", (e) => log.error("process", `unhandled rejection: ${errorMessage(e)}`));
37
+ process.on("uncaughtException", (e) => log.error("process", `uncaught exception: ${errorMessage(e)}`));
38
+ log.info("startup", `DevBoard (web) starting; data dir: ${devboardDataDir()}`);
39
+ // Hooks run on this machine: loopback, unless the server is bound to one
40
+ // specific address (that address must then be in DEVBOARD_ALLOWED_HOSTS).
41
+ const hookHost = loopback || host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
42
+ const ctx = createContext({ serverUrl: `http://${hookHost}:${port}`, token });
43
+ linkDevboardSkill(repoRoot);
44
+ void deployRemoteShell().catch((e) => log.warn("startup", `remote-shell deploy failed: ${errorMessage(e)}`));
45
+ const server = startServer(ctx, {
46
+ host,
47
+ port,
48
+ token,
49
+ distDir: path.join(pkgRoot, "dist"),
50
+ allowedHosts: (process.env.DEVBOARD_ALLOWED_HOSTS ?? "")
51
+ .split(",")
52
+ .map((s) => s.trim())
53
+ .filter(Boolean),
54
+ });
55
+ startPollers(ctx);
56
+ // Close the knowledge-base SQLite handles (checkpoints the WAL) before exiting on Ctrl+C / kill.
57
+ // Pollers are not stopped: the process is exiting, and each index write is one synchronous transaction.
58
+ let shuttingDown = false;
59
+ const shutdown = (signal) => {
60
+ if (shuttingDown)
61
+ return;
62
+ shuttingDown = true;
63
+ log.info("process", `${signal}: shutting down`);
64
+ // Hard stop in case a connection or the index refuses to close.
65
+ setTimeout(() => process.exit(1), 3000).unref();
66
+ server.close();
67
+ server.closeAllConnections();
68
+ try {
69
+ ctx.harness.index.close();
70
+ }
71
+ catch (e) {
72
+ log.warn("process", `index close failed: ${errorMessage(e)}`);
73
+ }
74
+ process.exit(0);
75
+ };
76
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
77
+ process.on(signal, () => shutdown(signal));