@seungyeop-lee/beads-ui 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }
@@ -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
+ });