@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.
- package/README.md +6 -3
- package/bin/beads-board.js +173 -183
- package/package.json +2 -2
- package/server/__tests__/api.test.js +241 -2
- package/server/__tests__/pidfile.test.js +99 -0
- package/server/__tests__/terminal.test.js +339 -0
- package/server/dist/assets/index-Dm1YZe0A.css +1 -0
- package/server/dist/assets/index-G6bcoKqz.js +232 -0
- package/server/dist/index.html +2 -2
- package/server/handlers.js +167 -57
- package/server/index.js +5 -26
- package/server/pidfile.js +71 -0
- package/server/terminal-sessions.js +149 -0
- package/server/terminal.js +132 -31
- package/terminal-session-01-initial.png +0 -0
- package/terminal-session-02-before-refresh.png +0 -0
- package/terminal-session-03-after-refresh.png +0 -0
- package/.claude/worktrees/agent-a7b97047/LICENSE +0 -21
- package/.claude/worktrees/agent-a7b97047/README.md +0 -63
- package/.claude/worktrees/agent-a7b97047/bin/beads-board.js +0 -183
- package/.claude/worktrees/agent-a7b97047/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/.claude/worktrees/agent-a7b97047/package-lock.json +0 -1752
- package/.claude/worktrees/agent-a7b97047/package.json +0 -43
- package/.claude/worktrees/agent-a7b97047/server/__tests__/api.test.js +0 -206
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/angular-html-DA-rfuFy.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/angular-ts-BrjP3tb8.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/c-BIGW1oBm.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/cpp-BRuaLJcg.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/csharp-COcwbKMJ.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/css-CLj8gQPS.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/dockerfile-BcOcwvcX.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/dotenv-Da5cRb03.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/github-dark-DHJKELXO.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/go-C27-OAKa.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/graphql-ChdNCCLP.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/html-derivative-C6UeqQa8.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/html-pp8916En.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/http-l_GQhCeT.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/index-BBBYE21N.css +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/index-cZFE6wf9.js +0 -231
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/ini-BEwlwnbL.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/java-CylS5w8V.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/javascript-wDzz0qaB.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/json-Cp-IABpG.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsonc-Des-eS-w.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsonl-DcaNXYhu.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsx-g9-lgVsj.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/kotlin-BdnUsdx6.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/kusto-wEQ09or8.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/latex-DdMFrP5M.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/markdown-Cvjx9yec.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/mdc-Dz5ISc6g.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/mdx-Cmh6b_Ma.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/mermaid-mWjccvbQ.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/php-R6g_5hLQ.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/powershell-Dpen1YoG.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/python-B6aJPvgy.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/ruby-AcS3PBV-.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/rust-B1yitclQ.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/sass-Cj5Yp3dK.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/scss-D5BDwBP9.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/shellscript-DfDnw5Jg.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/sql-BLtJtn59.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/svelte-DR4MIrkg.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/swift-D82vCrfD.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/toml-vGWfd6FD.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/tsx-COt5Ahok.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/typescript-BPQ3VLAy.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/vue-CJgBXYWu.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/xml-sdJ4AIDG.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/yaml-Buea-lGh.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/zig-VOosw3JB.js +0 -1
- package/.claude/worktrees/agent-a7b97047/server/dist/favicon.svg +0 -103
- package/.claude/worktrees/agent-a7b97047/server/dist/index.html +0 -14
- package/.claude/worktrees/agent-a7b97047/server/dist/vite.svg +0 -1
- package/.claude/worktrees/agent-a7b97047/server/handlers.js +0 -357
- package/.claude/worktrees/agent-a7b97047/server/index.js +0 -88
- package/.claude/worktrees/agent-a7b97047/server/terminal.js +0 -103
- package/.claude/worktrees/agent-a7b97047/vitest.config.mjs +0 -8
- package/.claude/worktrees/agent-a952bde8/LICENSE +0 -21
- package/.claude/worktrees/agent-a952bde8/README.md +0 -63
- package/.claude/worktrees/agent-a952bde8/bin/beads-board.js +0 -183
- package/.claude/worktrees/agent-a952bde8/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/.claude/worktrees/agent-a952bde8/package-lock.json +0 -1752
- package/.claude/worktrees/agent-a952bde8/package.json +0 -43
- package/.claude/worktrees/agent-a952bde8/server/__tests__/api.test.js +0 -122
- package/.claude/worktrees/agent-a952bde8/server/dist/assets/index-BsWRmNbj.js +0 -79
- package/.claude/worktrees/agent-a952bde8/server/dist/assets/index-C7JKZkTD.css +0 -1
- package/.claude/worktrees/agent-a952bde8/server/dist/favicon.svg +0 -103
- package/.claude/worktrees/agent-a952bde8/server/dist/index.html +0 -14
- package/.claude/worktrees/agent-a952bde8/server/dist/vite.svg +0 -1
- package/.claude/worktrees/agent-a952bde8/server/handlers.js +0 -269
- package/.claude/worktrees/agent-a952bde8/server/index.js +0 -88
- package/.claude/worktrees/agent-a952bde8/server/terminal.js +0 -103
- package/.claude/worktrees/agent-a952bde8/vitest.config.mjs +0 -8
- package/server/dist/assets/index-DOFQi_E1.js +0 -231
- package/server/dist/assets/index-DeppoR8O.css +0 -1
package/server/dist/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/server/handlers.js
CHANGED
|
@@ -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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|