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/server/db.js ADDED
@@ -0,0 +1,154 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Resolve the SQLite DB path used by beads according to precedence:
7
+ * 1) explicit --db flag (provided via options.explicit_db)
8
+ * 2) BEADS_DB environment variable
9
+ * 3) nearest ".beads/*.db" by walking up from cwd (excluding
10
+ * "~/.beads/default.db", which is reserved for fallback)
11
+ * 4) "~/.beads/default.db" fallback
12
+ *
13
+ * Returns a normalized absolute path and a `source` indicator. Existence is
14
+ * returned via the `exists` boolean.
15
+ *
16
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
17
+ * @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
18
+ */
19
+ export function resolveDbPath(options = {}) {
20
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
21
+ const env = options.env || process.env;
22
+ const home_default = path.join(os.homedir(), '.beads', 'default.db');
23
+
24
+ // 1) explicit flag
25
+ if (options.explicit_db && options.explicit_db.length > 0) {
26
+ const p = absFrom(options.explicit_db, cwd);
27
+ return { path: p, source: 'flag', exists: fileExists(p) };
28
+ }
29
+
30
+ // 2) BEADS_DB env
31
+ if (env.BEADS_DB && String(env.BEADS_DB).length > 0) {
32
+ const p = absFrom(String(env.BEADS_DB), cwd);
33
+ return { path: p, source: 'env', exists: fileExists(p) };
34
+ }
35
+
36
+ // 3) nearest .beads/*.db walking up
37
+ const nearest = findNearestBeadsDb(cwd);
38
+ if (nearest && path.normalize(nearest) !== path.normalize(home_default)) {
39
+ return { path: nearest, source: 'nearest', exists: fileExists(nearest) };
40
+ }
41
+
42
+ // 4) ~/.beads/default.db
43
+ return {
44
+ path: home_default,
45
+ source: 'home-default',
46
+ exists: fileExists(home_default)
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Resolve the workspace database location used by the UI/server.
52
+ *
53
+ * For non-SQLite backends (for example Dolt), this returns the nearest
54
+ * workspace `.beads` directory when metadata exists. This avoids collapsing
55
+ * all such workspaces onto the `~/.beads/default.db` fallback.
56
+ *
57
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
58
+ * @returns {{ path: string, source: 'flag'|'env'|'nearest'|'metadata'|'home-default', exists: boolean }}
59
+ */
60
+ export function resolveWorkspaceDatabase(options = {}) {
61
+ const sqlite_db = resolveDbPath(options);
62
+ if (sqlite_db.source !== 'home-default') {
63
+ return sqlite_db;
64
+ }
65
+
66
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
67
+ const metadata_path = findNearestBeadsMetadata(cwd);
68
+ if (metadata_path) {
69
+ return {
70
+ path: path.dirname(metadata_path),
71
+ source: 'metadata',
72
+ exists: true
73
+ };
74
+ }
75
+
76
+ return sqlite_db;
77
+ }
78
+
79
+ /**
80
+ * Find nearest `.beads/metadata.json` by walking up from start.
81
+ *
82
+ * @param {string} start
83
+ * @returns {string | null}
84
+ */
85
+ export function findNearestBeadsMetadata(start) {
86
+ let dir = path.resolve(start);
87
+ for (let i = 0; i < 100; i++) {
88
+ const metadata_path = path.join(dir, '.beads', 'metadata.json');
89
+ if (fileExists(metadata_path)) {
90
+ return metadata_path;
91
+ }
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir) {
94
+ break;
95
+ }
96
+ dir = parent;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Find nearest .beads/*.db by walking up from start.
103
+ * First alphabetical .db.
104
+ *
105
+ * @param {string} start
106
+ * @returns {string | null}
107
+ */
108
+ export function findNearestBeadsDb(start) {
109
+ let dir = path.resolve(start);
110
+ // Cap iterations to avoid infinite loop in degenerate cases
111
+ for (let i = 0; i < 100; i++) {
112
+ const beads_dir = path.join(dir, '.beads');
113
+ try {
114
+ const entries = fs.readdirSync(beads_dir, { withFileTypes: true });
115
+ const dbs = entries
116
+ .filter((e) => e.isFile() && e.name.endsWith('.db'))
117
+ .map((e) => e.name)
118
+ .sort();
119
+ if (dbs.length > 0) {
120
+ return path.join(beads_dir, dbs[0]);
121
+ }
122
+ } catch {
123
+ // ignore and walk up
124
+ }
125
+ const parent = path.dirname(dir);
126
+ if (parent === dir) {
127
+ break;
128
+ }
129
+ dir = parent;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
136
+ *
137
+ * @param {string} p
138
+ * @param {string} cwd
139
+ */
140
+ function absFrom(p, cwd) {
141
+ return path.isAbsolute(p) ? path.normalize(p) : path.join(cwd, p);
142
+ }
143
+
144
+ /**
145
+ * @param {string} p
146
+ */
147
+ function fileExists(p) {
148
+ try {
149
+ fs.accessSync(p, fs.constants.F_OK);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
@@ -0,0 +1,76 @@
1
+ import { createServer } from 'node:http';
2
+ import { createApp } from './app.js';
3
+ import { printServerUrl } from './cli/daemon.js';
4
+ import { getConfig } from './config.js';
5
+ import { resolveWorkspaceDatabase } from './db.js';
6
+ import { debug, enableAllDebug } from './logging.js';
7
+ import { registerWorkspace, watchRegistry } from './registry-watcher.js';
8
+ import { watchDb } from './watcher.js';
9
+ import { attachWsServer } from './ws.js';
10
+
11
+ if (process.argv.includes('--debug') || process.argv.includes('-d')) {
12
+ enableAllDebug();
13
+ }
14
+
15
+ // Parse --host and --port from argv and set env vars before getConfig()
16
+ for (let i = 0; i < process.argv.length; i++) {
17
+ if (process.argv[i] === '--host' && process.argv[i + 1]) {
18
+ process.env.HOST = process.argv[++i];
19
+ }
20
+ if (process.argv[i] === '--port' && process.argv[i + 1]) {
21
+ process.env.PORT = process.argv[++i];
22
+ }
23
+ }
24
+
25
+ const config = getConfig();
26
+ const app = createApp(config);
27
+ const server = createServer(app);
28
+ const log = debug('server');
29
+
30
+ // Register the initial workspace (from cwd) so it appears in the workspace picker
31
+ // even without the beads daemon running
32
+ const workspace_database = resolveWorkspaceDatabase({ cwd: config.root_dir });
33
+ if (workspace_database.source !== 'home-default' && workspace_database.exists) {
34
+ registerWorkspace({
35
+ path: config.root_dir,
36
+ database: workspace_database.path
37
+ });
38
+ }
39
+
40
+ // Watch the active beads DB and schedule subscription refresh for active lists
41
+ const db_watcher = watchDb(config.root_dir, () => {
42
+ // Schedule subscription list refresh run for active subscriptions
43
+ log('db change detected → schedule refresh');
44
+ scheduleListRefresh();
45
+ // v2: all updates flow via subscription push envelopes only
46
+ });
47
+
48
+ const { scheduleListRefresh } = attachWsServer(server, {
49
+ path: '/ws',
50
+ heartbeat_ms: 30000,
51
+ // Coalesce DB change bursts into one refresh run
52
+ refresh_debounce_ms: 75,
53
+ root_dir: config.root_dir,
54
+ watcher: db_watcher
55
+ });
56
+
57
+ // Watch the global registry for workspace changes (e.g., when user starts
58
+ // bd daemon in a different project). This enables automatic workspace switching.
59
+ watchRegistry(
60
+ (entries) => {
61
+ log('registry changed: %d entries', entries.length);
62
+ // Find if there's a newer workspace that matches our initial root
63
+ // For now, we just log the change - users can switch via set-workspace
64
+ // Future: could auto-switch if a workspace was started in a parent/child dir
65
+ },
66
+ { debounce_ms: 500 }
67
+ );
68
+
69
+ server.listen(config.port, config.host, () => {
70
+ printServerUrl();
71
+ });
72
+
73
+ server.on('error', (err) => {
74
+ log('server error %o', err);
75
+ process.exitCode = 1;
76
+ });
@@ -0,0 +1,264 @@
1
+ import { runBdJson } from './bd.js';
2
+ import { debug } from './logging.js';
3
+
4
+ const log = debug('list-adapters');
5
+
6
+ /**
7
+ * Build concrete `bd` CLI args for a subscription type + params.
8
+ * Always includes `--json` for parseable output.
9
+ *
10
+ * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
11
+ * @returns {string[]}
12
+ */
13
+ export function mapSubscriptionToBdArgs(spec) {
14
+ const t = String(spec.type);
15
+ switch (t) {
16
+ case 'all-issues': {
17
+ return ['list', '--json', '--tree=false'];
18
+ }
19
+ case 'epics': {
20
+ return ['epic', 'status', '--json'];
21
+ }
22
+ case 'blocked-issues': {
23
+ return ['blocked', '--json'];
24
+ }
25
+ case 'ready-issues': {
26
+ return ['ready', '--limit', '1000', '--json'];
27
+ }
28
+ case 'in-progress-issues': {
29
+ return ['list', '--json', '--tree=false', '--status', 'in_progress'];
30
+ }
31
+ case 'closed-issues': {
32
+ return [
33
+ 'list',
34
+ '--json',
35
+ '--tree=false',
36
+ '--status',
37
+ 'closed',
38
+ '--limit',
39
+ '1000'
40
+ ];
41
+ }
42
+ case 'issue-detail': {
43
+ const p = spec.params || {};
44
+ const id = String(p.id || '').trim();
45
+ if (id.length === 0) {
46
+ throw badRequest('Missing param: params.id');
47
+ }
48
+ return ['show', id, '--json'];
49
+ }
50
+ default: {
51
+ throw badRequest(`Unknown subscription type: ${t}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Normalize bd list output to minimal Issue shape used by the registry.
58
+ * - Ensures `id` is a string.
59
+ * - Coerces timestamps to numbers.
60
+ * - `closed_at` defaults to null when missing or invalid.
61
+ *
62
+ * @param {unknown} value
63
+ * @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
64
+ */
65
+ export function normalizeIssueList(value) {
66
+ if (!Array.isArray(value)) {
67
+ return [];
68
+ }
69
+ /** @type {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
70
+ const out = [];
71
+ for (const it of value) {
72
+ const id = String(it.id ?? '');
73
+ if (id.length === 0) {
74
+ continue;
75
+ }
76
+ const created_at = parseTimestamp(/** @type {any} */ (it).created_at);
77
+ const updated_at = parseTimestamp(it.updated_at);
78
+ const closed_raw = it.closed_at;
79
+ /** @type {number | null} */
80
+ let closed_at = null;
81
+ if (closed_raw !== undefined && closed_raw !== null) {
82
+ const n = parseTimestamp(closed_raw);
83
+ closed_at = Number.isFinite(n) ? n : null;
84
+ }
85
+ out.push({
86
+ ...it,
87
+ id,
88
+ created_at: Number.isFinite(created_at) ? created_at : 0,
89
+ updated_at: Number.isFinite(updated_at) ? updated_at : 0,
90
+ closed_at
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * @typedef {Object} FetchListResultSuccess
98
+ * @property {true} ok
99
+ * @property {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} FetchListResultFailure
104
+ * @property {false} ok
105
+ * @property {{ code: string, message: string, details?: Record<string, unknown> }} error
106
+ */
107
+
108
+ /**
109
+ * Execute the mapped `bd` command for a subscription spec and return normalized items.
110
+ * Errors do not throw; they are surfaced as a structured object.
111
+ *
112
+ * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
113
+ * @param {{ cwd?: string }} [options] - Optional working directory for bd command
114
+ * @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
115
+ */
116
+ export async function fetchListForSubscription(spec, options = {}) {
117
+ /** @type {string[]} */
118
+ let args;
119
+ try {
120
+ args = mapSubscriptionToBdArgs(spec);
121
+ } catch (err) {
122
+ // Surface bad requests (e.g., missing params)
123
+ log('mapSubscriptionToBdArgs failed for %o: %o', spec, err);
124
+ const e = toErrorObject(err);
125
+ return { ok: false, error: e };
126
+ }
127
+
128
+ try {
129
+ const res = await runBdJson(args, { cwd: options.cwd });
130
+ if (!res || res.code !== 0 || !('stdoutJson' in res)) {
131
+ log(
132
+ 'bd failed for %o (args=%o) code=%s stderr=%s',
133
+ spec,
134
+ args,
135
+ res?.code,
136
+ res?.stderr || ''
137
+ );
138
+ return {
139
+ ok: false,
140
+ error: {
141
+ code: 'bd_error',
142
+ message: String(res?.stderr || 'bd failed'),
143
+ details: { exit_code: res?.code ?? -1 }
144
+ }
145
+ };
146
+ }
147
+ // bd show may return a single object; normalize to an array first
148
+ let raw = Array.isArray(res.stdoutJson)
149
+ ? res.stdoutJson
150
+ : res.stdoutJson && typeof res.stdoutJson === 'object'
151
+ ? [res.stdoutJson]
152
+ : [];
153
+
154
+ // Special-case mapping for `epics`: current bd output nests the epic under
155
+ // an `epic` key and exposes counters at the top level. Flatten so that
156
+ // each entry has a top-level `id` and core fields expected by the registry.
157
+ if (String(spec.type) === 'epics') {
158
+ raw = raw.map((it) => {
159
+ if (it && typeof it === 'object' && 'epic' in it) {
160
+ const e = /** @type {any} */ (it).epic || {};
161
+ /** @type {Record<string, unknown>} */
162
+ const flat = {
163
+ // Required minimal fields for registry + client rendering
164
+ id: String(e.id ?? ''),
165
+ title: e.title,
166
+ status: e.status,
167
+ issue_type: e.issue_type || 'epic',
168
+ created_at: e.created_at,
169
+ updated_at: e.updated_at,
170
+ closed_at: e.closed_at ?? null,
171
+ deleted_at: e.deleted_at ?? null,
172
+ // Preserve useful counters from bd output
173
+ total_children: /** @type {any} */ (it).total_children,
174
+ closed_children: /** @type {any} */ (it).closed_children,
175
+ eligible_for_close: /** @type {any} */ (it).eligible_for_close
176
+ };
177
+ return flat;
178
+ }
179
+ return it;
180
+ });
181
+ raw = raw.filter((it) => {
182
+ if (!it || typeof it !== 'object') {
183
+ return false;
184
+ }
185
+ const status =
186
+ typeof (/** @type {any} */ (it).status) === 'string'
187
+ ? /** @type {any} */ (it).status
188
+ : '';
189
+ if (status === 'tombstone') {
190
+ return false;
191
+ }
192
+ const deleted_at = /** @type {any} */ (it).deleted_at;
193
+ if (deleted_at !== undefined && deleted_at !== null) {
194
+ return false;
195
+ }
196
+ return true;
197
+ });
198
+ }
199
+
200
+ const items = normalizeIssueList(raw);
201
+ return { ok: true, items };
202
+ } catch (err) {
203
+ log('bd invocation failed for %o (args=%o): %o', spec, args, err);
204
+ return {
205
+ ok: false,
206
+ error: {
207
+ code: 'bd_error',
208
+ message:
209
+ (err && /** @type {any} */ (err).message) || 'bd invocation failed'
210
+ }
211
+ };
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Create a `bad_request` error object.
217
+ *
218
+ * @param {string} message
219
+ */
220
+ function badRequest(message) {
221
+ const e = new Error(message);
222
+ // @ts-expect-error add code
223
+ e.code = 'bad_request';
224
+ return e;
225
+ }
226
+
227
+ /**
228
+ * Normalize arbitrary thrown values to a structured error object.
229
+ *
230
+ * @param {unknown} err
231
+ * @returns {FetchListResultFailure['error']}
232
+ */
233
+ function toErrorObject(err) {
234
+ if (err && typeof err === 'object') {
235
+ const any = /** @type {{ code?: unknown, message?: unknown }} */ (err);
236
+ const code = typeof any.code === 'string' ? any.code : 'bad_request';
237
+ const message =
238
+ typeof any.message === 'string' ? any.message : 'Request error';
239
+ return { code, message };
240
+ }
241
+ return { code: 'bad_request', message: 'Request error' };
242
+ }
243
+
244
+ /**
245
+ * Parse a bd timestamp string to epoch ms using Date.parse.
246
+ * Falls back to numeric coercion when parsing fails.
247
+ *
248
+ * @param {unknown} v
249
+ * @returns {number}
250
+ */
251
+ function parseTimestamp(v) {
252
+ if (typeof v === 'string') {
253
+ const ms = Date.parse(v);
254
+ if (Number.isFinite(ms)) {
255
+ return ms;
256
+ }
257
+ const n = Number(v);
258
+ return Number.isFinite(n) ? n : 0;
259
+ }
260
+ if (typeof v === 'number') {
261
+ return Number.isFinite(v) ? v : 0;
262
+ }
263
+ return 0;
264
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Debug logger helper for Node server/CLI.
3
+ */
4
+ import createDebug from 'debug';
5
+
6
+ /**
7
+ * Create a namespaced logger for Node runtime.
8
+ *
9
+ * @param {string} ns - Module namespace suffix (e.g., 'ws', 'watcher').
10
+ */
11
+ export function debug(ns) {
12
+ return createDebug(`beads-ui:${ns}`);
13
+ }
14
+
15
+ /**
16
+ * Enable all `beads-ui:*` debug logs at runtime for Node/CLI.
17
+ * Safe to call multiple times.
18
+ */
19
+ export function enableAllDebug() {
20
+ // `debug` exposes a global enable/disable API.
21
+ // Enabling after loggers are created updates their `.enabled` state.
22
+ createDebug.enable(process.env.DEBUG || 'beads-ui:*');
23
+ }