@citadel-labs/beads-ui 2.1.0 → 2.4.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/.claude/worktrees/agent-a7b97047/LICENSE +21 -0
- package/.claude/worktrees/agent-a7b97047/README.md +63 -0
- package/.claude/worktrees/agent-a7b97047/bin/beads-board.js +183 -0
- package/.claude/worktrees/agent-a7b97047/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/.claude/worktrees/agent-a7b97047/package-lock.json +1752 -0
- package/.claude/worktrees/agent-a7b97047/package.json +43 -0
- package/.claude/worktrees/agent-a7b97047/server/__tests__/api.test.js +206 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/angular-html-DA-rfuFy.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/angular-ts-BrjP3tb8.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/c-BIGW1oBm.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/cpp-BRuaLJcg.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/csharp-COcwbKMJ.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/css-CLj8gQPS.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/dockerfile-BcOcwvcX.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/dotenv-Da5cRb03.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/github-dark-DHJKELXO.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/go-C27-OAKa.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/graphql-ChdNCCLP.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/html-derivative-C6UeqQa8.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/html-pp8916En.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/http-l_GQhCeT.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/index-BBBYE21N.css +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/index-cZFE6wf9.js +231 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/ini-BEwlwnbL.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/java-CylS5w8V.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/javascript-wDzz0qaB.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/json-Cp-IABpG.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsonc-Des-eS-w.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsonl-DcaNXYhu.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/jsx-g9-lgVsj.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/kotlin-BdnUsdx6.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/kusto-wEQ09or8.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/latex-DdMFrP5M.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/markdown-Cvjx9yec.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/mdc-Dz5ISc6g.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/mdx-Cmh6b_Ma.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/mermaid-mWjccvbQ.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/php-R6g_5hLQ.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/powershell-Dpen1YoG.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/python-B6aJPvgy.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/ruby-AcS3PBV-.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/rust-B1yitclQ.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/sass-Cj5Yp3dK.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/scss-D5BDwBP9.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/shellscript-DfDnw5Jg.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/sql-BLtJtn59.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/svelte-DR4MIrkg.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/swift-D82vCrfD.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/toml-vGWfd6FD.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/tsx-COt5Ahok.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/typescript-BPQ3VLAy.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/vue-CJgBXYWu.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/xml-sdJ4AIDG.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/yaml-Buea-lGh.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/assets/zig-VOosw3JB.js +1 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/favicon.svg +103 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/index.html +14 -0
- package/.claude/worktrees/agent-a7b97047/server/dist/vite.svg +1 -0
- package/.claude/worktrees/agent-a7b97047/server/handlers.js +357 -0
- package/.claude/worktrees/agent-a7b97047/server/index.js +88 -0
- package/.claude/worktrees/agent-a7b97047/server/terminal.js +103 -0
- package/.claude/worktrees/agent-a7b97047/vitest.config.mjs +8 -0
- package/.claude/worktrees/agent-a952bde8/LICENSE +21 -0
- package/.claude/worktrees/agent-a952bde8/README.md +63 -0
- package/.claude/worktrees/agent-a952bde8/bin/beads-board.js +183 -0
- package/.claude/worktrees/agent-a952bde8/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/.claude/worktrees/agent-a952bde8/package-lock.json +1752 -0
- package/.claude/worktrees/agent-a952bde8/package.json +43 -0
- package/.claude/worktrees/agent-a952bde8/server/__tests__/api.test.js +122 -0
- package/.claude/worktrees/agent-a952bde8/server/dist/assets/index-BsWRmNbj.js +79 -0
- package/.claude/worktrees/agent-a952bde8/server/dist/assets/index-C7JKZkTD.css +1 -0
- package/.claude/worktrees/agent-a952bde8/server/dist/favicon.svg +103 -0
- package/.claude/worktrees/agent-a952bde8/server/dist/index.html +14 -0
- package/.claude/worktrees/agent-a952bde8/server/dist/vite.svg +1 -0
- package/.claude/worktrees/agent-a952bde8/server/handlers.js +269 -0
- package/.claude/worktrees/agent-a952bde8/server/index.js +88 -0
- package/.claude/worktrees/agent-a952bde8/server/terminal.js +103 -0
- package/.claude/worktrees/agent-a952bde8/vitest.config.mjs +8 -0
- package/README.md +2 -1
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +153 -0
- package/coverage/coverage-final.json +2 -0
- package/coverage/favicon.png +0 -0
- package/coverage/handlers.js.html +892 -0
- package/coverage/index.html +116 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/package.json +43 -34
- package/server/__tests__/api.test.js +261 -0
- package/server/dist/assets/angular-html-DA-rfuFy.js +1 -0
- package/server/dist/assets/angular-ts-BrjP3tb8.js +1 -0
- package/server/dist/assets/c-BIGW1oBm.js +1 -0
- package/server/dist/assets/cpp-BRuaLJcg.js +1 -0
- package/server/dist/assets/csharp-COcwbKMJ.js +1 -0
- package/server/dist/assets/css-CLj8gQPS.js +1 -0
- package/server/dist/assets/dockerfile-BcOcwvcX.js +1 -0
- package/server/dist/assets/dotenv-Da5cRb03.js +1 -0
- package/server/dist/assets/github-dark-DHJKELXO.js +1 -0
- package/server/dist/assets/go-C27-OAKa.js +1 -0
- package/server/dist/assets/graphql-ChdNCCLP.js +1 -0
- package/server/dist/assets/html-derivative-C6UeqQa8.js +1 -0
- package/server/dist/assets/html-pp8916En.js +1 -0
- package/server/dist/assets/http-l_GQhCeT.js +1 -0
- package/server/dist/assets/index-DOFQi_E1.js +231 -0
- package/server/dist/assets/index-DeppoR8O.css +1 -0
- package/server/dist/assets/ini-BEwlwnbL.js +1 -0
- package/server/dist/assets/java-CylS5w8V.js +1 -0
- package/server/dist/assets/javascript-wDzz0qaB.js +1 -0
- package/server/dist/assets/json-Cp-IABpG.js +1 -0
- package/server/dist/assets/jsonc-Des-eS-w.js +1 -0
- package/server/dist/assets/jsonl-DcaNXYhu.js +1 -0
- package/server/dist/assets/jsx-g9-lgVsj.js +1 -0
- package/server/dist/assets/kotlin-BdnUsdx6.js +1 -0
- package/server/dist/assets/kusto-wEQ09or8.js +1 -0
- package/server/dist/assets/latex-DdMFrP5M.js +1 -0
- package/server/dist/assets/markdown-Cvjx9yec.js +1 -0
- package/server/dist/assets/mdc-Dz5ISc6g.js +1 -0
- package/server/dist/assets/mdx-Cmh6b_Ma.js +1 -0
- package/server/dist/assets/mermaid-mWjccvbQ.js +1 -0
- package/server/dist/assets/php-R6g_5hLQ.js +1 -0
- package/server/dist/assets/powershell-Dpen1YoG.js +1 -0
- package/server/dist/assets/python-B6aJPvgy.js +1 -0
- package/server/dist/assets/ruby-AcS3PBV-.js +1 -0
- package/server/dist/assets/rust-B1yitclQ.js +1 -0
- package/server/dist/assets/sass-Cj5Yp3dK.js +1 -0
- package/server/dist/assets/scss-D5BDwBP9.js +1 -0
- package/server/dist/assets/shellscript-DfDnw5Jg.js +1 -0
- package/server/dist/assets/sql-BLtJtn59.js +1 -0
- package/server/dist/assets/svelte-DR4MIrkg.js +1 -0
- package/server/dist/assets/swift-D82vCrfD.js +1 -0
- package/server/dist/assets/toml-vGWfd6FD.js +1 -0
- package/server/dist/assets/tsx-COt5Ahok.js +1 -0
- package/server/dist/assets/typescript-BPQ3VLAy.js +1 -0
- package/server/dist/assets/vue-CJgBXYWu.js +1 -0
- package/server/dist/assets/xml-sdJ4AIDG.js +1 -0
- package/server/dist/assets/yaml-Buea-lGh.js +1 -0
- package/server/dist/assets/zig-VOosw3JB.js +1 -0
- package/server/dist/index.html +2 -2
- package/server/handlers.js +356 -0
- package/server/index.js +2 -242
- package/server/terminal.js +2 -4
- package/vitest.config.mjs +8 -0
- package/server/dist/assets/index-BNjM8bEC.css +0 -1
- package/server/dist/assets/index-BU8XOdEX.js +0 -75
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const { execFile } = require('node:child_process');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const url = require('node:url');
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// MIME types
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const MIME_TYPES = {
|
|
11
|
+
'.html': 'text/html',
|
|
12
|
+
'.js': 'application/javascript',
|
|
13
|
+
'.css': 'text/css',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.ico': 'image/x-icon',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// CLI helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// Indirection for testability — tests can replace _execFileFn to mock CLI calls
|
|
25
|
+
let _execFileFn = execFile;
|
|
26
|
+
|
|
27
|
+
function setExecFile(fn) {
|
|
28
|
+
_execFileFn = fn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function execCmd(cmd, args, cwd) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
_execFileFn(cmd, args, { cwd, timeout: 10000, windowsHide: true }, (err, stdout, stderr) => {
|
|
34
|
+
if (err) {
|
|
35
|
+
reject(new Error(`${cmd} ${args.join(' ')} failed: ${stderr || err.message}`));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
resolve(stdout);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function execBd(args, projectDir) {
|
|
44
|
+
const stdout = await execCmd('bd', [...args, '--json'], projectDir);
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(stdout);
|
|
47
|
+
} catch {
|
|
48
|
+
// bd list with no issues prints "No issues found." instead of JSON
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function execGit(args, projectDir) {
|
|
54
|
+
return execCmd('git', args, projectDir);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Issue normalization — bd CLI outputs `issue_type`, UI expects `type`
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function normalizeIssue(issue) {
|
|
62
|
+
if (issue.issue_type && !issue.type) {
|
|
63
|
+
issue.type = issue.issue_type;
|
|
64
|
+
}
|
|
65
|
+
return issue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// JSON response helpers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function jsonResponse(res, data, status = 200) {
|
|
73
|
+
res.writeHead(status, {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'Access-Control-Allow-Origin': '*',
|
|
76
|
+
});
|
|
77
|
+
res.end(JSON.stringify(data));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function errorResponse(res, message, status = 500) {
|
|
81
|
+
jsonResponse(res, { error: message }, status);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Request handler factory
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
function createRequestHandler(projectDir, distDir) {
|
|
89
|
+
return async function handleRequest(req, res) {
|
|
90
|
+
const parsed = url.parse(req.url, true);
|
|
91
|
+
const pathname = parsed.pathname;
|
|
92
|
+
|
|
93
|
+
if (req.method === 'OPTIONS') {
|
|
94
|
+
res.writeHead(204, {
|
|
95
|
+
'Access-Control-Allow-Origin': '*',
|
|
96
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
97
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
98
|
+
});
|
|
99
|
+
res.end();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
if (pathname === '/api/issues') {
|
|
105
|
+
const issues = await execBd(['list', '--flat', '--status=all', '--limit=0'], projectDir);
|
|
106
|
+
jsonResponse(res, Array.isArray(issues) ? issues.map(normalizeIssue) : issues);
|
|
107
|
+
} else if (pathname === '/api/ready') {
|
|
108
|
+
const ready = await execBd(['ready'], projectDir);
|
|
109
|
+
jsonResponse(res, Array.isArray(ready) ? ready.map(normalizeIssue) : ready);
|
|
110
|
+
} else if (pathname === '/api/blocked') {
|
|
111
|
+
const blocked = await execBd(['blocked'], projectDir);
|
|
112
|
+
jsonResponse(res, Array.isArray(blocked) ? blocked.map(normalizeIssue) : blocked);
|
|
113
|
+
} else if (pathname.startsWith('/api/issue/')) {
|
|
114
|
+
const id = pathname.split('/api/issue/')[1];
|
|
115
|
+
if (!id || !/^[\w.\-]+$/.test(id)) {
|
|
116
|
+
errorResponse(res, 'Invalid issue ID', 400);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const issue = await execBd(['show', id], projectDir);
|
|
120
|
+
jsonResponse(res, issue);
|
|
121
|
+
} else if (pathname === '/api/git-log') {
|
|
122
|
+
const branch = parsed.query.branch || '';
|
|
123
|
+
const limit = Math.min(Math.max(parseInt(parsed.query.limit || '50', 10) || 50, 1), 500);
|
|
124
|
+
if (branch && !/^[\w\/.@{}-]+$/.test(branch)) {
|
|
125
|
+
errorResponse(res, 'Invalid branch name', 400);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const format = '%h%x00%s%x00%b%x00%an%x00%ai%x1e';
|
|
129
|
+
const args = ['log', `--format=${format}`, `-n`, `${limit}`];
|
|
130
|
+
if (branch) args.splice(1, 0, branch);
|
|
131
|
+
const stdout = await execGit(args, projectDir);
|
|
132
|
+
const commits = stdout.split('\x1e').filter(s => s.trim()).map(record => {
|
|
133
|
+
const [hash, message, body, author, date] = record.trim().split('\0');
|
|
134
|
+
return { hash, message, body: body?.trim() || '', author, date };
|
|
135
|
+
});
|
|
136
|
+
jsonResponse(res, commits);
|
|
137
|
+
} else if (pathname === '/api/project') {
|
|
138
|
+
let name = path.basename(projectDir);
|
|
139
|
+
try {
|
|
140
|
+
const origin = (await execGit(['remote', 'get-url', 'origin'], projectDir)).trim();
|
|
141
|
+
const match = origin.match(/\/([^/]+?)(?:\.git)?$/);
|
|
142
|
+
if (match) name = match[1];
|
|
143
|
+
} catch {}
|
|
144
|
+
jsonResponse(res, { name });
|
|
145
|
+
} else if (pathname === '/api/dependencies') {
|
|
146
|
+
const issues = await execBd(['list', '--flat', '--status=all', '--limit=0'], projectDir);
|
|
147
|
+
const allIssues = Array.isArray(issues) ? issues.map(normalizeIssue) : [];
|
|
148
|
+
const edges = [];
|
|
149
|
+
for (const issue of allIssues) {
|
|
150
|
+
if (issue.dependencies && Array.isArray(issue.dependencies)) {
|
|
151
|
+
for (const dep of issue.dependencies) {
|
|
152
|
+
const depId = typeof dep === 'string' ? dep : dep.id || dep.issue_id;
|
|
153
|
+
if (depId) {
|
|
154
|
+
edges.push({ from: issue.id, to: depId });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
jsonResponse(res, { nodes: allIssues, edges });
|
|
160
|
+
} else if (pathname === '/api/projects') {
|
|
161
|
+
try {
|
|
162
|
+
const parentDir = path.dirname(projectDir);
|
|
163
|
+
const entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
164
|
+
const projects = [];
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
const fullPath = path.join(parentDir, entry.name);
|
|
168
|
+
const beadsDir = path.join(fullPath, '.beads');
|
|
169
|
+
try {
|
|
170
|
+
const stat = fs.statSync(beadsDir);
|
|
171
|
+
if (stat.isDirectory()) {
|
|
172
|
+
projects.push({ name: entry.name, path: fullPath });
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// No .beads directory, skip
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
projects.sort((a, b) => a.name.localeCompare(b.name));
|
|
180
|
+
jsonResponse(res, projects);
|
|
181
|
+
} catch {
|
|
182
|
+
jsonResponse(res, []);
|
|
183
|
+
}
|
|
184
|
+
} else if (pathname === '/api/git-status') {
|
|
185
|
+
const stdout = await execGit(['status', '--porcelain'], projectDir);
|
|
186
|
+
const files = stdout.replace(/\n$/, '').split('\n').filter(Boolean).map(line => {
|
|
187
|
+
const match = line.match(/^(..)[ ](.+)$/);
|
|
188
|
+
if (!match) return { status: '?', path: line.trim() };
|
|
189
|
+
return { status: match[1].trim(), path: match[2] };
|
|
190
|
+
});
|
|
191
|
+
jsonResponse(res, files);
|
|
192
|
+
} else if (pathname === '/api/git-diff') {
|
|
193
|
+
const file = parsed.query.file || '';
|
|
194
|
+
if (!file || /[;&|`$]/.test(file)) {
|
|
195
|
+
errorResponse(res, 'Invalid file path', 400);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
// Try staged + unstaged diff first, fall back to untracked file content
|
|
200
|
+
let diff;
|
|
201
|
+
try {
|
|
202
|
+
diff = await execGit(['diff', 'HEAD', '--', file], projectDir);
|
|
203
|
+
if (!diff.trim()) {
|
|
204
|
+
diff = await execGit(['diff', '--', file], projectDir);
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
diff = '';
|
|
208
|
+
}
|
|
209
|
+
if (!diff.trim()) {
|
|
210
|
+
// Untracked file — show full content as addition
|
|
211
|
+
try {
|
|
212
|
+
const content = fs.readFileSync(
|
|
213
|
+
path.join(projectDir, file), 'utf8'
|
|
214
|
+
);
|
|
215
|
+
const lines = content.split('\n').map(l => '+' + l).join('\n');
|
|
216
|
+
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${content.split('\n').length} @@\n${lines}`;
|
|
217
|
+
} catch {
|
|
218
|
+
diff = '';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
jsonResponse(res, { file, diff });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
errorResponse(res, err.message);
|
|
224
|
+
}
|
|
225
|
+
} else if (pathname === '/api/file-content') {
|
|
226
|
+
const relPath = parsed.query.path || '';
|
|
227
|
+
if (!relPath || relPath.includes('..') || path.isAbsolute(relPath)) {
|
|
228
|
+
errorResponse(res, 'Invalid path', 400);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const targetFile = path.join(projectDir, relPath);
|
|
232
|
+
const resolved = path.resolve(targetFile);
|
|
233
|
+
if (!resolved.startsWith(path.resolve(projectDir))) {
|
|
234
|
+
errorResponse(res, 'Invalid path', 400);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const EXT_TO_LANG = {
|
|
238
|
+
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
239
|
+
'.ts': 'typescript', '.tsx': 'tsx', '.jsx': 'jsx',
|
|
240
|
+
'.json': 'json', '.jsonc': 'jsonc', '.jsonl': 'jsonl',
|
|
241
|
+
'.md': 'markdown', '.mdx': 'mdx',
|
|
242
|
+
'.html': 'html', '.htm': 'html',
|
|
243
|
+
'.css': 'css', '.scss': 'scss', '.sass': 'sass',
|
|
244
|
+
'.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust',
|
|
245
|
+
'.java': 'java', '.kt': 'kotlin', '.kts': 'kotlin',
|
|
246
|
+
'.c': 'c', '.cpp': 'cpp', '.cc': 'cpp', '.h': 'c', '.hpp': 'cpp',
|
|
247
|
+
'.cs': 'csharp',
|
|
248
|
+
'.sh': 'bash', '.bash': 'bash', '.zsh': 'bash',
|
|
249
|
+
'.ps1': 'powershell', '.psm1': 'powershell',
|
|
250
|
+
'.yml': 'yaml', '.yaml': 'yaml', '.toml': 'toml', '.ini': 'ini',
|
|
251
|
+
'.xml': 'xml', '.svg': 'xml', '.sql': 'sql',
|
|
252
|
+
'.dockerfile': 'dockerfile',
|
|
253
|
+
'.graphql': 'graphql', '.gql': 'graphql',
|
|
254
|
+
'.tex': 'latex', '.latex': 'latex',
|
|
255
|
+
'.swift': 'swift', '.zig': 'zig', '.php': 'php',
|
|
256
|
+
'.vue': 'vue', '.svelte': 'svelte',
|
|
257
|
+
'.env': 'dotenv',
|
|
258
|
+
'.mermaid': 'mermaid', '.mmd': 'mermaid',
|
|
259
|
+
'.kusto': 'kusto', '.kql': 'kusto',
|
|
260
|
+
};
|
|
261
|
+
try {
|
|
262
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
263
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
264
|
+
const language = EXT_TO_LANG[ext] || 'text';
|
|
265
|
+
jsonResponse(res, { path: relPath, content, language });
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (err.code === 'ENOENT' || err.code === 'EISDIR') {
|
|
268
|
+
errorResponse(res, 'File not found', 404);
|
|
269
|
+
} else {
|
|
270
|
+
errorResponse(res, err.message);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} else if (pathname === '/api/files') {
|
|
274
|
+
const relPath = parsed.query.path || '';
|
|
275
|
+
// Block path traversal
|
|
276
|
+
if (relPath.includes('..') || path.isAbsolute(relPath)) {
|
|
277
|
+
errorResponse(res, 'Invalid path', 400);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const targetDir = path.join(projectDir, relPath);
|
|
281
|
+
const resolved = path.resolve(targetDir);
|
|
282
|
+
if (!resolved.startsWith(path.resolve(projectDir))) {
|
|
283
|
+
errorResponse(res, 'Invalid path', 400);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const IGNORED = new Set(['.git', 'node_modules', '.beads', '.claude', '.playwright-mcp', 'coverage', '.vscode']);
|
|
287
|
+
try {
|
|
288
|
+
const entries = fs.readdirSync(targetDir, { withFileTypes: true });
|
|
289
|
+
const results = [];
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
// Skip hidden files/dirs and common ignored directories
|
|
292
|
+
if (entry.name.startsWith('.') || IGNORED.has(entry.name)) continue;
|
|
293
|
+
const entryPath = relPath ? `${relPath}/${entry.name}` : entry.name;
|
|
294
|
+
results.push({
|
|
295
|
+
name: entry.name,
|
|
296
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
297
|
+
path: entryPath,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
// Sort: directories first, then alphabetically within each group
|
|
301
|
+
results.sort((a, b) => {
|
|
302
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
303
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
304
|
+
});
|
|
305
|
+
jsonResponse(res, results);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
|
|
308
|
+
errorResponse(res, 'Directory not found', 404);
|
|
309
|
+
} else {
|
|
310
|
+
errorResponse(res, err.message);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else if (pathname === '/api/branches') {
|
|
314
|
+
const stdout = await execGit(['branch', '--format=%(refname:short)'], projectDir);
|
|
315
|
+
const branches = stdout.trim().split('\n').filter(Boolean);
|
|
316
|
+
const current = (await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], projectDir)).trim();
|
|
317
|
+
jsonResponse(res, { branches, current });
|
|
318
|
+
} else {
|
|
319
|
+
// Serve static files from dist/
|
|
320
|
+
let filePath = path.join(distDir, pathname === '/' ? 'index.html' : pathname);
|
|
321
|
+
const resolved = path.resolve(filePath);
|
|
322
|
+
if (!resolved.startsWith(path.resolve(distDir))) {
|
|
323
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
324
|
+
res.end('Forbidden');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// SPA fallback: serve index.html for non-file paths
|
|
328
|
+
if (!path.extname(filePath)) {
|
|
329
|
+
filePath = path.join(distDir, 'index.html');
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const data = fs.readFileSync(filePath);
|
|
333
|
+
const ext = path.extname(filePath);
|
|
334
|
+
res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
|
|
335
|
+
res.end(data);
|
|
336
|
+
} catch {
|
|
337
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
338
|
+
res.end('Not found');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
errorResponse(res, err.message);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
MIME_TYPES,
|
|
349
|
+
execCmd,
|
|
350
|
+
execBd,
|
|
351
|
+
execGit,
|
|
352
|
+
normalizeIssue,
|
|
353
|
+
jsonResponse,
|
|
354
|
+
errorResponse,
|
|
355
|
+
createRequestHandler,
|
|
356
|
+
setExecFile,
|
|
357
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const http = require('node:http');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { attachTerminal, cleanupAllPtys } = require('./terminal.js');
|
|
5
|
+
const { createRequestHandler } = require('./handlers.js');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PORT = 8377;
|
|
8
|
+
const DIST_DIR = path.join(__dirname, 'dist');
|
|
9
|
+
|
|
10
|
+
// Project directory: passed as CLI arg or cwd
|
|
11
|
+
const PROJECT_DIR = process.argv[2] || process.cwd();
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Pidfile management
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
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
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Port detection
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function findAvailablePort(startPort) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const s = http.createServer();
|
|
46
|
+
s.listen(startPort, () => {
|
|
47
|
+
s.close(() => resolve(startPort));
|
|
48
|
+
});
|
|
49
|
+
s.on('error', () => {
|
|
50
|
+
if (startPort < DEFAULT_PORT + 10) {
|
|
51
|
+
resolve(findAvailablePort(startPort + 1));
|
|
52
|
+
} else {
|
|
53
|
+
reject(new Error('No available port found'));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Server startup
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const handleRequest = createRequestHandler(PROJECT_DIR, DIST_DIR);
|
|
64
|
+
const server = http.createServer(handleRequest);
|
|
65
|
+
|
|
66
|
+
const terminalEnabled = attachTerminal(server, PROJECT_DIR);
|
|
67
|
+
if (terminalEnabled) {
|
|
68
|
+
console.log('Terminal feature enabled');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function start() {
|
|
72
|
+
const existing = getRunningInstance();
|
|
73
|
+
if (existing) {
|
|
74
|
+
console.log(`beads-board already running at http://localhost:${existing.port}`);
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const port = await findAvailablePort(parseInt(process.env.PORT || DEFAULT_PORT, 10));
|
|
79
|
+
server.listen(port, () => {
|
|
80
|
+
writePidfile(port);
|
|
81
|
+
console.log(`beads-board server running at http://localhost:${port}`);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
process.on('SIGTERM', () => { cleanupAllPtys(); removePidfile(); process.exit(0); });
|
|
86
|
+
process.on('SIGINT', () => { cleanupAllPtys(); removePidfile(); process.exit(0); });
|
|
87
|
+
|
|
88
|
+
start();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const url = require('node:url');
|
|
2
|
+
|
|
3
|
+
let pty, WebSocket;
|
|
4
|
+
try {
|
|
5
|
+
pty = require('node-pty');
|
|
6
|
+
WebSocket = require('ws');
|
|
7
|
+
} catch {
|
|
8
|
+
// Dependencies not available — terminal feature disabled
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const activePtys = new Set();
|
|
12
|
+
|
|
13
|
+
function getShell() {
|
|
14
|
+
return process.platform === 'win32' ? 'powershell.exe' : 'bash';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attach terminal WebSocket handling to an HTTP server.
|
|
19
|
+
* Returns false if dependencies are missing (graceful degradation).
|
|
20
|
+
*/
|
|
21
|
+
function attachTerminal(httpServer, projectDir) {
|
|
22
|
+
if (!pty || !WebSocket) {
|
|
23
|
+
console.log('Terminal dependencies not available — terminal feature disabled');
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const wss = new WebSocket.Server({ noServer: true });
|
|
28
|
+
|
|
29
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
30
|
+
const parsed = url.parse(req.url);
|
|
31
|
+
if (parsed.pathname !== '/ws/terminal') {
|
|
32
|
+
socket.destroy();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Origin validation — local connections only
|
|
37
|
+
const origin = req.headers.origin || '';
|
|
38
|
+
if (origin && !origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/)) {
|
|
39
|
+
socket.destroy();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
44
|
+
wss.emit('connection', ws, req);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
wss.on('connection', (ws) => {
|
|
49
|
+
const shell = getShell();
|
|
50
|
+
const ptyProcess = pty.spawn(shell, [], {
|
|
51
|
+
name: 'xterm-256color',
|
|
52
|
+
cols: 80,
|
|
53
|
+
rows: 24,
|
|
54
|
+
cwd: projectDir,
|
|
55
|
+
env: process.env,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
activePtys.add(ptyProcess);
|
|
59
|
+
|
|
60
|
+
ptyProcess.onData((data) => {
|
|
61
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
62
|
+
ws.send(data);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
67
|
+
activePtys.delete(ptyProcess);
|
|
68
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
69
|
+
ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ws.on('message', (msg) => {
|
|
74
|
+
const str = msg.toString();
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(str);
|
|
77
|
+
if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
|
|
78
|
+
ptyProcess.resize(parsed.cols, parsed.rows);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Not JSON — raw terminal input
|
|
83
|
+
}
|
|
84
|
+
ptyProcess.write(str);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
ws.on('close', () => {
|
|
88
|
+
activePtys.delete(ptyProcess);
|
|
89
|
+
ptyProcess.kill();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cleanupAllPtys() {
|
|
97
|
+
for (const p of activePtys) {
|
|
98
|
+
try { p.kill(); } catch {}
|
|
99
|
+
}
|
|
100
|
+
activePtys.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { attachTerminal, cleanupAllPtys };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stuart Rimel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# beads-board
|
|
2
|
+
|
|
3
|
+
A minimal kanban dashboard and git log viewer for [Beads](https://github.com/steveyegge/beads). Runs as a standalone CLI tool.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Kanban board** — Issues organized by status: Ready, In Progress, Blocked, Done
|
|
8
|
+
- **Git log** — Scrollable commit history with branch selector
|
|
9
|
+
- **Bead ID linking** — Bead IDs in commit messages are highlighted as badges
|
|
10
|
+
- **Dark/light theme** — Toggle between themes, dark by default
|
|
11
|
+
- **Auto-refresh** — Polls for updates every 5 seconds
|
|
12
|
+
- **Integrated terminal** — Built-in terminal panel powered by node-pty and xterm.js
|
|
13
|
+
- **Minimal runtime dependencies** — Server uses Node.js stdlib plus `node-pty` and `ws` for the terminal
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Install globally
|
|
19
|
+
npm install -g @citadel-labs/beads-ui
|
|
20
|
+
|
|
21
|
+
# Or run directly with npx (installs temporarily)
|
|
22
|
+
npx @citadel-labs/beads-ui
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then from any project that uses [Beads](https://github.com/steveyegge/beads):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd /path/to/your/project
|
|
29
|
+
bdui # Start dashboard in background, prints URL
|
|
30
|
+
bdui /path/to/project # Specify a project directory
|
|
31
|
+
bdui --port 9000 # Custom port
|
|
32
|
+
bdui status # Check if dashboard is running
|
|
33
|
+
bdui stop # Stop the dashboard
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or start the server directly:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
node server/index.js
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The server starts in the background and returns control to your terminal. Open the printed URL (default **http://localhost:8377**) in your browser. Port auto-increments if taken.
|
|
43
|
+
|
|
44
|
+
## How It Works
|
|
45
|
+
|
|
46
|
+
The server shells out to `bd` and `git` CLI commands to fetch data, then serves a React dashboard that polls the API every 5 seconds. No direct database access — all data flows through the Beads CLI.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Browser → GET /api/* → Node.js server → bd/git CLI → JSON response
|
|
50
|
+
← React app ← server/dist/
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See [docs/architecture.md](docs/architecture.md) for details.
|
|
54
|
+
|
|
55
|
+
## Documentation
|
|
56
|
+
|
|
57
|
+
- [Architecture](docs/architecture.md) — How the server and UI fit together
|
|
58
|
+
- [API Reference](docs/api.md) — All API endpoints with examples
|
|
59
|
+
- [Contributing](docs/contributing.md) — How to set up a dev environment and make changes
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
[MIT](LICENSE)
|