@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/CHANGES.md +469 -0
- package/LICENSE +23 -0
- package/README.md +92 -0
- package/app/index.html +48 -0
- package/app/main.bundle.js +825 -0
- package/app/main.bundle.js.map +7 -0
- package/app/protocol.js +216 -0
- package/app/styles.css +2214 -0
- package/bin/bdui.js +19 -0
- package/package.json +82 -0
- package/server/app.js +119 -0
- package/server/bd.js +227 -0
- package/server/cli/commands.js +285 -0
- package/server/cli/daemon.js +340 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +178 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/index.js +76 -0
- package/server/list-adapters.js +365 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +305 -0
- package/server/validators.js +153 -0
- package/server/watcher.js +139 -0
- package/server/ws.js +1348 -0
|
@@ -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
|
+
}
|