beads-enhanced-ui 0.1.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,90 @@
1
+ {
2
+ "name": "beads-enhanced-ui",
3
+ "version": "0.1.0",
4
+ "description": "Enhanced local UI for Beads with improved epic rows, status labels, and UX refinements.",
5
+ "keywords": [
6
+ "agent",
7
+ "issue-tracker",
8
+ "local-first",
9
+ "ai-tools"
10
+ ],
11
+ "homepage": "https://github.com/AlexeyPlatkovsky/beads-enhanced-ui#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/AlexeyPlatkovsky/beads-enhanced-ui.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/AlexeyPlatkovsky/beads-enhanced-ui/issues"
18
+ },
19
+ "author": "Alexey Platkovsky",
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "bin": {
23
+ "bdui": "bin/bdui.js"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "scripts": {
29
+ "all": "npm run lint && npm run tsc && npm test && npm run test:coverage:app && npm run prettier:check",
30
+ "start": "node server/index.js --debug",
31
+ "build": "node scripts/build-frontend.js",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "test:coverage": "vitest run --coverage",
35
+ "test:coverage:app": "vitest run --project=jsdom --coverage",
36
+ "test:e2e": "playwright test",
37
+ "test:e2e:ui": "playwright test --ui",
38
+ "tsc": "tsc -p tsconfig.json --noEmit",
39
+ "lint": "eslint --ext .js .",
40
+ "prettier:write": "prettier --write .",
41
+ "prettier:check": "prettier --check .",
42
+ "preversion": "npm run all",
43
+ "version": "changes --commits --footer",
44
+ "postversion": "git push --follow-tags && npm publish",
45
+ "prepack": "npm run build",
46
+ "postpack": "rm app/main.bundle.js app/main.bundle.js.map"
47
+ },
48
+ "dependencies": {
49
+ "debug": "^4.4.3",
50
+ "dompurify": "^3.3.0",
51
+ "express": "^5.2.1",
52
+ "lit-html": "^3.3.1",
53
+ "marked": "^17.0.1",
54
+ "ws": "^8.18.3"
55
+ },
56
+ "devDependencies": {
57
+ "@eslint/js": "^9.39.1",
58
+ "@playwright/test": "^1.58.2",
59
+ "@studio/changes": "^3.0.0",
60
+ "@trivago/prettier-plugin-sort-imports": "^6.0.0",
61
+ "@types/debug": "^4.1.12",
62
+ "@types/express": "^5.0.6",
63
+ "@types/node": "^22.19.1",
64
+ "@types/ws": "^8.18.1",
65
+ "@vitest/coverage-v8": "^4.1.2",
66
+ "esbuild": "^0.27.1",
67
+ "eslint": "^9.39.1",
68
+ "eslint-plugin-import": "^2.29.1",
69
+ "eslint-plugin-jsdoc": "^61.4.1",
70
+ "eslint-plugin-n": "^17.9.0",
71
+ "globals": "^16.5.0",
72
+ "jsdom": "^27.2.0",
73
+ "prettier": "^3.7.4",
74
+ "typescript": "^5.6.3",
75
+ "vitest": "^4.0.15"
76
+ },
77
+ "files": [
78
+ "app/index.html",
79
+ "app/styles.css",
80
+ "app/main.bundle.js",
81
+ "app/main.bundle.js.map",
82
+ "app/protocol.js",
83
+ "bin",
84
+ "server",
85
+ "CHANGES.md",
86
+ "LICENSE",
87
+ "README.md",
88
+ "!**/*.test.js"
89
+ ]
90
+ }
package/server/app.js ADDED
@@ -0,0 +1,110 @@
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 { registerWorkspace } from './registry-watcher.js';
8
+
9
+ /**
10
+ * Create and configure the Express application.
11
+ *
12
+ * @param {{ host: string, port: number, app_dir: string, root_dir: string }} config - Server configuration.
13
+ * @returns {Express} Configured Express app instance.
14
+ */
15
+ export function createApp(config) {
16
+ const app = express();
17
+
18
+ // Basic hardening and config
19
+ app.disable('x-powered-by');
20
+
21
+ // Health endpoint
22
+ /**
23
+ * @param {Request} _req
24
+ * @param {Response} res
25
+ */
26
+ app.get('/healthz', (_req, res) => {
27
+ res.type('application/json');
28
+ res.status(200).send({ ok: true });
29
+ });
30
+
31
+ // Enable JSON body parsing for API endpoints
32
+ app.use(express.json());
33
+
34
+ // Register workspace endpoint - allows CLI to register workspaces dynamically
35
+ // when the server is already running
36
+ /**
37
+ * @param {Request} req
38
+ * @param {Response} res
39
+ */
40
+ app.post('/api/register-workspace', (req, res) => {
41
+ const { path: workspace_path, database } = req.body || {};
42
+ if (!workspace_path || typeof workspace_path !== 'string') {
43
+ res.status(400).json({ ok: false, error: 'Missing or invalid path' });
44
+ return;
45
+ }
46
+ if (!database || typeof database !== 'string') {
47
+ res.status(400).json({ ok: false, error: 'Missing or invalid database' });
48
+ return;
49
+ }
50
+ registerWorkspace({ path: workspace_path, database });
51
+ res.status(200).json({ ok: true, registered: workspace_path });
52
+ });
53
+
54
+ if (
55
+ !fs.statSync(path.resolve(config.app_dir, 'main.bundle.js'), {
56
+ throwIfNoEntry: false
57
+ })
58
+ ) {
59
+ /**
60
+ * On-demand bundle for the browser using esbuild.
61
+ *
62
+ * @param {Request} _req
63
+ * @param {Response} res
64
+ */
65
+ app.get('/main.bundle.js', async (_req, res) => {
66
+ try {
67
+ const esbuild = await import('esbuild');
68
+ const entry = path.join(config.app_dir, 'main.js');
69
+ const result = await esbuild.build({
70
+ entryPoints: [entry],
71
+ bundle: true,
72
+ format: 'esm',
73
+ platform: 'browser',
74
+ target: 'es2020',
75
+ sourcemap: 'inline',
76
+ minify: false,
77
+ write: false
78
+ });
79
+ const out = result.outputFiles && result.outputFiles[0];
80
+ if (!out) {
81
+ res.status(500).type('text/plain').send('Bundle failed: no output');
82
+ return;
83
+ }
84
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
85
+ res.setHeader('Cache-Control', 'no-store');
86
+ res.send(out.text);
87
+ } catch (err) {
88
+ res
89
+ .status(500)
90
+ .type('text/plain')
91
+ .send('Bundle error: ' + (err && /** @type {any} */ (err).message));
92
+ }
93
+ });
94
+ }
95
+
96
+ // Static assets from /app
97
+ app.use(express.static(config.app_dir));
98
+
99
+ // Root serves index.html explicitly (even if static would catch it)
100
+ /**
101
+ * @param {Request} _req
102
+ * @param {Response} res
103
+ */
104
+ app.get('/', (_req, res) => {
105
+ const index_path = path.join(config.app_dir, 'index.html');
106
+ res.sendFile(index_path);
107
+ });
108
+
109
+ return app;
110
+ }
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
+ }
@@ -0,0 +1,203 @@
1
+ import { getConfig } from '../config.js';
2
+ import { resolveWorkspaceDatabase } from '../db.js';
3
+ import {
4
+ isProcessRunning,
5
+ printServerUrl,
6
+ readPidFile,
7
+ removePidFile,
8
+ startDaemon,
9
+ terminateProcess
10
+ } from './daemon.js';
11
+ import { openUrl, registerWorkspaceWithServer, waitForServer } from './open.js';
12
+
13
+ const STARTUP_SETTLE_MS = 200;
14
+ const REGISTER_RETRY_ATTEMPTS = 5;
15
+ const REGISTER_RETRY_DELAY_MS = 150;
16
+
17
+ /**
18
+ * Handle `start` command. Idempotent when already running.
19
+ * - Spawns a detached server process, writes PID file, returns 0.
20
+ * - If already running (PID file present and process alive), prints URL and returns 0.
21
+ *
22
+ * @param {{ open?: boolean, is_debug?: boolean, host?: string, port?: number }} [options]
23
+ * @returns {Promise<number>} Exit code (0 on success)
24
+ */
25
+ export async function handleStart(options) {
26
+ // Default: do not open a browser unless explicitly requested via `open: true`.
27
+ const should_open = options?.open === true;
28
+ const cwd = process.cwd();
29
+
30
+ // Set env vars early so getConfig() reflects CLI overrides in ALL branches,
31
+ // including the "already running" path that registers workspaces via HTTP.
32
+ if (options?.host) {
33
+ process.env.HOST = options.host;
34
+ }
35
+ if (options?.port) {
36
+ process.env.PORT = String(options.port);
37
+ }
38
+
39
+ const existing_pid = readPidFile();
40
+ if (existing_pid && isProcessRunning(existing_pid)) {
41
+ // Server is already running - register this workspace dynamically
42
+ const { url } = getConfig();
43
+ const registered = await registerCurrentWorkspace(url, cwd);
44
+ if (registered) {
45
+ console.log('Workspace registered: %s', cwd);
46
+ }
47
+ console.warn('Server is already running.');
48
+ if (should_open) {
49
+ await openUrl(url);
50
+ }
51
+ return 0;
52
+ }
53
+ if (existing_pid && !isProcessRunning(existing_pid)) {
54
+ // stale PID file
55
+ removePidFile();
56
+ }
57
+
58
+ const { url } = getConfig();
59
+
60
+ const started = startDaemon({
61
+ is_debug: options?.is_debug,
62
+ host: options?.host,
63
+ port: options?.port
64
+ });
65
+ if (started && started.pid > 0) {
66
+ // Give the spawned daemon a brief moment to fail fast (for example EADDRINUSE).
67
+ await sleep(STARTUP_SETTLE_MS);
68
+
69
+ if (!isProcessRunning(started.pid)) {
70
+ removePidFile();
71
+
72
+ // If another server is already running at the configured URL, register this
73
+ // workspace there so it appears in the picker instead of silently missing.
74
+ const registered = await registerCurrentWorkspaceWithRetry(url, cwd);
75
+ if (registered) {
76
+ console.warn(
77
+ 'Daemon exited early; registered workspace with existing server: %s',
78
+ cwd
79
+ );
80
+ return 0;
81
+ }
82
+ return 1;
83
+ }
84
+
85
+ // Register against the currently reachable server to ensure this workspace
86
+ // appears in the picker even when startup races with other daemons.
87
+ void registerCurrentWorkspaceWithRetry(url, cwd);
88
+
89
+ printServerUrl();
90
+ // Auto-open the browser once for a fresh daemon start
91
+ if (should_open) {
92
+ // Wait briefly for the server to accept connections (single retry window)
93
+ await waitForServer(url, 600);
94
+ // Best-effort open; ignore result
95
+ await openUrl(url);
96
+ }
97
+ return 0;
98
+ }
99
+
100
+ return 1;
101
+ }
102
+
103
+ /**
104
+ * @param {number} ms
105
+ * @returns {Promise<void>}
106
+ */
107
+ function sleep(ms) {
108
+ return new Promise((resolve) => {
109
+ setTimeout(() => {
110
+ resolve();
111
+ }, ms);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * @param {string} url
117
+ * @param {string} cwd
118
+ * @returns {Promise<boolean>}
119
+ */
120
+ async function registerCurrentWorkspace(url, cwd) {
121
+ const workspace_database = resolveWorkspaceDatabase({ cwd });
122
+ if (
123
+ workspace_database.source === 'home-default' ||
124
+ !workspace_database.exists
125
+ ) {
126
+ return false;
127
+ }
128
+
129
+ return registerWorkspaceWithServer(url, {
130
+ path: cwd,
131
+ database: workspace_database.path
132
+ });
133
+ }
134
+
135
+ /**
136
+ * @param {string} url
137
+ * @param {string} cwd
138
+ * @returns {Promise<boolean>}
139
+ */
140
+ async function registerCurrentWorkspaceWithRetry(url, cwd) {
141
+ for (let i = 0; i < REGISTER_RETRY_ATTEMPTS; i++) {
142
+ const registered = await registerCurrentWorkspace(url, cwd);
143
+ if (registered) {
144
+ return true;
145
+ }
146
+ if (i < REGISTER_RETRY_ATTEMPTS - 1) {
147
+ await sleep(REGISTER_RETRY_DELAY_MS);
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Handle `stop` command.
155
+ * - Sends SIGTERM and waits for exit (with SIGKILL fallback), removes PID file.
156
+ * - Returns 2 if not running.
157
+ *
158
+ * @returns {Promise<number>} Exit code
159
+ */
160
+ export async function handleStop() {
161
+ const existing_pid = readPidFile();
162
+ if (!existing_pid) {
163
+ return 2;
164
+ }
165
+
166
+ if (!isProcessRunning(existing_pid)) {
167
+ // stale PID file
168
+ removePidFile();
169
+ return 2;
170
+ }
171
+
172
+ const terminated = await terminateProcess(existing_pid, 5000);
173
+ if (terminated) {
174
+ removePidFile();
175
+ return 0;
176
+ }
177
+
178
+ // Not terminated within timeout
179
+ return 1;
180
+ }
181
+
182
+ /**
183
+ * Handle `restart` command: stop (ignore not-running) then start.
184
+ *
185
+ * @returns {Promise<number>} Exit code (0 on success)
186
+ */
187
+ /**
188
+ * Handle `restart` command: stop (ignore not-running) then start.
189
+ * Accepts the same options as `handleStart` and passes them through,
190
+ * so restart only opens a browser when `open` is explicitly true.
191
+ *
192
+ * @param {{ open?: boolean }} [options]
193
+ * @returns {Promise<number>}
194
+ */
195
+ export async function handleRestart(options) {
196
+ const stop_code = await handleStop();
197
+ // 0 = stopped, 2 = not running; both are acceptable to proceed
198
+ if (stop_code !== 0 && stop_code !== 2) {
199
+ return 1;
200
+ }
201
+ const start_code = await handleStart(options);
202
+ return start_code === 0 ? 0 : 1;
203
+ }