@citadel-labs/beads-ui 2.4.0 → 2.5.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.
Files changed (97) hide show
  1. package/README.md +6 -3
  2. package/bin/beads-board.js +173 -183
  3. package/package.json +2 -2
  4. package/server/__tests__/api.test.js +241 -2
  5. package/server/__tests__/pidfile.test.js +99 -0
  6. package/server/__tests__/terminal.test.js +339 -0
  7. package/server/dist/assets/index-Dm1YZe0A.css +1 -0
  8. package/server/dist/assets/index-G6bcoKqz.js +232 -0
  9. package/server/dist/index.html +2 -2
  10. package/server/handlers.js +167 -57
  11. package/server/index.js +5 -26
  12. package/server/pidfile.js +71 -0
  13. package/server/terminal-sessions.js +149 -0
  14. package/server/terminal.js +132 -31
  15. package/terminal-session-01-initial.png +0 -0
  16. package/terminal-session-02-before-refresh.png +0 -0
  17. package/terminal-session-03-after-refresh.png +0 -0
  18. package/.claude/worktrees/agent-a7b97047/LICENSE +0 -21
  19. package/.claude/worktrees/agent-a7b97047/README.md +0 -63
  20. package/.claude/worktrees/agent-a7b97047/bin/beads-board.js +0 -183
  21. package/.claude/worktrees/agent-a7b97047/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  22. package/.claude/worktrees/agent-a7b97047/package-lock.json +0 -1752
  23. package/.claude/worktrees/agent-a7b97047/package.json +0 -43
  24. package/.claude/worktrees/agent-a7b97047/server/__tests__/api.test.js +0 -206
  25. package/.claude/worktrees/agent-a7b97047/server/dist/assets/angular-html-DA-rfuFy.js +0 -1
  26. package/.claude/worktrees/agent-a7b97047/server/dist/assets/angular-ts-BrjP3tb8.js +0 -1
  27. package/.claude/worktrees/agent-a7b97047/server/dist/assets/c-BIGW1oBm.js +0 -1
  28. package/.claude/worktrees/agent-a7b97047/server/dist/assets/cpp-BRuaLJcg.js +0 -1
  29. package/.claude/worktrees/agent-a7b97047/server/dist/assets/csharp-COcwbKMJ.js +0 -1
  30. package/.claude/worktrees/agent-a7b97047/server/dist/assets/css-CLj8gQPS.js +0 -1
  31. package/.claude/worktrees/agent-a7b97047/server/dist/assets/dockerfile-BcOcwvcX.js +0 -1
  32. package/.claude/worktrees/agent-a7b97047/server/dist/assets/dotenv-Da5cRb03.js +0 -1
  33. package/.claude/worktrees/agent-a7b97047/server/dist/assets/github-dark-DHJKELXO.js +0 -1
  34. package/.claude/worktrees/agent-a7b97047/server/dist/assets/go-C27-OAKa.js +0 -1
  35. package/.claude/worktrees/agent-a7b97047/server/dist/assets/graphql-ChdNCCLP.js +0 -1
  36. package/.claude/worktrees/agent-a7b97047/server/dist/assets/html-derivative-C6UeqQa8.js +0 -1
  37. package/.claude/worktrees/agent-a7b97047/server/dist/assets/html-pp8916En.js +0 -1
  38. package/.claude/worktrees/agent-a7b97047/server/dist/assets/http-l_GQhCeT.js +0 -1
  39. package/.claude/worktrees/agent-a7b97047/server/dist/assets/index-BBBYE21N.css +0 -1
  40. package/.claude/worktrees/agent-a7b97047/server/dist/assets/index-cZFE6wf9.js +0 -231
  41. package/.claude/worktrees/agent-a7b97047/server/dist/assets/ini-BEwlwnbL.js +0 -1
  42. package/.claude/worktrees/agent-a7b97047/server/dist/assets/java-CylS5w8V.js +0 -1
  43. package/.claude/worktrees/agent-a7b97047/server/dist/assets/javascript-wDzz0qaB.js +0 -1
  44. package/.claude/worktrees/agent-a7b97047/server/dist/assets/json-Cp-IABpG.js +0 -1
  45. package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsonc-Des-eS-w.js +0 -1
  46. package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsonl-DcaNXYhu.js +0 -1
  47. package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsx-g9-lgVsj.js +0 -1
  48. package/.claude/worktrees/agent-a7b97047/server/dist/assets/kotlin-BdnUsdx6.js +0 -1
  49. package/.claude/worktrees/agent-a7b97047/server/dist/assets/kusto-wEQ09or8.js +0 -1
  50. package/.claude/worktrees/agent-a7b97047/server/dist/assets/latex-DdMFrP5M.js +0 -1
  51. package/.claude/worktrees/agent-a7b97047/server/dist/assets/markdown-Cvjx9yec.js +0 -1
  52. package/.claude/worktrees/agent-a7b97047/server/dist/assets/mdc-Dz5ISc6g.js +0 -1
  53. package/.claude/worktrees/agent-a7b97047/server/dist/assets/mdx-Cmh6b_Ma.js +0 -1
  54. package/.claude/worktrees/agent-a7b97047/server/dist/assets/mermaid-mWjccvbQ.js +0 -1
  55. package/.claude/worktrees/agent-a7b97047/server/dist/assets/php-R6g_5hLQ.js +0 -1
  56. package/.claude/worktrees/agent-a7b97047/server/dist/assets/powershell-Dpen1YoG.js +0 -1
  57. package/.claude/worktrees/agent-a7b97047/server/dist/assets/python-B6aJPvgy.js +0 -1
  58. package/.claude/worktrees/agent-a7b97047/server/dist/assets/ruby-AcS3PBV-.js +0 -1
  59. package/.claude/worktrees/agent-a7b97047/server/dist/assets/rust-B1yitclQ.js +0 -1
  60. package/.claude/worktrees/agent-a7b97047/server/dist/assets/sass-Cj5Yp3dK.js +0 -1
  61. package/.claude/worktrees/agent-a7b97047/server/dist/assets/scss-D5BDwBP9.js +0 -1
  62. package/.claude/worktrees/agent-a7b97047/server/dist/assets/shellscript-DfDnw5Jg.js +0 -1
  63. package/.claude/worktrees/agent-a7b97047/server/dist/assets/sql-BLtJtn59.js +0 -1
  64. package/.claude/worktrees/agent-a7b97047/server/dist/assets/svelte-DR4MIrkg.js +0 -1
  65. package/.claude/worktrees/agent-a7b97047/server/dist/assets/swift-D82vCrfD.js +0 -1
  66. package/.claude/worktrees/agent-a7b97047/server/dist/assets/toml-vGWfd6FD.js +0 -1
  67. package/.claude/worktrees/agent-a7b97047/server/dist/assets/tsx-COt5Ahok.js +0 -1
  68. package/.claude/worktrees/agent-a7b97047/server/dist/assets/typescript-BPQ3VLAy.js +0 -1
  69. package/.claude/worktrees/agent-a7b97047/server/dist/assets/vue-CJgBXYWu.js +0 -1
  70. package/.claude/worktrees/agent-a7b97047/server/dist/assets/xml-sdJ4AIDG.js +0 -1
  71. package/.claude/worktrees/agent-a7b97047/server/dist/assets/yaml-Buea-lGh.js +0 -1
  72. package/.claude/worktrees/agent-a7b97047/server/dist/assets/zig-VOosw3JB.js +0 -1
  73. package/.claude/worktrees/agent-a7b97047/server/dist/favicon.svg +0 -103
  74. package/.claude/worktrees/agent-a7b97047/server/dist/index.html +0 -14
  75. package/.claude/worktrees/agent-a7b97047/server/dist/vite.svg +0 -1
  76. package/.claude/worktrees/agent-a7b97047/server/handlers.js +0 -357
  77. package/.claude/worktrees/agent-a7b97047/server/index.js +0 -88
  78. package/.claude/worktrees/agent-a7b97047/server/terminal.js +0 -103
  79. package/.claude/worktrees/agent-a7b97047/vitest.config.mjs +0 -8
  80. package/.claude/worktrees/agent-a952bde8/LICENSE +0 -21
  81. package/.claude/worktrees/agent-a952bde8/README.md +0 -63
  82. package/.claude/worktrees/agent-a952bde8/bin/beads-board.js +0 -183
  83. package/.claude/worktrees/agent-a952bde8/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  84. package/.claude/worktrees/agent-a952bde8/package-lock.json +0 -1752
  85. package/.claude/worktrees/agent-a952bde8/package.json +0 -43
  86. package/.claude/worktrees/agent-a952bde8/server/__tests__/api.test.js +0 -122
  87. package/.claude/worktrees/agent-a952bde8/server/dist/assets/index-BsWRmNbj.js +0 -79
  88. package/.claude/worktrees/agent-a952bde8/server/dist/assets/index-C7JKZkTD.css +0 -1
  89. package/.claude/worktrees/agent-a952bde8/server/dist/favicon.svg +0 -103
  90. package/.claude/worktrees/agent-a952bde8/server/dist/index.html +0 -14
  91. package/.claude/worktrees/agent-a952bde8/server/dist/vite.svg +0 -1
  92. package/.claude/worktrees/agent-a952bde8/server/handlers.js +0 -269
  93. package/.claude/worktrees/agent-a952bde8/server/index.js +0 -88
  94. package/.claude/worktrees/agent-a952bde8/server/terminal.js +0 -103
  95. package/.claude/worktrees/agent-a952bde8/vitest.config.mjs +0 -8
  96. package/server/dist/assets/index-DOFQi_E1.js +0 -231
  97. package/server/dist/assets/index-DeppoR8O.css +0 -1
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Beads Board</title>
8
- <script type="module" crossorigin src="/assets/index-DOFQi_E1.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DeppoR8O.css">
8
+ <script type="module" crossorigin src="/assets/index-G6bcoKqz.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Dm1YZe0A.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -6,6 +6,8 @@ const path = require('node:path');
6
6
  // MIME types
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
+ const FILES_IGNORED = new Set(['.git']);
10
+
9
11
  const MIME_TYPES = {
10
12
  '.html': 'text/html',
11
13
  '.js': 'application/javascript',
@@ -68,6 +70,16 @@ function normalizeIssue(issue) {
68
70
  // JSON response helpers
69
71
  // ---------------------------------------------------------------------------
70
72
 
73
+ function readJsonBody(req) {
74
+ return new Promise((resolve) => {
75
+ let data = '';
76
+ req.on('data', chunk => { data += chunk; });
77
+ req.on('end', () => {
78
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
79
+ });
80
+ });
81
+ }
82
+
71
83
  function jsonResponse(res, data, status = 200) {
72
84
  res.writeHead(status, {
73
85
  'Content-Type': 'application/json',
@@ -92,7 +104,7 @@ function createRequestHandler(projectDir, distDir) {
92
104
  if (req.method === 'OPTIONS') {
93
105
  res.writeHead(204, {
94
106
  'Access-Control-Allow-Origin': '*',
95
- 'Access-Control-Allow-Methods': 'GET, OPTIONS',
107
+ 'Access-Control-Allow-Methods': 'GET, PATCH, OPTIONS',
96
108
  'Access-Control-Allow-Headers': 'Content-Type',
97
109
  });
98
110
  res.end();
@@ -109,6 +121,19 @@ function createRequestHandler(projectDir, distDir) {
109
121
  } else if (pathname === '/api/blocked') {
110
122
  const blocked = await execBd(['blocked'], projectDir);
111
123
  jsonResponse(res, Array.isArray(blocked) ? blocked.map(normalizeIssue) : blocked);
124
+ } else if (pathname.startsWith('/api/issue/') && req.method === 'PATCH') {
125
+ const id = pathname.split('/api/issue/')[1];
126
+ if (!id || !/^[\w.\-]+$/.test(id)) {
127
+ errorResponse(res, 'Invalid issue ID', 400);
128
+ return;
129
+ }
130
+ const body = await readJsonBody(req);
131
+ if (!body || typeof body.description !== 'string') {
132
+ errorResponse(res, 'Missing required field: description', 400);
133
+ return;
134
+ }
135
+ await execCmd('bd', ['update', id, '--description', body.description], projectDir);
136
+ jsonResponse(res, { ok: true });
112
137
  } else if (pathname.startsWith('/api/issue/')) {
113
138
  const id = pathname.split('/api/issue/')[1];
114
139
  if (!id || !/^[\w.\-]+$/.test(id)) {
@@ -181,32 +206,63 @@ function createRequestHandler(projectDir, distDir) {
181
206
  jsonResponse(res, []);
182
207
  }
183
208
  } else if (pathname === '/api/git-status') {
184
- const stdout = await execGit(['status', '--porcelain'], projectDir);
185
- const files = stdout.replace(/\n$/, '').split('\n').filter(Boolean).map(line => {
186
- const match = line.match(/^(..)[ ](.+)$/);
187
- if (!match) return { status: '?', path: line.trim() };
188
- return { status: match[1].trim(), path: match[2] };
189
- });
190
- jsonResponse(res, files);
209
+ const branch = parsedUrl.searchParams.get('branch') || '';
210
+ if (branch && !/^[\w\/.@{}-]+$/.test(branch)) {
211
+ errorResponse(res, 'Invalid branch name', 400);
212
+ return;
213
+ }
214
+ if (branch) {
215
+ // Show what changed between the selected branch and the working tree
216
+ // (i.e. "what would change if you merged this branch into your working state")
217
+ const stdout = await execGit(['diff', '--name-status', branch], projectDir);
218
+ const files = stdout.replace(/\n$/, '').split('\n').filter(Boolean).map(line => {
219
+ const match = line.match(/^([A-Z])\t(.+)$/);
220
+ if (!match) return { status: '?', path: line.trim() };
221
+ return { status: match[1], path: match[2] };
222
+ });
223
+ jsonResponse(res, files);
224
+ } else {
225
+ const stdout = await execGit(['status', '--porcelain'], projectDir);
226
+ const files = stdout.replace(/\n$/, '').split('\n').filter(Boolean).map(line => {
227
+ const match = line.match(/^(..)[ ](.+)$/);
228
+ if (!match) return { status: '?', path: line.trim() };
229
+ return { status: match[1].trim(), path: match[2] };
230
+ });
231
+ jsonResponse(res, files);
232
+ }
191
233
  } else if (pathname === '/api/git-diff') {
192
234
  const file = parsedUrl.searchParams.get('file') || '';
235
+ const branch = parsedUrl.searchParams.get('branch') || '';
193
236
  if (!file || /[;&|`$]/.test(file)) {
194
237
  errorResponse(res, 'Invalid file path', 400);
195
238
  return;
196
239
  }
240
+ if (branch && !/^[\w\/.@{}-]+$/.test(branch)) {
241
+ errorResponse(res, 'Invalid branch name', 400);
242
+ return;
243
+ }
197
244
  try {
198
- // Try staged + unstaged diff first, fall back to untracked file content
199
245
  let diff;
200
- try {
201
- diff = await execGit(['diff', 'HEAD', '--', file], projectDir);
202
- if (!diff.trim()) {
203
- diff = await execGit(['diff', '--', file], projectDir);
246
+ if (branch) {
247
+ // Diff working tree against the specified branch for this file
248
+ try {
249
+ diff = await execGit(['diff', branch, '--', file], projectDir);
250
+ } catch {
251
+ diff = '';
252
+ }
253
+ } else {
254
+ // Try staged + unstaged diff first, fall back to untracked file content
255
+ try {
256
+ diff = await execGit(['diff', 'HEAD', '--', file], projectDir);
257
+ if (!diff.trim()) {
258
+ diff = await execGit(['diff', '--', file], projectDir);
259
+ }
260
+ } catch {
261
+ diff = '';
204
262
  }
205
- } catch {
206
- diff = '';
207
263
  }
208
- if (!diff.trim()) {
209
- // Untracked file — show full content as addition
264
+ if (!diff.trim() && !branch) {
265
+ // Untracked file — show full content as addition (only for current branch)
210
266
  try {
211
267
  const content = fs.readFileSync(
212
268
  path.join(projectDir, file), 'utf8'
@@ -223,14 +279,13 @@ function createRequestHandler(projectDir, distDir) {
223
279
  }
224
280
  } else if (pathname === '/api/file-content') {
225
281
  const relPath = parsedUrl.searchParams.get('path') || '';
282
+ const branch = parsedUrl.searchParams.get('branch') || '';
226
283
  if (!relPath || relPath.includes('..') || path.isAbsolute(relPath)) {
227
284
  errorResponse(res, 'Invalid path', 400);
228
285
  return;
229
286
  }
230
- const targetFile = path.join(projectDir, relPath);
231
- const resolved = path.resolve(targetFile);
232
- if (!resolved.startsWith(path.resolve(projectDir))) {
233
- errorResponse(res, 'Invalid path', 400);
287
+ if (branch && !/^[\w\/.@{}-]+$/.test(branch)) {
288
+ errorResponse(res, 'Invalid branch name', 400);
234
289
  return;
235
290
  }
236
291
  const EXT_TO_LANG = {
@@ -257,56 +312,111 @@ function createRequestHandler(projectDir, distDir) {
257
312
  '.mermaid': 'mermaid', '.mmd': 'mermaid',
258
313
  '.kusto': 'kusto', '.kql': 'kusto',
259
314
  };
260
- try {
261
- const content = fs.readFileSync(resolved, 'utf8');
262
- const ext = path.extname(relPath).toLowerCase();
263
- const language = EXT_TO_LANG[ext] || 'text';
264
- jsonResponse(res, { path: relPath, content, language });
265
- } catch (err) {
266
- if (err.code === 'ENOENT' || err.code === 'EISDIR') {
267
- errorResponse(res, 'File not found', 404);
268
- } else {
315
+ const ext = path.extname(relPath).toLowerCase();
316
+ const language = EXT_TO_LANG[ext] || 'text';
317
+ if (branch) {
318
+ // git show sandboxes to the repo; relPath already validated against .. and absolute
319
+ try {
320
+ const content = await execGit(['show', `${branch}:${relPath}`], projectDir);
321
+ jsonResponse(res, { path: relPath, content, language });
322
+ } catch (err) {
269
323
  errorResponse(res, err.message);
270
324
  }
325
+ } else {
326
+ const targetFile = path.join(projectDir, relPath);
327
+ const resolved = path.resolve(targetFile);
328
+ if (!resolved.startsWith(path.resolve(projectDir))) {
329
+ errorResponse(res, 'Invalid path', 400);
330
+ return;
331
+ }
332
+ try {
333
+ const content = fs.readFileSync(resolved, 'utf8');
334
+ jsonResponse(res, { path: relPath, content, language });
335
+ } catch (err) {
336
+ if (err.code === 'ENOENT' || err.code === 'EISDIR') {
337
+ errorResponse(res, 'File not found', 404);
338
+ } else {
339
+ errorResponse(res, err.message);
340
+ }
341
+ }
271
342
  }
272
343
  } else if (pathname === '/api/files') {
273
344
  const relPath = parsedUrl.searchParams.get('path') || '';
345
+ const branch = parsedUrl.searchParams.get('branch') || '';
274
346
  // Block path traversal
275
347
  if (relPath.includes('..') || path.isAbsolute(relPath)) {
276
348
  errorResponse(res, 'Invalid path', 400);
277
349
  return;
278
350
  }
279
- const targetDir = path.join(projectDir, relPath);
280
- const resolved = path.resolve(targetDir);
281
- if (!resolved.startsWith(path.resolve(projectDir))) {
282
- errorResponse(res, 'Invalid path', 400);
351
+ if (branch && !/^[\w\/.@{}-]+$/.test(branch)) {
352
+ errorResponse(res, 'Invalid branch name', 400);
283
353
  return;
284
354
  }
285
- const IGNORED = new Set(['.git', 'node_modules', '.beads', '.claude', '.playwright-mcp', 'coverage', '.vscode']);
286
- try {
287
- const entries = fs.readdirSync(targetDir, { withFileTypes: true });
288
- const results = [];
289
- for (const entry of entries) {
290
- // Skip hidden files/dirs and common ignored directories
291
- if (entry.name.startsWith('.') || IGNORED.has(entry.name)) continue;
292
- const entryPath = relPath ? `${relPath}/${entry.name}` : entry.name;
293
- results.push({
294
- name: entry.name,
295
- type: entry.isDirectory() ? 'directory' : 'file',
296
- path: entryPath,
355
+ if (branch) {
356
+ // Use git ls-tree to list files from the specified branch
357
+ try {
358
+ const treeRef = relPath ? `${branch}:${relPath}` : branch;
359
+ const stdout = await execGit(['ls-tree', treeRef], projectDir);
360
+ const results = [];
361
+ for (const line of stdout.trim().split('\n').filter(Boolean)) {
362
+ // Format: <mode> <type> <hash>\t<name>
363
+ const match = line.match(/^\d+\s+(blob|tree)\s+[a-f0-9]+\t(.+)$/);
364
+ if (!match) continue;
365
+ const name = match[2];
366
+ if (FILES_IGNORED.has(name)) continue;
367
+ const entryPath = relPath ? `${relPath}/${name}` : name;
368
+ results.push({
369
+ name,
370
+ type: match[1] === 'tree' ? 'directory' : 'file',
371
+ path: entryPath,
372
+ });
373
+ }
374
+ results.sort((a, b) => {
375
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
376
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
297
377
  });
378
+ jsonResponse(res, results);
379
+ } catch (err) {
380
+ // git ls-tree fails when branch or path doesn't exist
381
+ const msg = err.message || '';
382
+ if (msg.includes('Not a valid object') || msg.includes('does not exist')) {
383
+ errorResponse(res, 'Directory not found', 404);
384
+ } else {
385
+ errorResponse(res, msg);
386
+ }
298
387
  }
299
- // Sort: directories first, then alphabetically within each group
300
- results.sort((a, b) => {
301
- if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
302
- return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
303
- });
304
- jsonResponse(res, results);
305
- } catch (err) {
306
- if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
307
- errorResponse(res, 'Directory not found', 404);
308
- } else {
309
- errorResponse(res, err.message);
388
+ } else {
389
+ const targetDir = path.join(projectDir, relPath);
390
+ const resolved = path.resolve(targetDir);
391
+ if (!resolved.startsWith(path.resolve(projectDir))) {
392
+ errorResponse(res, 'Invalid path', 400);
393
+ return;
394
+ }
395
+ try {
396
+ const entries = fs.readdirSync(targetDir, { withFileTypes: true });
397
+ const results = [];
398
+ for (const entry of entries) {
399
+ // Skip ignored dirs (.git, node_modules, coverage)
400
+ if (FILES_IGNORED.has(entry.name)) continue;
401
+ const entryPath = relPath ? `${relPath}/${entry.name}` : entry.name;
402
+ results.push({
403
+ name: entry.name,
404
+ type: entry.isDirectory() ? 'directory' : 'file',
405
+ path: entryPath,
406
+ });
407
+ }
408
+ // Sort: directories first, then alphabetically within each group
409
+ results.sort((a, b) => {
410
+ if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
411
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
412
+ });
413
+ jsonResponse(res, results);
414
+ } catch (err) {
415
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
416
+ errorResponse(res, 'Directory not found', 404);
417
+ } else {
418
+ errorResponse(res, err.message);
419
+ }
310
420
  }
311
421
  }
312
422
  } else if (pathname === '/api/branches') {
package/server/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  const http = require('node:http');
2
- const fs = require('node:fs');
3
2
  const path = require('node:path');
4
3
  const { attachTerminal, cleanupAllPtys } = require('./terminal.js');
5
4
  const { createRequestHandler } = require('./handlers.js');
5
+ const { createPidfileManager } = require('./pidfile.js');
6
6
 
7
7
  const DEFAULT_PORT = 8377;
8
8
  const DIST_DIR = path.join(__dirname, 'dist');
@@ -14,27 +14,7 @@ const PROJECT_DIR = process.argv[2] || process.cwd();
14
14
  // Pidfile management
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
- const PIDFILE = path.join(PROJECT_DIR, '.beads-board.pid');
18
-
19
- function writePidfile(port) {
20
- fs.writeFileSync(PIDFILE, JSON.stringify({ pid: process.pid, port }));
21
- }
22
-
23
- function removePidfile() {
24
- try { fs.unlinkSync(PIDFILE); } catch {}
25
- }
26
-
27
- function getRunningInstance() {
28
- try {
29
- const data = JSON.parse(fs.readFileSync(PIDFILE, 'utf8'));
30
- // Check if process is still running
31
- process.kill(data.pid, 0);
32
- return data;
33
- } catch {
34
- removePidfile();
35
- return null;
36
- }
37
- }
17
+ const pidfile = createPidfileManager(PROJECT_DIR);
38
18
 
39
19
  // ---------------------------------------------------------------------------
40
20
  // Port detection
@@ -69,7 +49,7 @@ if (terminalEnabled) {
69
49
  }
70
50
 
71
51
  async function start() {
72
- const existing = getRunningInstance();
52
+ const existing = pidfile.getRunningInstance();
73
53
  if (existing) {
74
54
  console.log(`beads-board already running at http://localhost:${existing.port}`);
75
55
  process.exit(0);
@@ -77,12 +57,11 @@ async function start() {
77
57
 
78
58
  const port = await findAvailablePort(parseInt(process.env.PORT || DEFAULT_PORT, 10));
79
59
  server.listen(port, () => {
80
- writePidfile(port);
60
+ pidfile.writePidfile(port);
81
61
  console.log(`beads-board server running at http://localhost:${port}`);
82
62
  });
83
63
  }
84
64
 
85
- process.on('SIGTERM', () => { cleanupAllPtys(); removePidfile(); process.exit(0); });
86
- process.on('SIGINT', () => { cleanupAllPtys(); removePidfile(); process.exit(0); });
65
+ pidfile.registerCleanupHandlers(cleanupAllPtys);
87
66
 
88
67
  start();
@@ -0,0 +1,71 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ /**
5
+ * Creates a pidfile manager for the given project directory.
6
+ * @param {string} projectDir - The project directory where .beads-board.pid lives.
7
+ * @returns {object} Pidfile management functions.
8
+ */
9
+ function createPidfileManager(projectDir) {
10
+ const PIDFILE = path.join(projectDir, '.beads-board.pid');
11
+
12
+ function writePidfile(port) {
13
+ fs.writeFileSync(PIDFILE, JSON.stringify({ pid: process.pid, port }));
14
+ }
15
+
16
+ function removePidfile() {
17
+ try { fs.unlinkSync(PIDFILE); } catch {}
18
+ }
19
+
20
+ function getRunningInstance() {
21
+ try {
22
+ const data = JSON.parse(fs.readFileSync(PIDFILE, 'utf8'));
23
+ if (!data.pid) {
24
+ removePidfile();
25
+ return null;
26
+ }
27
+ // Check if process is still running (signal 0 throws if not running)
28
+ process.kill(data.pid, 0);
29
+ return data;
30
+ } catch {
31
+ removePidfile();
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Registers process-level handlers to ensure the pidfile is removed on exit.
38
+ * Handles SIGTERM, SIGINT, uncaughtException, and unhandledRejection.
39
+ * @param {function} [onBeforeExit] - Optional callback to run before exiting (e.g., PTY cleanup).
40
+ */
41
+ function registerCleanupHandlers(onBeforeExit) {
42
+ function cleanup(exitCode) {
43
+ if (onBeforeExit) {
44
+ try { onBeforeExit(); } catch {}
45
+ }
46
+ removePidfile();
47
+ process.exit(exitCode || 0);
48
+ }
49
+
50
+ process.on('SIGTERM', () => cleanup(0));
51
+ process.on('SIGINT', () => cleanup(0));
52
+ process.on('uncaughtException', (err) => {
53
+ console.error('Uncaught exception:', err);
54
+ cleanup(1);
55
+ });
56
+ process.on('unhandledRejection', (reason) => {
57
+ console.error('Unhandled rejection:', reason);
58
+ cleanup(1);
59
+ });
60
+ }
61
+
62
+ return {
63
+ writePidfile,
64
+ removePidfile,
65
+ getRunningInstance,
66
+ registerCleanupHandlers,
67
+ get pidfilePath() { return PIDFILE; },
68
+ };
69
+ }
70
+
71
+ module.exports = { createPidfileManager };
@@ -0,0 +1,149 @@
1
+ const { randomUUID } = require('node:crypto');
2
+
3
+ const MAX_SCROLLBACK_CHARS = 100 * 1024; // ~100KB for ASCII terminal output
4
+ const MAX_SESSIONS = 10;
5
+ const DISCONNECT_TIMEOUT_MS = 60 * 1000; // 60 seconds
6
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7
+
8
+ /** @type {Map<string, Session>} */
9
+ const sessions = new Map();
10
+
11
+ /**
12
+ * @typedef {Object} Session
13
+ * @property {string} id
14
+ * @property {import('node-pty').IPty|null} pty
15
+ * @property {string[]} scrollback - ring buffer of output chunks
16
+ * @property {number} scrollbackChars - total character count
17
+ * @property {number} cols
18
+ * @property {number} rows
19
+ * @property {NodeJS.Timeout|null} disconnectTimer
20
+ * @property {WebSocket|null} ws
21
+ * @property {boolean} replaying
22
+ * @property {string[]} outputQueue
23
+ */
24
+
25
+ /**
26
+ * Create a new terminal session.
27
+ * @param {{ cols: number, rows: number, evictIdle?: boolean }} opts
28
+ * @returns {Session}
29
+ */
30
+ function createSession({ cols, rows, evictIdle = false }) {
31
+ if (sessions.size >= MAX_SESSIONS) {
32
+ if (evictIdle) {
33
+ // Find oldest idle session (no ws connection)
34
+ let oldestId = null;
35
+ for (const [id, s] of sessions) {
36
+ if (!s.ws) {
37
+ oldestId = id;
38
+ break; // first inserted = oldest (Map preserves insertion order)
39
+ }
40
+ }
41
+ if (oldestId) {
42
+ const old = sessions.get(oldestId);
43
+ if (old.disconnectTimer) clearTimeout(old.disconnectTimer);
44
+ if (old.pty) try { old.pty.kill(); } catch {}
45
+ sessions.delete(oldestId);
46
+ } else {
47
+ throw new Error('Session limit reached: all sessions are active');
48
+ }
49
+ } else {
50
+ throw new Error('Session limit reached');
51
+ }
52
+ }
53
+
54
+ const id = randomUUID();
55
+ /** @type {Session} */
56
+ const session = {
57
+ id,
58
+ pty: null,
59
+ scrollback: [],
60
+ scrollbackChars: 0,
61
+ cols,
62
+ rows,
63
+ disconnectTimer: null,
64
+ ws: null,
65
+ replaying: false,
66
+ outputQueue: [],
67
+ };
68
+ sessions.set(id, session);
69
+ return session;
70
+ }
71
+
72
+ /**
73
+ * Validate that a string is a UUID format.
74
+ * @param {string} id
75
+ * @returns {boolean}
76
+ */
77
+ function isValidSessionId(id) {
78
+ return UUID_RE.test(id);
79
+ }
80
+
81
+ /**
82
+ * Get a session by ID.
83
+ * @param {string} id
84
+ * @returns {Session|undefined}
85
+ */
86
+ function getSession(id) {
87
+ return sessions.get(id);
88
+ }
89
+
90
+ /**
91
+ * Delete a session by ID.
92
+ * @param {string} id
93
+ */
94
+ function deleteSession(id) {
95
+ const session = sessions.get(id);
96
+ if (session) {
97
+ if (session.disconnectTimer) clearTimeout(session.disconnectTimer);
98
+ sessions.delete(id);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Append data to a session's scrollback ring buffer.
104
+ * Trims from front when exceeding MAX_SCROLLBACK_CHARS.
105
+ * @param {Session} session
106
+ * @param {string} data
107
+ */
108
+ function appendScrollback(session, data) {
109
+ session.scrollback.push(data);
110
+ session.scrollbackChars += data.length;
111
+
112
+ // Trim from front if over limit
113
+ while (session.scrollbackChars > MAX_SCROLLBACK_CHARS && session.scrollback.length > 1) {
114
+ const removed = session.scrollback.shift();
115
+ session.scrollbackChars -= removed.length;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get the count of active sessions.
121
+ * @returns {number}
122
+ */
123
+ function getSessionCount() {
124
+ return sessions.size;
125
+ }
126
+
127
+ /**
128
+ * Clear all sessions (for testing/cleanup).
129
+ */
130
+ function clearAllSessions() {
131
+ for (const [, session] of sessions) {
132
+ if (session.disconnectTimer) clearTimeout(session.disconnectTimer);
133
+ if (session.pty) try { session.pty.kill(); } catch {}
134
+ }
135
+ sessions.clear();
136
+ }
137
+
138
+ module.exports = {
139
+ createSession,
140
+ getSession,
141
+ deleteSession,
142
+ isValidSessionId,
143
+ appendScrollback,
144
+ getSessionCount,
145
+ clearAllSessions,
146
+ MAX_SCROLLBACK_CHARS,
147
+ MAX_SESSIONS,
148
+ DISCONNECT_TIMEOUT_MS,
149
+ };