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.
- package/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- 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
|
+
}
|
package/server/config.js
ADDED
|
@@ -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
|
+
});
|
package/server/index.js
ADDED
|
@@ -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,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
|
+
}
|