@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.
@@ -0,0 +1,365 @@
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 'board-issues': {
43
+ // Handled by fetchBoardIssues in fetchListForSubscription
44
+ return [];
45
+ }
46
+ case 'filtered-issues': {
47
+ const p = spec.params || {};
48
+ const raw = typeof p.statuses === 'string' ? p.statuses : '';
49
+ const arr = raw
50
+ .split(',')
51
+ .map((s) => s.trim())
52
+ .filter((s) => s.length > 0);
53
+ const statuses = arr.length > 0 ? arr : ['open', 'in_progress', 'closed'];
54
+ return [
55
+ 'list',
56
+ '--json',
57
+ '--tree=false',
58
+ '--status',
59
+ statuses.join(','),
60
+ '--limit',
61
+ '1000'
62
+ ];
63
+ }
64
+ case 'issue-detail': {
65
+ const p = spec.params || {};
66
+ const id = String(p.id || '').trim();
67
+ if (id.length === 0) {
68
+ throw badRequest('Missing param: params.id');
69
+ }
70
+ return ['show', id, '--json'];
71
+ }
72
+ default: {
73
+ throw badRequest(`Unknown subscription type: ${t}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Normalize bd list output to minimal Issue shape used by the registry.
80
+ * - Ensures `id` is a string.
81
+ * - Coerces timestamps to numbers.
82
+ * - `closed_at` defaults to null when missing or invalid.
83
+ *
84
+ * @param {unknown} value
85
+ * @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
86
+ */
87
+ export function normalizeIssueList(value) {
88
+ if (!Array.isArray(value)) {
89
+ return [];
90
+ }
91
+ /** @type {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
92
+ const out = [];
93
+ for (const it of value) {
94
+ const id = String(it.id ?? '');
95
+ if (id.length === 0) {
96
+ continue;
97
+ }
98
+ const created_at = parseTimestamp(/** @type {any} */ (it).created_at);
99
+ const updated_at = parseTimestamp(it.updated_at);
100
+ const closed_raw = it.closed_at;
101
+ /** @type {number | null} */
102
+ let closed_at = null;
103
+ if (closed_raw !== undefined && closed_raw !== null) {
104
+ const n = parseTimestamp(closed_raw);
105
+ closed_at = Number.isFinite(n) ? n : null;
106
+ }
107
+ out.push({
108
+ ...it,
109
+ id,
110
+ created_at: Number.isFinite(created_at) ? created_at : 0,
111
+ updated_at: Number.isFinite(updated_at) ? updated_at : 0,
112
+ closed_at
113
+ });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * @typedef {Object} FetchListResultSuccess
120
+ * @property {true} ok
121
+ * @property {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
122
+ */
123
+
124
+ /**
125
+ * @typedef {Object} FetchListResultFailure
126
+ * @property {false} ok
127
+ * @property {{ code: string, message: string, details?: Record<string, unknown> }} error
128
+ */
129
+
130
+ /**
131
+ * Execute the mapped `bd` command for a subscription spec and return normalized items.
132
+ * Errors do not throw; they are surfaced as a structured object.
133
+ *
134
+ * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
135
+ * @param {{ cwd?: string }} [options] - Optional working directory for bd command
136
+ * @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
137
+ */
138
+ export async function fetchListForSubscription(spec, options = {}) {
139
+ // Board-issues: unified fetch for all board columns in a single subscription
140
+ if (String(spec.type) === 'board-issues') {
141
+ return fetchBoardIssues(options);
142
+ }
143
+
144
+ /** @type {string[]} */
145
+ let args;
146
+ try {
147
+ args = mapSubscriptionToBdArgs(spec);
148
+ } catch (err) {
149
+ // Surface bad requests (e.g., missing params)
150
+ log('mapSubscriptionToBdArgs failed for %o: %o', spec, err);
151
+ const e = toErrorObject(err);
152
+ return { ok: false, error: e };
153
+ }
154
+
155
+ try {
156
+ const res = await runBdJson(args, { cwd: options.cwd });
157
+ if (!res || res.code !== 0 || !('stdoutJson' in res)) {
158
+ log(
159
+ 'bd failed for %o (args=%o) code=%s stderr=%s',
160
+ spec,
161
+ args,
162
+ res?.code,
163
+ res?.stderr || ''
164
+ );
165
+ return {
166
+ ok: false,
167
+ error: {
168
+ code: 'bd_error',
169
+ message: String(res?.stderr || 'bd failed'),
170
+ details: { exit_code: res?.code ?? -1 }
171
+ }
172
+ };
173
+ }
174
+ // bd show may return a single object; normalize to an array first
175
+ let raw = Array.isArray(res.stdoutJson)
176
+ ? res.stdoutJson
177
+ : res.stdoutJson && typeof res.stdoutJson === 'object'
178
+ ? [res.stdoutJson]
179
+ : [];
180
+
181
+ // Special-case mapping for `epics`: current bd output nests the epic under
182
+ // an `epic` key and exposes counters at the top level. Flatten so that
183
+ // each entry has a top-level `id` and core fields expected by the registry.
184
+ if (String(spec.type) === 'epics') {
185
+ raw = raw.map((it) => {
186
+ if (it && typeof it === 'object' && 'epic' in it) {
187
+ const e = /** @type {any} */ (it).epic || {};
188
+ /** @type {Record<string, unknown>} */
189
+ const flat = {
190
+ // Required minimal fields for registry + client rendering
191
+ id: String(e.id ?? ''),
192
+ title: e.title,
193
+ status: e.status,
194
+ issue_type: e.issue_type || 'epic',
195
+ created_at: e.created_at,
196
+ updated_at: e.updated_at,
197
+ closed_at: e.closed_at ?? null,
198
+ deleted_at: e.deleted_at ?? null,
199
+ // Preserve useful counters from bd output
200
+ total_children: /** @type {any} */ (it).total_children,
201
+ closed_children: /** @type {any} */ (it).closed_children,
202
+ eligible_for_close: /** @type {any} */ (it).eligible_for_close
203
+ };
204
+ return flat;
205
+ }
206
+ return it;
207
+ });
208
+ raw = raw.filter((it) => {
209
+ if (!it || typeof it !== 'object') {
210
+ return false;
211
+ }
212
+ const status =
213
+ typeof (/** @type {any} */ (it).status) === 'string'
214
+ ? /** @type {any} */ (it).status
215
+ : '';
216
+ if (status === 'tombstone') {
217
+ return false;
218
+ }
219
+ const deleted_at = /** @type {any} */ (it).deleted_at;
220
+ if (deleted_at !== undefined && deleted_at !== null) {
221
+ return false;
222
+ }
223
+ return true;
224
+ });
225
+ }
226
+
227
+ const items = normalizeIssueList(raw);
228
+ return { ok: true, items };
229
+ } catch (err) {
230
+ log('bd invocation failed for %o (args=%o): %o', spec, args, err);
231
+ return {
232
+ ok: false,
233
+ error: {
234
+ code: 'bd_error',
235
+ message:
236
+ (err && /** @type {any} */ (err).message) || 'bd invocation failed'
237
+ }
238
+ };
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Create a `bad_request` error object.
244
+ *
245
+ * @param {string} message
246
+ */
247
+ function badRequest(message) {
248
+ const e = new Error(message);
249
+ // @ts-expect-error add code
250
+ e.code = 'bad_request';
251
+ return e;
252
+ }
253
+
254
+ /**
255
+ * Normalize arbitrary thrown values to a structured error object.
256
+ *
257
+ * @param {unknown} err
258
+ * @returns {FetchListResultFailure['error']}
259
+ */
260
+ function toErrorObject(err) {
261
+ if (err && typeof err === 'object') {
262
+ const any = /** @type {{ code?: unknown, message?: unknown }} */ (err);
263
+ const code = typeof any.code === 'string' ? any.code : 'bad_request';
264
+ const message =
265
+ typeof any.message === 'string' ? any.message : 'Request error';
266
+ return { code, message };
267
+ }
268
+ return { code: 'bad_request', message: 'Request error' };
269
+ }
270
+
271
+ /**
272
+ * Parse a bd timestamp string to epoch ms using Date.parse.
273
+ * Falls back to numeric coercion when parsing fails.
274
+ *
275
+ * @param {unknown} v
276
+ * @returns {number}
277
+ */
278
+ function parseTimestamp(v) {
279
+ if (typeof v === 'string') {
280
+ const ms = Date.parse(v);
281
+ if (Number.isFinite(ms)) {
282
+ return ms;
283
+ }
284
+ const n = Number(v);
285
+ return Number.isFinite(n) ? n : 0;
286
+ }
287
+ if (typeof v === 'number') {
288
+ return Number.isFinite(v) ? v : 0;
289
+ }
290
+ return 0;
291
+ }
292
+
293
+ /**
294
+ * Fetch all board data (ready, blocked, in_progress, closed) in parallel
295
+ * and merge into a single list with `_board_column` tags.
296
+ *
297
+ * Priority (last write wins): closed < ready < in_progress < blocked.
298
+ *
299
+ * @param {{ cwd?: string }} [options]
300
+ * @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
301
+ */
302
+ async function fetchBoardIssues(options = {}) {
303
+ const cwd = options.cwd;
304
+ const [readyRes, blockedRes, inProgRes, closedRes] = await Promise.all([
305
+ runBdJson(['ready', '--limit', '1000', '--json'], { cwd }),
306
+ runBdJson(['blocked', '--json'], { cwd }),
307
+ runBdJson(['list', '--json', '--tree=false', '--status', 'in_progress'], {
308
+ cwd
309
+ }),
310
+ runBdJson(
311
+ [
312
+ 'list',
313
+ '--json',
314
+ '--tree=false',
315
+ '--status',
316
+ 'closed',
317
+ '--limit',
318
+ '1000'
319
+ ],
320
+ { cwd }
321
+ )
322
+ ]);
323
+ /** @type {Map<string, { id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
324
+ const itemsById = new Map();
325
+
326
+ // 1. closed (lowest priority)
327
+ if (closedRes.code === 0 && Array.isArray(closedRes.stdoutJson)) {
328
+ for (const it of normalizeIssueList(closedRes.stdoutJson)) {
329
+ itemsById.set(it.id, { ...it, _board_column: 'closed' });
330
+ }
331
+ }
332
+ // 2. ready
333
+ if (readyRes.code === 0 && Array.isArray(readyRes.stdoutJson)) {
334
+ for (const it of normalizeIssueList(readyRes.stdoutJson)) {
335
+ itemsById.set(it.id, { ...it, _board_column: 'ready' });
336
+ }
337
+ }
338
+ // 3. in_progress (overrides ready — matches existing client-side dedup)
339
+ if (inProgRes.code === 0 && Array.isArray(inProgRes.stdoutJson)) {
340
+ for (const it of normalizeIssueList(inProgRes.stdoutJson)) {
341
+ itemsById.set(it.id, { ...it, _board_column: 'in_progress' });
342
+ }
343
+ }
344
+ // 4. blocked (highest priority)
345
+ if (blockedRes.code === 0 && Array.isArray(blockedRes.stdoutJson)) {
346
+ for (const it of normalizeIssueList(blockedRes.stdoutJson)) {
347
+ itemsById.set(it.id, { ...it, _board_column: 'blocked' });
348
+ }
349
+ }
350
+
351
+ const items = Array.from(itemsById.values());
352
+ if (
353
+ items.length === 0 &&
354
+ readyRes.code !== 0 &&
355
+ blockedRes.code !== 0 &&
356
+ inProgRes.code !== 0 &&
357
+ closedRes.code !== 0
358
+ ) {
359
+ return {
360
+ ok: false,
361
+ error: { code: 'bd_error', message: 'All board queries failed' }
362
+ };
363
+ }
364
+ return { ok: true, items };
365
+ }
@@ -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
+ }
@@ -0,0 +1,200 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { debug } from './logging.js';
5
+
6
+ const log = debug('registry-watcher');
7
+
8
+ /**
9
+ * In-memory registry of workspaces registered dynamically via the API.
10
+ * These supplement the file-based registry at ~/.beads/registry.json.
11
+ *
12
+ * @type {Map<string, { path: string, database: string, pid: number, version: string }>}
13
+ */
14
+ const inMemoryWorkspaces = new Map();
15
+
16
+ /**
17
+ * Register a workspace dynamically (in-memory).
18
+ * This allows `bdui start` to register workspaces when the server is already running.
19
+ *
20
+ * @param {{ path: string, database: string }} workspace
21
+ */
22
+ export function registerWorkspace(workspace) {
23
+ const normalized = path.resolve(workspace.path);
24
+ log('registering workspace: %s (db: %s)', normalized, workspace.database);
25
+ inMemoryWorkspaces.set(normalized, {
26
+ path: normalized,
27
+ database: workspace.database,
28
+ pid: process.pid,
29
+ version: 'dynamic'
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Get all dynamically registered workspaces (in-memory only).
35
+ *
36
+ * @returns {Array<{ path: string, database: string, pid: number, version: string }>}
37
+ */
38
+ export function getInMemoryWorkspaces() {
39
+ return Array.from(inMemoryWorkspaces.values());
40
+ }
41
+
42
+ /**
43
+ * @typedef {Object} RegistryEntry
44
+ * @property {string} workspace_path
45
+ * @property {string} socket_path
46
+ * @property {string} database_path
47
+ * @property {number} pid
48
+ * @property {string} version
49
+ * @property {string} started_at
50
+ */
51
+
52
+ /**
53
+ * Get the path to the global beads registry file.
54
+ *
55
+ * @returns {string}
56
+ */
57
+ export function getRegistryPath() {
58
+ return path.join(os.homedir(), '.beads', 'registry.json');
59
+ }
60
+
61
+ /**
62
+ * Read and parse the registry file.
63
+ *
64
+ * @returns {RegistryEntry[]}
65
+ */
66
+ export function readRegistry() {
67
+ const registry_path = getRegistryPath();
68
+ try {
69
+ const content = fs.readFileSync(registry_path, 'utf8');
70
+ const data = JSON.parse(content);
71
+ if (Array.isArray(data)) {
72
+ return data;
73
+ }
74
+ return [];
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Find the registry entry that matches the given root directory.
82
+ * Matches if the root_dir is the same as or a subdirectory of the workspace_path.
83
+ *
84
+ * @param {string} root_dir
85
+ * @returns {RegistryEntry | null}
86
+ */
87
+ export function findWorkspaceEntry(root_dir) {
88
+ const entries = readRegistry();
89
+ const normalized = path.resolve(root_dir);
90
+
91
+ // First, try exact match
92
+ for (const entry of entries) {
93
+ if (path.resolve(entry.workspace_path) === normalized) {
94
+ return entry;
95
+ }
96
+ }
97
+
98
+ // Then try to find if root_dir is inside a workspace
99
+ for (const entry of entries) {
100
+ const workspace = path.resolve(entry.workspace_path);
101
+ if (normalized.startsWith(workspace + path.sep)) {
102
+ return entry;
103
+ }
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Get all available workspaces from both the file-based registry and
111
+ * dynamically registered in-memory workspaces.
112
+ *
113
+ * @returns {Array<{ path: string, database: string, pid: number, version: string }>}
114
+ */
115
+ export function getAvailableWorkspaces() {
116
+ const entries = readRegistry();
117
+ const fileWorkspaces = entries.map((entry) => ({
118
+ path: entry.workspace_path,
119
+ database: entry.database_path,
120
+ pid: entry.pid,
121
+ version: entry.version
122
+ }));
123
+
124
+ // Merge in-memory workspaces, avoiding duplicates by path
125
+ const seen = new Set(fileWorkspaces.map((w) => path.resolve(w.path)));
126
+ const inMemory = getInMemoryWorkspaces().filter(
127
+ (w) => !seen.has(path.resolve(w.path))
128
+ );
129
+
130
+ return [...fileWorkspaces, ...inMemory];
131
+ }
132
+
133
+ /**
134
+ * Watch the global beads registry file and invoke callback when it changes.
135
+ *
136
+ * @param {(entries: RegistryEntry[]) => void} onChange
137
+ * @param {{ debounce_ms?: number }} [options]
138
+ * @returns {{ close: () => void }}
139
+ */
140
+ export function watchRegistry(onChange, options = {}) {
141
+ const debounce_ms = options.debounce_ms ?? 500;
142
+ const registry_path = getRegistryPath();
143
+ const registry_dir = path.dirname(registry_path);
144
+ const registry_file = path.basename(registry_path);
145
+
146
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
147
+ let timer;
148
+ /** @type {fs.FSWatcher | undefined} */
149
+ let watcher;
150
+
151
+ const schedule = () => {
152
+ if (timer) {
153
+ clearTimeout(timer);
154
+ }
155
+ timer = setTimeout(() => {
156
+ try {
157
+ const entries = readRegistry();
158
+ onChange(entries);
159
+ } catch (err) {
160
+ log('error reading registry on change: %o', err);
161
+ }
162
+ }, debounce_ms);
163
+ timer.unref?.();
164
+ };
165
+
166
+ try {
167
+ // Ensure the directory exists before watching
168
+ if (!fs.existsSync(registry_dir)) {
169
+ log('registry directory does not exist: %s', registry_dir);
170
+ return { close: () => {} };
171
+ }
172
+
173
+ watcher = fs.watch(
174
+ registry_dir,
175
+ { persistent: true },
176
+ (event_type, filename) => {
177
+ if (filename && String(filename) !== registry_file) {
178
+ return;
179
+ }
180
+ if (event_type === 'change' || event_type === 'rename') {
181
+ log('registry %s %s', event_type, filename || '');
182
+ schedule();
183
+ }
184
+ }
185
+ );
186
+ } catch (err) {
187
+ log('unable to watch registry directory: %o', err);
188
+ return { close: () => {} };
189
+ }
190
+
191
+ return {
192
+ close() {
193
+ if (timer) {
194
+ clearTimeout(timer);
195
+ timer = undefined;
196
+ }
197
+ watcher?.close();
198
+ }
199
+ };
200
+ }