@seungyeop-lee/beads-ui 0.13.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/bin/bdui.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Thin CLI entry for `bdui`.
4
+ * Delegates to `server/cli/index.js` and sets the process exit code.
5
+ */
6
+ import { main } from '../server/cli/index.js';
7
+ import { debug } from '../server/logging.js';
8
+
9
+ const argv = process.argv.slice(2);
10
+
11
+ try {
12
+ const code = await main(argv);
13
+ if (Number.isFinite(code)) {
14
+ process.exitCode = code;
15
+ }
16
+ } catch (err) {
17
+ debug('cli')('fatal %o', err);
18
+ process.exitCode = 1;
19
+ }
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@seungyeop-lee/beads-ui",
3
+ "version": "0.13.0",
4
+ "description": "Local UI for Beads — Collaborate on issues with your coding agent.",
5
+ "keywords": [
6
+ "agent",
7
+ "issue-tracker",
8
+ "local-first",
9
+ "ai-tools"
10
+ ],
11
+ "homepage": "https://github.com/seungyeop-lee/beads-ui",
12
+ "author": "seungyeop-lee",
13
+ "type": "module",
14
+ "bin": {
15
+ "bdui": "bin/bdui.js"
16
+ },
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "dependencies": {
21
+ "debug": "^4.4.3",
22
+ "dompurify": "^3.3.0",
23
+ "express": "^5.2.1",
24
+ "lit-html": "^3.3.1",
25
+ "marked": "^17.0.1",
26
+ "ws": "^8.18.3"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/js": "^9.39.1",
30
+ "@studio/changes": "^3.0.0",
31
+ "@trivago/prettier-plugin-sort-imports": "^6.0.0",
32
+ "@types/debug": "^4.1.12",
33
+ "@types/express": "^5.0.6",
34
+ "@types/node": "^22.19.1",
35
+ "@types/ws": "^8.18.1",
36
+ "esbuild": "^0.27.1",
37
+ "eslint": "^9.39.1",
38
+ "eslint-plugin-import": "^2.29.1",
39
+ "eslint-plugin-jsdoc": "^61.4.1",
40
+ "eslint-plugin-n": "^17.9.0",
41
+ "globals": "^16.5.0",
42
+ "jsdom": "^27.2.0",
43
+ "lefthook": "^2.1.6",
44
+ "prettier": "^3.7.4",
45
+ "typescript": "^5.6.3",
46
+ "vitest": "^4.0.15"
47
+ },
48
+ "files": [
49
+ "app/index.html",
50
+ "app/styles.css",
51
+ "app/main.bundle.js",
52
+ "app/main.bundle.js.map",
53
+ "app/protocol.js",
54
+ "bin",
55
+ "server",
56
+ "CHANGES.md",
57
+ "LICENSE",
58
+ "README.md",
59
+ "!**/*.test.js"
60
+ ],
61
+ "publishConfig": {
62
+ "access": "public"
63
+ },
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "https://github.com/seungyeop-lee/beads-ui.git"
67
+ },
68
+ "scripts": {
69
+ "all": "pnpm lint && pnpm tsc && pnpm test && pnpm prettier:check",
70
+ "start": "node server/index.js --debug",
71
+ "build": "node scripts/build-frontend.js",
72
+ "test": "vitest run",
73
+ "test:watch": "vitest",
74
+ "tsc": "tsc -p tsconfig.json --noEmit",
75
+ "lint": "eslint --ext .js .",
76
+ "prettier:write": "prettier --write .",
77
+ "prettier:check": "prettier --check .",
78
+ "preversion": "pnpm all",
79
+ "version": "changes --commits --footer",
80
+ "postversion": "git push --follow-tags && pnpm publish"
81
+ }
82
+ }
package/server/app.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @import { Express, Request, Response } from 'express'
3
+ */
4
+ import express from 'express';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ getAvailableWorkspaces,
9
+ registerWorkspace
10
+ } from './registry-watcher.js';
11
+
12
+ /**
13
+ * Create and configure the Express application.
14
+ *
15
+ * @param {{ host: string, port: number, app_dir: string, root_dir: string }} config - Server configuration.
16
+ * @returns {Express} Configured Express app instance.
17
+ */
18
+ export function createApp(config) {
19
+ const app = express();
20
+
21
+ // Basic hardening and config
22
+ app.disable('x-powered-by');
23
+
24
+ // Health endpoint
25
+ /**
26
+ * @param {Request} _req
27
+ * @param {Response} res
28
+ */
29
+ app.get('/healthz', (_req, res) => {
30
+ res.type('application/json');
31
+ res.status(200).send({ ok: true });
32
+ });
33
+
34
+ // Enable JSON body parsing for API endpoints
35
+ app.use(express.json());
36
+
37
+ // Register workspace endpoint - allows CLI to register workspaces dynamically
38
+ // when the server is already running
39
+ /**
40
+ * @param {Request} req
41
+ * @param {Response} res
42
+ */
43
+ app.post('/api/register-workspace', (req, res) => {
44
+ const { path: workspace_path, database } = req.body || {};
45
+ if (!workspace_path || typeof workspace_path !== 'string') {
46
+ res.status(400).json({ ok: false, error: 'Missing or invalid path' });
47
+ return;
48
+ }
49
+ if (!database || typeof database !== 'string') {
50
+ res.status(400).json({ ok: false, error: 'Missing or invalid database' });
51
+ return;
52
+ }
53
+ registerWorkspace({ path: workspace_path, database });
54
+ res.status(200).json({ ok: true, registered: workspace_path });
55
+ });
56
+
57
+ // List all known workspaces (file-based registry + in-memory)
58
+ app.get('/api/workspaces', (_req, res) => {
59
+ const workspaces = getAvailableWorkspaces();
60
+ res.status(200).json({ ok: true, workspaces });
61
+ });
62
+
63
+ if (
64
+ !fs.statSync(path.resolve(config.app_dir, 'main.bundle.js'), {
65
+ throwIfNoEntry: false
66
+ })
67
+ ) {
68
+ /**
69
+ * On-demand bundle for the browser using esbuild.
70
+ *
71
+ * @param {Request} _req
72
+ * @param {Response} res
73
+ */
74
+ app.get('/main.bundle.js', async (_req, res) => {
75
+ try {
76
+ const esbuild = await import('esbuild');
77
+ const entry = path.join(config.app_dir, 'main.js');
78
+ const result = await esbuild.build({
79
+ entryPoints: [entry],
80
+ bundle: true,
81
+ format: 'esm',
82
+ platform: 'browser',
83
+ target: 'es2020',
84
+ sourcemap: 'inline',
85
+ minify: false,
86
+ write: false
87
+ });
88
+ const out = result.outputFiles && result.outputFiles[0];
89
+ if (!out) {
90
+ res.status(500).type('text/plain').send('Bundle failed: no output');
91
+ return;
92
+ }
93
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
94
+ res.setHeader('Cache-Control', 'no-store');
95
+ res.send(out.text);
96
+ } catch (err) {
97
+ res
98
+ .status(500)
99
+ .type('text/plain')
100
+ .send('Bundle error: ' + (err && /** @type {any} */ (err).message));
101
+ }
102
+ });
103
+ }
104
+
105
+ // Static assets from /app
106
+ app.use(express.static(config.app_dir));
107
+
108
+ // Root serves index.html explicitly (even if static would catch it)
109
+ /**
110
+ * @param {Request} _req
111
+ * @param {Response} res
112
+ */
113
+ app.get('/', (_req, res) => {
114
+ const index_path = path.join(config.app_dir, 'index.html');
115
+ res.sendFile(index_path);
116
+ });
117
+
118
+ return app;
119
+ }
package/server/bd.js ADDED
@@ -0,0 +1,227 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolveDbPath } from './db.js';
3
+ import { debug } from './logging.js';
4
+
5
+ const log = debug('bd');
6
+ /** @type {Promise<void>} */
7
+ let bd_run_queue = Promise.resolve();
8
+
9
+ /**
10
+ * Get the git user name from git config.
11
+ *
12
+ * @param {{ cwd?: string }} [options]
13
+ * @returns {Promise<string>}
14
+ */
15
+ export async function getGitUserName(options = {}) {
16
+ return new Promise((resolve) => {
17
+ const child = spawn('git', ['config', 'user.name'], {
18
+ cwd: options.cwd || process.cwd(),
19
+ shell: false,
20
+ windowsHide: true
21
+ });
22
+
23
+ /** @type {string[]} */
24
+ const chunks = [];
25
+
26
+ if (child.stdout) {
27
+ child.stdout.setEncoding('utf8');
28
+ child.stdout.on('data', (chunk) => chunks.push(String(chunk)));
29
+ }
30
+
31
+ child.on('error', () => resolve(''));
32
+ child.on('close', (code) => {
33
+ if (code !== 0) {
34
+ resolve('');
35
+ return;
36
+ }
37
+ resolve(chunks.join('').trim());
38
+ });
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Resolve the bd executable path.
44
+ *
45
+ * @returns {string}
46
+ */
47
+ export function getBdBin() {
48
+ const env_value = process.env.BD_BIN;
49
+ if (env_value && env_value.length > 0) {
50
+ return env_value;
51
+ }
52
+ return 'bd';
53
+ }
54
+
55
+ /**
56
+ * Run the `bd` CLI with provided arguments.
57
+ * Shell is not used to avoid injection; args must be pre-split.
58
+ *
59
+ * @param {string[]} args - Arguments to pass (e.g., ["list", "--json"]).
60
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
61
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
62
+ */
63
+ export function runBd(args, options = {}) {
64
+ return withBdRunQueue(async () => runBdUnlocked(args, options));
65
+ }
66
+
67
+ /**
68
+ * Run the `bd` CLI with provided arguments without queueing.
69
+ *
70
+ * @param {string[]} args
71
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
72
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
73
+ */
74
+ function runBdUnlocked(args, options = {}) {
75
+ const bin = getBdBin();
76
+
77
+ // Set BEADS_DB only when the workspace has a local SQLite DB.
78
+ // Do not force BEADS_DB from global fallback paths; this can override
79
+ // backend autodetection in non-SQLite workspaces (for example Dolt).
80
+ const db_path = resolveDbPath({
81
+ cwd: options.cwd || process.cwd(),
82
+ env: options.env || process.env
83
+ });
84
+ const env_with_db = { ...(options.env || process.env) };
85
+ if (db_path.source === 'nearest' && db_path.exists) {
86
+ env_with_db.BEADS_DB = db_path.path;
87
+ }
88
+
89
+ const spawn_opts = {
90
+ cwd: options.cwd || process.cwd(),
91
+ env: env_with_db,
92
+ shell: false,
93
+ windowsHide: true
94
+ };
95
+
96
+ /** @type {string[]} */
97
+ const final_args = buildBdArgs(args);
98
+
99
+ return new Promise((resolve) => {
100
+ const child = spawn(bin, final_args, spawn_opts);
101
+
102
+ /** @type {string[]} */
103
+ const out_chunks = [];
104
+ /** @type {string[]} */
105
+ const err_chunks = [];
106
+
107
+ if (child.stdout) {
108
+ child.stdout.setEncoding('utf8');
109
+ child.stdout.on('data', (chunk) => {
110
+ out_chunks.push(String(chunk));
111
+ });
112
+ }
113
+ if (child.stderr) {
114
+ child.stderr.setEncoding('utf8');
115
+ child.stderr.on('data', (chunk) => {
116
+ err_chunks.push(String(chunk));
117
+ });
118
+ }
119
+
120
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
121
+ let timer;
122
+ if (options.timeout_ms && options.timeout_ms > 0) {
123
+ timer = setTimeout(() => {
124
+ child.kill('SIGKILL');
125
+ }, options.timeout_ms);
126
+ timer.unref?.();
127
+ }
128
+
129
+ /**
130
+ * @param {number | string | null} code
131
+ */
132
+ const finish = (code) => {
133
+ if (timer) {
134
+ clearTimeout(timer);
135
+ }
136
+ resolve({
137
+ code: Number(code || 0),
138
+ stdout: out_chunks.join(''),
139
+ stderr: err_chunks.join('')
140
+ });
141
+ };
142
+
143
+ child.on('error', (err) => {
144
+ // Treat spawn error as an immediate non-zero exit; log for diagnostics.
145
+ log('spawn error running %s %o', bin, err);
146
+ finish(127);
147
+ });
148
+ child.on('close', (code) => {
149
+ finish(code);
150
+ });
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Build final bd CLI arguments.
156
+ * bdui defaults to sandbox mode to avoid sync/autopush overhead on interactive
157
+ * UI requests. Set `BDUI_BD_SANDBOX=0` (or "false") to opt out.
158
+ *
159
+ * @param {string[]} args
160
+ * @returns {string[]}
161
+ */
162
+ function buildBdArgs(args) {
163
+ const arg_set = new Set(args);
164
+ const raw_sandbox = String(process.env.BDUI_BD_SANDBOX || '').toLowerCase();
165
+ const sandbox_disabled = raw_sandbox === '0' || raw_sandbox === 'false';
166
+ const should_prepend_sandbox = !sandbox_disabled && !arg_set.has('--sandbox');
167
+
168
+ if (!should_prepend_sandbox) {
169
+ return args.slice();
170
+ }
171
+
172
+ return ['--sandbox', ...args];
173
+ }
174
+
175
+ /**
176
+ * Serialize `bd` invocations.
177
+ * Dolt embedded mode can crash when multiple `bd` processes run concurrently
178
+ * against the same workspace.
179
+ *
180
+ * @template T
181
+ * @param {() => Promise<T>} operation
182
+ * @returns {Promise<T>}
183
+ */
184
+ async function withBdRunQueue(operation) {
185
+ const previous = bd_run_queue;
186
+ /** @type {() => void} */
187
+ let release = () => {};
188
+ bd_run_queue = new Promise((resolve) => {
189
+ release = resolve;
190
+ });
191
+
192
+ await previous.catch(() => {});
193
+ try {
194
+ return await operation();
195
+ } finally {
196
+ release();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Run `bd` and parse JSON from stdout if exit code is 0.
202
+ *
203
+ * @param {string[]} args - Must include flags that cause JSON to be printed (e.g., `--json`).
204
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
205
+ * @returns {Promise<{ code: number, stdoutJson?: unknown, stderr?: string }>}
206
+ */
207
+ export async function runBdJson(args, options = {}) {
208
+ const result = await runBd(args, options);
209
+ if (result.code !== 0) {
210
+ log(
211
+ 'bd exited with code %d (args=%o) stderr=%s',
212
+ result.code,
213
+ args,
214
+ result.stderr
215
+ );
216
+ return { code: result.code, stderr: result.stderr };
217
+ }
218
+ /** @type {unknown} */
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(result.stdout || 'null');
222
+ } catch (err) {
223
+ log('bd returned invalid JSON (args=%o): %o', args, err);
224
+ return { code: 0, stderr: 'Invalid JSON from bd' };
225
+ }
226
+ return { code: 0, stdoutJson: parsed };
227
+ }