beads-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.
Files changed (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
@@ -0,0 +1,74 @@
1
+ import { handleRestart, handleStart, handleStop } from './commands.js';
2
+ import { printUsage } from './usage.js';
3
+
4
+ /**
5
+ * Parse argv into a command token and flags.
6
+ * @param {string[]} args
7
+ * @returns {{ command: string | null, flags: string[] }}
8
+ */
9
+ export function parseArgs(args) {
10
+ /** @type {string[]} */
11
+ const flags = [];
12
+ /** @type {string | null} */
13
+ let command = null;
14
+
15
+ for (const token of args) {
16
+ if (token === '--help' || token === '-h') {
17
+ flags.push('help');
18
+ continue;
19
+ }
20
+ if (token === '--no-open') {
21
+ flags.push('no-open');
22
+ continue;
23
+ }
24
+ if (
25
+ !command &&
26
+ (token === 'start' || token === 'stop' || token === 'restart')
27
+ ) {
28
+ command = token;
29
+ continue;
30
+ }
31
+ // Ignore unrecognized tokens for now; future flags may be parsed here.
32
+ }
33
+
34
+ return { command, flags };
35
+ }
36
+
37
+ /**
38
+ * CLI main entry. Returns an exit code and prints usage on `--help` or errors.
39
+ * No side effects beyond invoking stub handlers.
40
+ * @param {string[]} args
41
+ * @returns {Promise<number>}
42
+ */
43
+ export async function main(args) {
44
+ const { command, flags } = parseArgs(args);
45
+
46
+ if (flags.includes('help')) {
47
+ printUsage(process.stdout);
48
+ return 0;
49
+ }
50
+ if (!command) {
51
+ printUsage(process.stdout);
52
+ return 1;
53
+ }
54
+
55
+ if (command === 'start') {
56
+ /** @type {{ no_open: boolean }} */
57
+ const options = {
58
+ no_open:
59
+ flags.includes('no-open') ||
60
+ String(process.env.BDUI_NO_OPEN || '') === '1'
61
+ };
62
+ return await handleStart(options);
63
+ }
64
+ if (command === 'stop') {
65
+ return await handleStop();
66
+ }
67
+ if (command === 'restart') {
68
+ return await handleRestart();
69
+ }
70
+
71
+ // Unknown command path (should not happen due to parseArgs guard)
72
+ printUsage(process.stdout);
73
+ return 1;
74
+ }
@@ -0,0 +1,96 @@
1
+ import { spawn } from 'node:child_process';
2
+ import http from 'node:http';
3
+
4
+ /**
5
+ * Compute a platform-specific command to open a URL in the default browser.
6
+ * @param {string} url
7
+ * @param {string} platform
8
+ * @returns {{ cmd: string, args: string[] }}
9
+ */
10
+ export function computeOpenCommand(url, platform) {
11
+ if (platform === 'darwin') {
12
+ return { cmd: 'open', args: [url] };
13
+ }
14
+ if (platform === 'win32') {
15
+ // Use `start` via cmd.exe to open URLs
16
+ return { cmd: 'cmd', args: ['/c', 'start', '', url] };
17
+ }
18
+ // Assume Linux/other Unix with xdg-open
19
+ return { cmd: 'xdg-open', args: [url] };
20
+ }
21
+
22
+ /**
23
+ * Open the given URL in the default browser. Best-effort; resolves true on spawn success.
24
+ * @param {string} url
25
+ * @returns {Promise<boolean>}
26
+ */
27
+ export async function openUrl(url) {
28
+ const { cmd, args } = computeOpenCommand(url, process.platform);
29
+ try {
30
+ const child = spawn(cmd, args, {
31
+ stdio: 'ignore',
32
+ detached: false
33
+ });
34
+ // If spawn succeeded and pid is present, consider it a success
35
+ return typeof child.pid === 'number' && child.pid > 0;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Wait until the server at the URL accepts a connection, with a brief retry.
43
+ * Does not throw; returns when either a connection was accepted or timeout elapsed.
44
+ * @param {string} url
45
+ * @param {number} total_timeout_ms
46
+ * @returns {Promise<void>}
47
+ */
48
+ export async function waitForServer(url, total_timeout_ms = 600) {
49
+ const deadline = Date.now() + total_timeout_ms;
50
+
51
+ // Attempt one GET; if it fails, wait and try once more within the deadline
52
+ const tryOnce = () =>
53
+ new Promise((resolve) => {
54
+ let done = false;
55
+ const req = http.get(url, (res) => {
56
+ // Any response implies the server is accepting connections
57
+ if (!done) {
58
+ done = true;
59
+ res.resume();
60
+ resolve(undefined);
61
+ }
62
+ });
63
+ req.on('error', () => {
64
+ if (!done) {
65
+ done = true;
66
+ resolve(undefined);
67
+ }
68
+ });
69
+ req.setTimeout(200, () => {
70
+ try {
71
+ req.destroy();
72
+ } catch {
73
+ void 0;
74
+ }
75
+ if (!done) {
76
+ done = true;
77
+ resolve(undefined);
78
+ }
79
+ });
80
+ });
81
+
82
+ await tryOnce();
83
+
84
+ if (Date.now() < deadline) {
85
+ const remaining = Math.max(0, deadline - Date.now());
86
+ await sleep(remaining);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * @param {number} ms
92
+ * @returns {Promise<void>}
93
+ */
94
+ function sleep(ms) {
95
+ return new Promise((resolve) => setTimeout(resolve, ms));
96
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { computeOpenCommand } from './open.js';
3
+
4
+ describe('computeOpenCommand', () => {
5
+ test('returns macOS open command', () => {
6
+ const r = computeOpenCommand('http://127.0.0.1:3000', 'darwin');
7
+
8
+ expect(r.cmd).toBe('open');
9
+ expect(r.args).toEqual(['http://127.0.0.1:3000']);
10
+ });
11
+
12
+ test('returns Linux xdg-open command', () => {
13
+ const r = computeOpenCommand('http://127.0.0.1:3000', 'linux');
14
+
15
+ expect(r.cmd).toBe('xdg-open');
16
+ expect(r.args).toEqual(['http://127.0.0.1:3000']);
17
+ });
18
+
19
+ test('returns Windows start command via cmd', () => {
20
+ const r = computeOpenCommand('http://127.0.0.1:3000', 'win32');
21
+
22
+ expect(r.cmd).toBe('cmd');
23
+ expect(r.args.slice(0, 3)).toEqual(['/c', 'start', '']);
24
+ expect(r.args[r.args.length - 1]).toBe('http://127.0.0.1:3000');
25
+ });
26
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Print CLI usage to a stream-like target.
3
+ * @param {{ write: (chunk: string) => any }} out_stream
4
+ */
5
+ export function printUsage(out_stream) {
6
+ const lines = [
7
+ 'Usage: bdui <command> [options]',
8
+ '',
9
+ 'Commands:',
10
+ ' start Start the UI server (daemonized in later steps)',
11
+ ' stop Stop the UI server',
12
+ ' restart Restart the UI server',
13
+ '',
14
+ 'Options:',
15
+ ' -h, --help Show this help message',
16
+ ' --no-open Do not open the browser on start',
17
+ ''
18
+ ];
19
+ for (const line of lines) {
20
+ out_stream.write(line + '\n');
21
+ }
22
+ }
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ /**
5
+ * Resolve runtime configuration for the server.
6
+ * @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string }}
7
+ */
8
+ export function getConfig() {
9
+ const this_file = fileURLToPath(new URL(import.meta.url));
10
+ const server_dir = path.dirname(this_file);
11
+ const root_dir = path.resolve(server_dir, '..');
12
+
13
+ /** @type {number} */
14
+ let port_value = Number.parseInt(process.env.PORT || '', 10);
15
+ if (!Number.isFinite(port_value)) {
16
+ port_value = 3000;
17
+ }
18
+
19
+ /** @type {string} */
20
+ const host_value = '127.0.0.1';
21
+
22
+ return {
23
+ host: host_value,
24
+ port: port_value,
25
+ env: process.env.NODE_ENV ? String(process.env.NODE_ENV) : 'development',
26
+ app_dir: path.resolve(root_dir, 'app'),
27
+ root_dir
28
+ };
29
+ }
package/server/db.js ADDED
@@ -0,0 +1,100 @@
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
10
+ * 4) "~/.beads/default.db" fallback
11
+ *
12
+ * Returns a normalized absolute path and a `source` indicator. Existence is
13
+ * returned via the `exists` boolean.
14
+ * @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
15
+ * @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
16
+ */
17
+ export function resolveDbPath(options = {}) {
18
+ const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
19
+ const env = options.env || process.env;
20
+
21
+ // 1) explicit flag
22
+ if (options.explicit_db && options.explicit_db.length > 0) {
23
+ const p = absFrom(options.explicit_db, cwd);
24
+ return { path: p, source: 'flag', exists: fileExists(p) };
25
+ }
26
+
27
+ // 2) BEADS_DB env
28
+ if (env.BEADS_DB && String(env.BEADS_DB).length > 0) {
29
+ const p = absFrom(String(env.BEADS_DB), cwd);
30
+ return { path: p, source: 'env', exists: fileExists(p) };
31
+ }
32
+
33
+ // 3) nearest .beads/*.db walking up
34
+ const nearest = findNearestBeadsDb(cwd);
35
+ if (nearest) {
36
+ return { path: nearest, source: 'nearest', exists: fileExists(nearest) };
37
+ }
38
+
39
+ // 4) ~/.beads/default.db
40
+ const homeDefault = path.join(os.homedir(), '.beads', 'default.db');
41
+ return {
42
+ path: homeDefault,
43
+ source: 'home-default',
44
+ exists: fileExists(homeDefault)
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Find nearest .beads/*.db by walking up from start.
50
+ * First alphabetical .db.
51
+ * @param {string} start
52
+ * @returns {string | null}
53
+ */
54
+ export function findNearestBeadsDb(start) {
55
+ let dir = path.resolve(start);
56
+ // Cap iterations to avoid infinite loop in degenerate cases
57
+ for (let i = 0; i < 100; i++) {
58
+ const beadsDir = path.join(dir, '.beads');
59
+ try {
60
+ const entries = fs.readdirSync(beadsDir, { withFileTypes: true });
61
+ /** @type {string[]} */
62
+ const dbs = entries
63
+ .filter((e) => e.isFile() && e.name.endsWith('.db'))
64
+ .map((e) => e.name)
65
+ .sort();
66
+ if (dbs.length > 0) {
67
+ return path.join(beadsDir, dbs[0]);
68
+ }
69
+ } catch {
70
+ // ignore and walk up
71
+ }
72
+ const parent = path.dirname(dir);
73
+ if (parent === dir) {
74
+ break;
75
+ }
76
+ dir = parent;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
83
+ * @param {string} p
84
+ * @param {string} cwd
85
+ */
86
+ function absFrom(p, cwd) {
87
+ return path.isAbsolute(p) ? path.normalize(p) : path.join(cwd, p);
88
+ }
89
+
90
+ /**
91
+ * @param {string} p
92
+ */
93
+ function fileExists(p) {
94
+ try {
95
+ fs.accessSync(p, fs.constants.F_OK);
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
5
+ import { findNearestBeadsDb, resolveDbPath } from './db.js';
6
+
7
+ /** @type {string[]} */
8
+ const tmps = [];
9
+
10
+ function mkdtemp() {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'beads-ui-test-'));
12
+ tmps.push(dir);
13
+ return dir;
14
+ }
15
+
16
+ beforeEach(() => {
17
+ vi.resetModules();
18
+ });
19
+
20
+ afterEach(() => {
21
+ for (const d of tmps.splice(0)) {
22
+ try {
23
+ fs.rmSync(d, { recursive: true, force: true });
24
+ } catch {
25
+ // ignore cleanup errors
26
+ }
27
+ }
28
+ });
29
+
30
+ describe('resolveDbPath', () => {
31
+ test('uses explicit_db when provided', () => {
32
+ const res = resolveDbPath({ cwd: '/x', explicit_db: './my.db', env: {} });
33
+ expect(res.path.endsWith('/x/my.db')).toBe(true);
34
+ expect(res.source).toBe('flag');
35
+ });
36
+
37
+ test('uses BEADS_DB from env when set', () => {
38
+ const res = resolveDbPath({ cwd: '/x', env: { BEADS_DB: '/abs/env.db' } });
39
+ expect(res.path).toBe('/abs/env.db');
40
+ expect(res.source).toBe('env');
41
+ });
42
+
43
+ test('finds nearest .beads/ui.db walking up', () => {
44
+ const root = mkdtemp();
45
+ const nested = path.join(root, 'a', 'b', 'c');
46
+ fs.mkdirSync(nested, { recursive: true });
47
+ const beads = path.join(root, '.beads');
48
+ fs.mkdirSync(beads);
49
+ const uiDb = path.join(beads, 'ui.db');
50
+ fs.writeFileSync(uiDb, '');
51
+
52
+ const found = findNearestBeadsDb(nested);
53
+ expect(found).toBe(uiDb);
54
+
55
+ const res = resolveDbPath({ cwd: nested, env: {} });
56
+ expect(res.path).toBe(uiDb);
57
+ expect(res.source).toBe('nearest');
58
+ });
59
+
60
+ test('falls back to ~/.beads/default.db when none found', async () => {
61
+ // Mock os.homedir to a deterministic location using spy
62
+ const home = mkdtemp();
63
+ const spy = vi.spyOn(os, 'homedir').mockReturnValue(home);
64
+ const mod = await import('./db.js');
65
+ const res = mod.resolveDbPath({ cwd: '/no/db/here', env: {} });
66
+ expect(res.path).toBe(path.join(home, '.beads', 'default.db'));
67
+ expect(res.source).toBe('home-default');
68
+ spy.mockRestore();
69
+ });
70
+ });
@@ -0,0 +1,29 @@
1
+ import { createServer } from 'node:http';
2
+ import { createApp } from './app.js';
3
+ import { getConfig } from './config.js';
4
+ import { watchDb } from './watcher.js';
5
+ import { attachWsServer } from './ws.js';
6
+
7
+ const config = getConfig();
8
+ const app = createApp(config);
9
+ const server = createServer(app);
10
+ const { notifyIssuesChanged } = attachWsServer(server, {
11
+ path: '/ws',
12
+ heartbeat_ms: 30000
13
+ });
14
+
15
+ // Watch the active beads DB and push invalidation (targeted when possible)
16
+ watchDb(config.root_dir, (payload) => {
17
+ notifyIssuesChanged(payload);
18
+ });
19
+
20
+ server.listen(config.port, config.host, () => {
21
+ console.log(
22
+ `beads-ui server listening on http://${config.host}:${config.port} (${config.env})`
23
+ );
24
+ });
25
+
26
+ server.on('error', (err) => {
27
+ console.error('Server error:', err);
28
+ process.exitCode = 1;
29
+ });
@@ -0,0 +1,3 @@
1
+ // Server-side access to protocol utilities and constants.
2
+ // Import from the shared client definition to ensure a single source of truth.
3
+ export * from '../app/protocol.js';
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ MESSAGE_TYPES,
4
+ PROTOCOL_VERSION,
5
+ decodeReply,
6
+ decodeRequest,
7
+ isMessageType,
8
+ isReply,
9
+ isRequest,
10
+ makeError,
11
+ makeOk,
12
+ makeRequest
13
+ } from './protocol.js';
14
+
15
+ describe('server/protocol', () => {
16
+ test('isMessageType returns true for known type', () => {
17
+ // execution
18
+ const res = isMessageType('list-issues');
19
+
20
+ // assertion
21
+ expect(res).toBe(true);
22
+ });
23
+
24
+ test('isMessageType returns false for unknown type', () => {
25
+ // execution
26
+ const res = isMessageType('not-a-type');
27
+
28
+ // assertion
29
+ expect(res).toBe(false);
30
+ });
31
+
32
+ test('makeRequest and decodeRequest round-trip', () => {
33
+ // setup
34
+ const req = makeRequest('show-issue', { id: 'UI-9' }, 'r-9');
35
+
36
+ // execution
37
+ const decoded = decodeRequest(JSON.parse(JSON.stringify(req)));
38
+
39
+ // assertion
40
+ expect(isRequest(req)).toBe(true);
41
+ expect(decoded.id).toBe('r-9');
42
+ expect(decoded.type).toBe('show-issue');
43
+ });
44
+
45
+ test('makeOk and makeError create valid replies', () => {
46
+ // setup
47
+ const req = makeRequest('list-issues', undefined, 'r-10');
48
+
49
+ // execution
50
+ const ok = makeOk(req, [{ id: 'UI-1' }]);
51
+ const err = makeError(req, 'boom', 'Something went wrong');
52
+
53
+ // assertion
54
+ expect(isReply(ok)).toBe(true);
55
+ expect(isReply(err)).toBe(true);
56
+ expect(ok.ok).toBe(true);
57
+ expect(err.ok).toBe(false);
58
+ });
59
+
60
+ test('decodeReply accepts ok and error envelopes', () => {
61
+ // setup
62
+ const req = makeRequest('edit-text', { id: 'UI-1', text: 'x' }, 'r-11');
63
+ const ok = makeOk(req, { id: 'UI-1' });
64
+ const err = makeError(req, 'validation', 'Invalid');
65
+
66
+ // execution
67
+ const ok2 = decodeReply(JSON.parse(JSON.stringify(ok)));
68
+ const err2 = decodeReply(JSON.parse(JSON.stringify(err)));
69
+
70
+ // assertion
71
+ expect(ok2.ok).toBe(true);
72
+ expect(err2.ok).toBe(false);
73
+ });
74
+
75
+ test('invalid envelopes throw on decode', () => {
76
+ // execution + assertion
77
+ expect(() => decodeRequest({})).toThrow();
78
+ expect(() => decodeReply({ ok: true })).toThrow();
79
+ });
80
+
81
+ test('exports protocol constants', () => {
82
+ // assertion
83
+ expect(typeof PROTOCOL_VERSION).toBe('string');
84
+ expect(Array.isArray(MESSAGE_TYPES)).toBe(true);
85
+ expect(MESSAGE_TYPES.length).toBeGreaterThan(0);
86
+ });
87
+ });
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { resolveDbPath } from './db.js';
4
+
5
+ /**
6
+ * Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
7
+ * The DB path is resolved following beads precedence and can be overridden via options.
8
+ * @param {string} root_dir - Project root directory (starting point for resolution).
9
+ * @param {(payload: { ts: number }) => void} on_change - Called when changes are detected.
10
+ * @param {{ debounce_ms?: number, explicit_db?: string }} [options]
11
+ * @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
12
+ */
13
+ export function watchDb(root_dir, on_change, options = {}) {
14
+ const debounce_ms = options.debounce_ms ?? 250;
15
+
16
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
17
+ let timer;
18
+ /** @type {fs.FSWatcher | undefined} */
19
+ let watcher;
20
+ /** @type {string} */
21
+ let current_path = '';
22
+ /** @type {string} */
23
+ let current_dir = '';
24
+ /** @type {string} */
25
+ let current_file = '';
26
+
27
+ const schedule = () => {
28
+ if (timer) {
29
+ clearTimeout(timer);
30
+ }
31
+ timer = setTimeout(() => {
32
+ on_change({ ts: Date.now() });
33
+ }, debounce_ms);
34
+ timer.unref?.();
35
+ };
36
+
37
+ /**
38
+ * Attach a watcher to the directory containing the resolved DB path.
39
+ * @param {string} base_dir
40
+ * @param {string | undefined} explicit_db
41
+ */
42
+ const bind = (base_dir, explicit_db) => {
43
+ const resolved = resolveDbPath({ cwd: base_dir, explicit_db });
44
+ current_path = resolved.path;
45
+ current_dir = path.dirname(current_path);
46
+ current_file = path.basename(current_path);
47
+ if (!resolved.exists) {
48
+ console.warn(
49
+ 'watchDb: resolved DB does not exist yet:',
50
+ current_path,
51
+ '\nHint: set --db, export BEADS_DB, or run `bd init` in your workspace.'
52
+ );
53
+ }
54
+
55
+ // (Re)create watcher
56
+ try {
57
+ watcher = fs.watch(
58
+ current_dir,
59
+ { persistent: true },
60
+ (event_type, filename) => {
61
+ if (filename && String(filename) !== current_file) {
62
+ return;
63
+ }
64
+ if (event_type === 'change' || event_type === 'rename') {
65
+ schedule();
66
+ }
67
+ }
68
+ );
69
+ } catch (err) {
70
+ console.warn('watchDb: unable to watch directory', current_dir, err);
71
+ }
72
+ };
73
+
74
+ // initial bind
75
+ bind(root_dir, options.explicit_db);
76
+
77
+ return {
78
+ get path() {
79
+ return current_path;
80
+ },
81
+ close() {
82
+ if (timer) {
83
+ clearTimeout(timer);
84
+ timer = undefined;
85
+ }
86
+ watcher?.close();
87
+ },
88
+ /**
89
+ * Re-resolve and reattach watcher when root_dir or explicit_db changes.
90
+ * @param {{ root_dir?: string, explicit_db?: string }} [opts]
91
+ */
92
+ rebind(opts = {}) {
93
+ const next_root = opts.root_dir ? String(opts.root_dir) : root_dir;
94
+ const next_explicit = opts.explicit_db ?? options.explicit_db;
95
+ const nextResolved = resolveDbPath({
96
+ cwd: next_root,
97
+ explicit_db: next_explicit
98
+ });
99
+ const next_path = nextResolved.path;
100
+ if (next_path !== current_path) {
101
+ // swap watcher
102
+ watcher?.close();
103
+ bind(next_root, next_explicit);
104
+ }
105
+ }
106
+ };
107
+ }