@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,135 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { enableAllDebug } from '../logging.js';
|
|
3
|
+
import { handleRestart, handleStart, handleStop } from './commands.js';
|
|
4
|
+
import { printUsage } from './usage.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse argv into a command token, flags, and options.
|
|
8
|
+
*
|
|
9
|
+
* @param {string[]} args
|
|
10
|
+
* @returns {{ command: string | null, flags: string[], options: { host?: string, port?: number } }}
|
|
11
|
+
*/
|
|
12
|
+
export function parseArgs(args) {
|
|
13
|
+
/** @type {string[]} */
|
|
14
|
+
const flags = [];
|
|
15
|
+
/** @type {string | null} */
|
|
16
|
+
let command = null;
|
|
17
|
+
/** @type {{ host?: string, port?: number }} */
|
|
18
|
+
const options = {};
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const token = args[i];
|
|
22
|
+
if (token === '--help' || token === '-h') {
|
|
23
|
+
flags.push('help');
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (token === '--debug' || token === '-d') {
|
|
27
|
+
flags.push('debug');
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (token === '--open') {
|
|
31
|
+
flags.push('open');
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (token === '--version' || token === '-v') {
|
|
35
|
+
flags.push('version');
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (token === '--host' && i + 1 < args.length) {
|
|
39
|
+
options.host = args[++i];
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (token === '--port' && i + 1 < args.length) {
|
|
43
|
+
const port_value = Number.parseInt(args[++i], 10);
|
|
44
|
+
if (Number.isFinite(port_value) && port_value > 0) {
|
|
45
|
+
options.port = port_value;
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
!command &&
|
|
51
|
+
(token === 'start' || token === 'stop' || token === 'restart')
|
|
52
|
+
) {
|
|
53
|
+
command = token;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Ignore unrecognized tokens for now; future flags may be parsed here.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { command, flags, options };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load the package.json version string.
|
|
64
|
+
*
|
|
65
|
+
* @returns {Promise<string>}
|
|
66
|
+
*/
|
|
67
|
+
async function loadVersion() {
|
|
68
|
+
const package_url = new URL('../../package.json', import.meta.url);
|
|
69
|
+
const package_text = await readFile(package_url, 'utf8');
|
|
70
|
+
const package_data = JSON.parse(package_text);
|
|
71
|
+
const version = package_data.version;
|
|
72
|
+
if (typeof version !== 'string') {
|
|
73
|
+
throw new Error('Invalid package.json version');
|
|
74
|
+
}
|
|
75
|
+
return version;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* CLI main entry. Returns an exit code and prints usage on `--help` or errors.
|
|
80
|
+
* No side effects beyond invoking stub handlers.
|
|
81
|
+
*
|
|
82
|
+
* @param {string[]} args
|
|
83
|
+
* @returns {Promise<number>}
|
|
84
|
+
*/
|
|
85
|
+
export async function main(args) {
|
|
86
|
+
const { command, flags, options } = parseArgs(args);
|
|
87
|
+
|
|
88
|
+
const is_debug = flags.includes('debug');
|
|
89
|
+
if (is_debug) {
|
|
90
|
+
enableAllDebug();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (flags.includes('version')) {
|
|
94
|
+
const version = await loadVersion();
|
|
95
|
+
process.stdout.write(`${version}\n`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (flags.includes('help')) {
|
|
99
|
+
printUsage(process.stdout);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
if (!command) {
|
|
103
|
+
printUsage(process.stdout);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (command === 'start') {
|
|
108
|
+
/**
|
|
109
|
+
* Default behavior: do NOT open a browser. `--open` explicitly opens.
|
|
110
|
+
*/
|
|
111
|
+
const start_options = {
|
|
112
|
+
open: flags.includes('open'),
|
|
113
|
+
is_debug: is_debug || Boolean(process.env.DEBUG),
|
|
114
|
+
host: options.host,
|
|
115
|
+
port: options.port
|
|
116
|
+
};
|
|
117
|
+
return await handleStart(start_options);
|
|
118
|
+
}
|
|
119
|
+
if (command === 'stop') {
|
|
120
|
+
return await handleStop();
|
|
121
|
+
}
|
|
122
|
+
if (command === 'restart') {
|
|
123
|
+
const restart_options = {
|
|
124
|
+
open: flags.includes('open'),
|
|
125
|
+
is_debug: is_debug || Boolean(process.env.DEBUG),
|
|
126
|
+
host: options.host,
|
|
127
|
+
port: options.port
|
|
128
|
+
};
|
|
129
|
+
return await handleRestart(restart_options);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Unknown command path (should not happen due to parseArgs guard)
|
|
133
|
+
printUsage(process.stdout);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
*
|
|
7
|
+
* @param {string} url
|
|
8
|
+
* @param {string} platform
|
|
9
|
+
* @returns {{ cmd: string, args: string[] }}
|
|
10
|
+
*/
|
|
11
|
+
export function computeOpenCommand(url, platform) {
|
|
12
|
+
if (platform === 'darwin') {
|
|
13
|
+
return { cmd: 'open', args: [url] };
|
|
14
|
+
}
|
|
15
|
+
if (platform === 'win32') {
|
|
16
|
+
// Use `start` via cmd.exe to open URLs
|
|
17
|
+
return { cmd: 'cmd', args: ['/c', 'start', '', url] };
|
|
18
|
+
}
|
|
19
|
+
// Assume Linux/other Unix with xdg-open
|
|
20
|
+
return { cmd: 'xdg-open', args: [url] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Open the given URL in the default browser. Best-effort; resolves true on spawn success.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} url
|
|
27
|
+
* @returns {Promise<boolean>}
|
|
28
|
+
*/
|
|
29
|
+
export async function openUrl(url) {
|
|
30
|
+
const { cmd, args } = computeOpenCommand(url, process.platform);
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(cmd, args, {
|
|
33
|
+
stdio: 'ignore',
|
|
34
|
+
detached: false
|
|
35
|
+
});
|
|
36
|
+
// If spawn succeeded and pid is present, consider it a success
|
|
37
|
+
return typeof child.pid === 'number' && child.pid > 0;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Wait until the server at the URL accepts a connection, with a brief retry.
|
|
45
|
+
* Does not throw; returns when either a connection was accepted or timeout elapsed.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} url
|
|
48
|
+
* @param {number} total_timeout_ms
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
export async function waitForServer(url, total_timeout_ms = 600) {
|
|
52
|
+
const deadline = Date.now() + total_timeout_ms;
|
|
53
|
+
|
|
54
|
+
// Attempt one GET; if it fails, wait and try once more within the deadline
|
|
55
|
+
const tryOnce = () =>
|
|
56
|
+
new Promise((resolve) => {
|
|
57
|
+
let done = false;
|
|
58
|
+
const req = http.get(url, (res) => {
|
|
59
|
+
// Any response implies the server is accepting connections
|
|
60
|
+
if (!done) {
|
|
61
|
+
done = true;
|
|
62
|
+
res.resume();
|
|
63
|
+
resolve(undefined);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
req.on('error', () => {
|
|
67
|
+
if (!done) {
|
|
68
|
+
done = true;
|
|
69
|
+
resolve(undefined);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
req.setTimeout(200, () => {
|
|
73
|
+
try {
|
|
74
|
+
req.destroy();
|
|
75
|
+
} catch {
|
|
76
|
+
void 0;
|
|
77
|
+
}
|
|
78
|
+
if (!done) {
|
|
79
|
+
done = true;
|
|
80
|
+
resolve(undefined);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await tryOnce();
|
|
86
|
+
|
|
87
|
+
if (Date.now() < deadline) {
|
|
88
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
89
|
+
await sleep(remaining);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {number} ms
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
function sleep(ms) {
|
|
98
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Fetch the list of workspaces from the running server.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} base_url - Server base URL (e.g., "http://127.0.0.1:3000")
|
|
105
|
+
* @returns {Promise<Array<{ path: string, database: string }>>}
|
|
106
|
+
*/
|
|
107
|
+
export async function fetchWorkspacesFromServer(base_url) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const url = new URL('/api/workspaces', base_url);
|
|
110
|
+
const req = http.get(url, (res) => {
|
|
111
|
+
let data = '';
|
|
112
|
+
res.on('data', (chunk) => {
|
|
113
|
+
data += chunk;
|
|
114
|
+
});
|
|
115
|
+
res.on('end', () => {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(data);
|
|
118
|
+
if (parsed.ok && Array.isArray(parsed.workspaces)) {
|
|
119
|
+
resolve(parsed.workspaces);
|
|
120
|
+
} else {
|
|
121
|
+
resolve([]);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
resolve([]);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
req.on('error', () => resolve([]));
|
|
129
|
+
req.setTimeout(2000, () => {
|
|
130
|
+
try {
|
|
131
|
+
req.destroy();
|
|
132
|
+
} catch {
|
|
133
|
+
void 0;
|
|
134
|
+
}
|
|
135
|
+
resolve([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Register a workspace with the running server.
|
|
142
|
+
* Makes a POST request to /api/register-workspace.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} base_url - Server base URL (e.g., "http://127.0.0.1:3000")
|
|
145
|
+
* @param {{ path: string, database: string }} workspace
|
|
146
|
+
* @returns {Promise<boolean>} True if registration succeeded
|
|
147
|
+
*/
|
|
148
|
+
export async function registerWorkspaceWithServer(base_url, workspace) {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const url = new URL('/api/register-workspace', base_url);
|
|
151
|
+
const body = JSON.stringify(workspace);
|
|
152
|
+
const req = http.request(
|
|
153
|
+
url,
|
|
154
|
+
{
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
'Content-Type': 'application/json',
|
|
158
|
+
'Content-Length': Buffer.byteLength(body)
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
(res) => {
|
|
162
|
+
res.resume();
|
|
163
|
+
resolve(res.statusCode === 200);
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
req.on('error', () => resolve(false));
|
|
167
|
+
req.setTimeout(2000, () => {
|
|
168
|
+
try {
|
|
169
|
+
req.destroy();
|
|
170
|
+
} catch {
|
|
171
|
+
void 0;
|
|
172
|
+
}
|
|
173
|
+
resolve(false);
|
|
174
|
+
});
|
|
175
|
+
req.write(body);
|
|
176
|
+
req.end();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Print CLI usage to a stream-like target.
|
|
3
|
+
*
|
|
4
|
+
* @param {{ write: (chunk: string) => any }} out_stream
|
|
5
|
+
*/
|
|
6
|
+
export function printUsage(out_stream) {
|
|
7
|
+
const lines = [
|
|
8
|
+
'Usage: bdui <command> [options]',
|
|
9
|
+
'',
|
|
10
|
+
'Commands:',
|
|
11
|
+
' start Start the UI server',
|
|
12
|
+
' stop Stop the UI server',
|
|
13
|
+
' restart Restart the UI server',
|
|
14
|
+
'',
|
|
15
|
+
'Options:',
|
|
16
|
+
' -h, --help Show this help message',
|
|
17
|
+
' -v, --version Show the CLI version',
|
|
18
|
+
' -d, --debug Enable debug logging',
|
|
19
|
+
' --open Open the browser after start/restart',
|
|
20
|
+
' --host <addr> Bind to a specific host (default: 127.0.0.1)',
|
|
21
|
+
' --port <num> Bind to a specific port (default: 3000)',
|
|
22
|
+
''
|
|
23
|
+
];
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
out_stream.write(line + '\n');
|
|
26
|
+
}
|
|
27
|
+
}
|
package/server/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve runtime configuration for the server.
|
|
6
|
+
* Notes:
|
|
7
|
+
* - `app_dir` is resolved relative to the installed package location.
|
|
8
|
+
* - `root_dir` represents the directory where the process was invoked
|
|
9
|
+
* (i.e., the current working directory) so DB resolution follows the
|
|
10
|
+
* caller's context rather than the install location.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ host: string, port: number, app_dir: string, root_dir: string, url: string }}
|
|
13
|
+
*/
|
|
14
|
+
export function getConfig() {
|
|
15
|
+
const this_file = fileURLToPath(new URL(import.meta.url));
|
|
16
|
+
const server_dir = path.dirname(this_file);
|
|
17
|
+
const package_root = path.resolve(server_dir, '..');
|
|
18
|
+
// Always reflect the directory from which the process was started
|
|
19
|
+
const root_dir = process.cwd();
|
|
20
|
+
|
|
21
|
+
let port_value = Number.parseInt(process.env.PORT || '', 10);
|
|
22
|
+
if (!Number.isFinite(port_value)) {
|
|
23
|
+
port_value = 3000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const host_env = process.env.HOST;
|
|
27
|
+
const host_value = host_env && host_env.length > 0 ? host_env : '127.0.0.1';
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
host: host_value,
|
|
31
|
+
port: port_value,
|
|
32
|
+
app_dir: path.resolve(package_root, 'app'),
|
|
33
|
+
root_dir,
|
|
34
|
+
url: `http://${host_value}:${port_value}`
|
|
35
|
+
};
|
|
36
|
+
}
|
package/server/db.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the SQLite DB path used by beads according to precedence:
|
|
7
|
+
* 1) explicit --db flag (provided via options.explicit_db)
|
|
8
|
+
* 2) BEADS_DB environment variable
|
|
9
|
+
* 3) nearest ".beads/*.db" by walking up from cwd (excluding
|
|
10
|
+
* "~/.beads/default.db", which is reserved for fallback)
|
|
11
|
+
* 4) "~/.beads/default.db" fallback
|
|
12
|
+
*
|
|
13
|
+
* Returns a normalized absolute path and a `source` indicator. Existence is
|
|
14
|
+
* returned via the `exists` boolean.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
|
|
17
|
+
* @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
|
|
18
|
+
*/
|
|
19
|
+
export function resolveDbPath(options = {}) {
|
|
20
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
21
|
+
const env = options.env || process.env;
|
|
22
|
+
const home_default = path.join(os.homedir(), '.beads', 'default.db');
|
|
23
|
+
|
|
24
|
+
// 1) explicit flag
|
|
25
|
+
if (options.explicit_db && options.explicit_db.length > 0) {
|
|
26
|
+
const p = absFrom(options.explicit_db, cwd);
|
|
27
|
+
return { path: p, source: 'flag', exists: fileExists(p) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2) BEADS_DB env
|
|
31
|
+
if (env.BEADS_DB && String(env.BEADS_DB).length > 0) {
|
|
32
|
+
const p = absFrom(String(env.BEADS_DB), cwd);
|
|
33
|
+
return { path: p, source: 'env', exists: fileExists(p) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 3) nearest .beads/*.db walking up
|
|
37
|
+
const nearest = findNearestBeadsDb(cwd);
|
|
38
|
+
if (nearest && path.normalize(nearest) !== path.normalize(home_default)) {
|
|
39
|
+
return { path: nearest, source: 'nearest', exists: fileExists(nearest) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 4) ~/.beads/default.db
|
|
43
|
+
return {
|
|
44
|
+
path: home_default,
|
|
45
|
+
source: 'home-default',
|
|
46
|
+
exists: fileExists(home_default)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the workspace database location used by the UI/server.
|
|
52
|
+
*
|
|
53
|
+
* For non-SQLite backends (for example Dolt), this returns the nearest
|
|
54
|
+
* workspace `.beads` directory when metadata exists. This avoids collapsing
|
|
55
|
+
* all such workspaces onto the `~/.beads/default.db` fallback.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
|
|
58
|
+
* @returns {{ path: string, source: 'flag'|'env'|'nearest'|'metadata'|'home-default', exists: boolean }}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveWorkspaceDatabase(options = {}) {
|
|
61
|
+
const sqlite_db = resolveDbPath(options);
|
|
62
|
+
if (sqlite_db.source !== 'home-default') {
|
|
63
|
+
return sqlite_db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
67
|
+
const metadata_path = findNearestBeadsMetadata(cwd);
|
|
68
|
+
if (metadata_path) {
|
|
69
|
+
return {
|
|
70
|
+
path: path.dirname(metadata_path),
|
|
71
|
+
source: 'metadata',
|
|
72
|
+
exists: true
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sqlite_db;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Find nearest `.beads/metadata.json` by walking up from start.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} start
|
|
83
|
+
* @returns {string | null}
|
|
84
|
+
*/
|
|
85
|
+
export function findNearestBeadsMetadata(start) {
|
|
86
|
+
let dir = path.resolve(start);
|
|
87
|
+
for (let i = 0; i < 100; i++) {
|
|
88
|
+
const metadata_path = path.join(dir, '.beads', 'metadata.json');
|
|
89
|
+
if (fileExists(metadata_path)) {
|
|
90
|
+
return metadata_path;
|
|
91
|
+
}
|
|
92
|
+
const parent = path.dirname(dir);
|
|
93
|
+
if (parent === dir) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
dir = parent;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Find nearest .beads/*.db by walking up from start.
|
|
103
|
+
* First alphabetical .db.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} start
|
|
106
|
+
* @returns {string | null}
|
|
107
|
+
*/
|
|
108
|
+
export function findNearestBeadsDb(start) {
|
|
109
|
+
let dir = path.resolve(start);
|
|
110
|
+
// Cap iterations to avoid infinite loop in degenerate cases
|
|
111
|
+
for (let i = 0; i < 100; i++) {
|
|
112
|
+
const beads_dir = path.join(dir, '.beads');
|
|
113
|
+
try {
|
|
114
|
+
const entries = fs.readdirSync(beads_dir, { withFileTypes: true });
|
|
115
|
+
const dbs = entries
|
|
116
|
+
.filter((e) => e.isFile() && e.name.endsWith('.db'))
|
|
117
|
+
.map((e) => e.name)
|
|
118
|
+
.sort();
|
|
119
|
+
if (dbs.length > 0) {
|
|
120
|
+
return path.join(beads_dir, dbs[0]);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore and walk up
|
|
124
|
+
}
|
|
125
|
+
const parent = path.dirname(dir);
|
|
126
|
+
if (parent === dir) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
dir = parent;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} p
|
|
138
|
+
* @param {string} cwd
|
|
139
|
+
*/
|
|
140
|
+
function absFrom(p, cwd) {
|
|
141
|
+
return path.isAbsolute(p) ? path.normalize(p) : path.join(cwd, p);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} p
|
|
146
|
+
*/
|
|
147
|
+
function fileExists(p) {
|
|
148
|
+
try {
|
|
149
|
+
fs.accessSync(p, fs.constants.F_OK);
|
|
150
|
+
return true;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createApp } from './app.js';
|
|
3
|
+
import { printServerUrl } from './cli/daemon.js';
|
|
4
|
+
import { getConfig } from './config.js';
|
|
5
|
+
import { resolveWorkspaceDatabase } from './db.js';
|
|
6
|
+
import { debug, enableAllDebug } from './logging.js';
|
|
7
|
+
import { registerWorkspace, watchRegistry } from './registry-watcher.js';
|
|
8
|
+
import { watchDb } from './watcher.js';
|
|
9
|
+
import { attachWsServer } from './ws.js';
|
|
10
|
+
|
|
11
|
+
if (process.argv.includes('--debug') || process.argv.includes('-d')) {
|
|
12
|
+
enableAllDebug();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Parse --host and --port from argv and set env vars before getConfig()
|
|
16
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
17
|
+
if (process.argv[i] === '--host' && process.argv[i + 1]) {
|
|
18
|
+
process.env.HOST = process.argv[++i];
|
|
19
|
+
}
|
|
20
|
+
if (process.argv[i] === '--port' && process.argv[i + 1]) {
|
|
21
|
+
process.env.PORT = process.argv[++i];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const config = getConfig();
|
|
26
|
+
const app = createApp(config);
|
|
27
|
+
const server = createServer(app);
|
|
28
|
+
const log = debug('server');
|
|
29
|
+
|
|
30
|
+
// Register the initial workspace (from cwd) so it appears in the workspace picker
|
|
31
|
+
// even without the beads daemon running
|
|
32
|
+
const workspace_database = resolveWorkspaceDatabase({ cwd: config.root_dir });
|
|
33
|
+
if (workspace_database.source !== 'home-default' && workspace_database.exists) {
|
|
34
|
+
registerWorkspace({
|
|
35
|
+
path: config.root_dir,
|
|
36
|
+
database: workspace_database.path
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Watch the active beads DB and schedule subscription refresh for active lists
|
|
41
|
+
const db_watcher = watchDb(config.root_dir, () => {
|
|
42
|
+
// Schedule subscription list refresh run for active subscriptions
|
|
43
|
+
log('db change detected → schedule refresh');
|
|
44
|
+
scheduleListRefresh();
|
|
45
|
+
// v2: all updates flow via subscription push envelopes only
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { scheduleListRefresh } = attachWsServer(server, {
|
|
49
|
+
path: '/ws',
|
|
50
|
+
heartbeat_ms: 30000,
|
|
51
|
+
// Coalesce DB change bursts into one refresh run
|
|
52
|
+
refresh_debounce_ms: 75,
|
|
53
|
+
root_dir: config.root_dir,
|
|
54
|
+
watcher: db_watcher
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Watch the global registry for workspace changes (e.g., when user starts
|
|
58
|
+
// bd daemon in a different project). This enables automatic workspace switching.
|
|
59
|
+
watchRegistry(
|
|
60
|
+
(entries) => {
|
|
61
|
+
log('registry changed: %d entries', entries.length);
|
|
62
|
+
// Find if there's a newer workspace that matches our initial root
|
|
63
|
+
// For now, we just log the change - users can switch via set-workspace
|
|
64
|
+
// Future: could auto-switch if a workspace was started in a parent/child dir
|
|
65
|
+
},
|
|
66
|
+
{ debounce_ms: 500 }
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
server.listen(config.port, config.host, () => {
|
|
70
|
+
printServerUrl();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server.on('error', (err) => {
|
|
74
|
+
log('server error %o', err);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
});
|