beads-enhanced-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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * @import { SpawnOptions } from 'node:child_process'
3
+ */
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { getConfig } from '../config.js';
10
+ import { resolveWorkspaceDatabase } from '../db.js';
11
+
12
+ /**
13
+ * Resolve the runtime directory used for PID and log files.
14
+ * Prefers `BDUI_RUNTIME_DIR`, then `$XDG_RUNTIME_DIR/beads-ui`,
15
+ * and finally `os.tmpdir()/beads-ui`.
16
+ *
17
+ * @returns {string}
18
+ */
19
+ export function getRuntimeDir() {
20
+ const override_dir = process.env.BDUI_RUNTIME_DIR;
21
+ if (override_dir && override_dir.length > 0) {
22
+ return ensureDir(override_dir);
23
+ }
24
+
25
+ const xdg_dir = process.env.XDG_RUNTIME_DIR;
26
+ if (xdg_dir && xdg_dir.length > 0) {
27
+ return ensureDir(path.join(xdg_dir, 'beads-ui'));
28
+ }
29
+
30
+ return ensureDir(path.join(os.tmpdir(), 'beads-ui'));
31
+ }
32
+
33
+ /**
34
+ * Ensure a directory exists with safe permissions and return its path.
35
+ *
36
+ * @param {string} dir_path
37
+ * @returns {string}
38
+ */
39
+ function ensureDir(dir_path) {
40
+ try {
41
+ fs.mkdirSync(dir_path, { recursive: true, mode: 0o700 });
42
+ } catch {
43
+ // Best-effort; permission errors will surface on file ops later.
44
+ }
45
+ return dir_path;
46
+ }
47
+
48
+ /**
49
+ * @returns {string}
50
+ */
51
+ export function getPidFilePath() {
52
+ const runtime_dir = getRuntimeDir();
53
+ return path.join(runtime_dir, 'server.pid');
54
+ }
55
+
56
+ /**
57
+ * @returns {string}
58
+ */
59
+ export function getLogFilePath() {
60
+ const runtime_dir = getRuntimeDir();
61
+ return path.join(runtime_dir, 'daemon.log');
62
+ }
63
+
64
+ /**
65
+ * Read PID from the PID file if present.
66
+ *
67
+ * @returns {number | null}
68
+ */
69
+ export function readPidFile() {
70
+ const pid_file = getPidFilePath();
71
+ try {
72
+ const text = fs.readFileSync(pid_file, 'utf8');
73
+ const pid_value = Number.parseInt(text.trim(), 10);
74
+ if (Number.isFinite(pid_value) && pid_value > 0) {
75
+ return pid_value;
76
+ }
77
+ } catch {
78
+ // ignore missing or unreadable
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * @param {number} pid
85
+ */
86
+ export function writePidFile(pid) {
87
+ const pid_file = getPidFilePath();
88
+ try {
89
+ fs.writeFileSync(pid_file, String(pid) + '\n', { encoding: 'utf8' });
90
+ } catch {
91
+ // ignore write errors; daemon still runs but management degrades
92
+ }
93
+ }
94
+
95
+ export function removePidFile() {
96
+ const pid_file = getPidFilePath();
97
+ try {
98
+ fs.unlinkSync(pid_file);
99
+ } catch {
100
+ // ignore
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Check whether a process is running.
106
+ *
107
+ * @param {number} pid
108
+ * @returns {boolean}
109
+ */
110
+ export function isProcessRunning(pid) {
111
+ try {
112
+ if (pid <= 0) {
113
+ return false;
114
+ }
115
+ process.kill(pid, 0);
116
+ return true;
117
+ } catch (err) {
118
+ const code = /** @type {{ code?: string }} */ (err).code;
119
+ if (code === 'ESRCH') {
120
+ return false;
121
+ }
122
+ // EPERM or other errors imply the process likely exists but is not killable
123
+ return true;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Compute the absolute path to the server entry file.
129
+ *
130
+ * @returns {string}
131
+ */
132
+ export function getServerEntryPath() {
133
+ const here = fileURLToPath(new URL(import.meta.url));
134
+ const cli_dir = path.dirname(here);
135
+ const server_entry = path.resolve(cli_dir, '..', 'index.js');
136
+ return server_entry;
137
+ }
138
+
139
+ /**
140
+ * Spawn the server as a detached daemon, redirecting stdio to the log file.
141
+ * Writes the PID file upon success.
142
+ *
143
+ * @param {{ is_debug?: boolean, host?: string, port?: number }} [options]
144
+ * @returns {{ pid: number } | null} Returns child PID on success; null on failure.
145
+ */
146
+ export function startDaemon(options = {}) {
147
+ const server_entry = getServerEntryPath();
148
+ const log_file = getLogFilePath();
149
+
150
+ // Open the log file for appending; reuse for both stdout and stderr
151
+ /** @type {number} */
152
+ let log_fd;
153
+ try {
154
+ log_fd = fs.openSync(log_file, 'a');
155
+ if (options.is_debug) {
156
+ console.debug('log file ', log_file);
157
+ }
158
+ } catch {
159
+ // If log cannot be opened, fallback to ignoring stdio
160
+ log_fd = -1;
161
+ }
162
+
163
+ /** @type {Record<string, string | undefined>} */
164
+ const spawn_env = { ...process.env };
165
+ if (options.host) {
166
+ spawn_env.HOST = options.host;
167
+ }
168
+ if (options.port) {
169
+ spawn_env.PORT = String(options.port);
170
+ }
171
+
172
+ /** @type {SpawnOptions} */
173
+ const opts = {
174
+ cwd: process.cwd(),
175
+ detached: true,
176
+ env: spawn_env,
177
+ stdio: log_fd >= 0 ? ['ignore', log_fd, log_fd] : 'ignore',
178
+ windowsHide: true
179
+ };
180
+
181
+ try {
182
+ const child = spawn(process.execPath, [server_entry], opts);
183
+ // Detach fully from the parent
184
+ child.unref();
185
+ const child_pid = typeof child.pid === 'number' ? child.pid : -1;
186
+ if (child_pid > 0) {
187
+ if (options.is_debug) {
188
+ console.debug('starting ', child_pid);
189
+ }
190
+ writePidFile(child_pid);
191
+ return { pid: child_pid };
192
+ }
193
+ return null;
194
+ } catch (err) {
195
+ console.error('start error', err);
196
+ // Log startup error to log file for traceability
197
+ try {
198
+ const message =
199
+ new Date().toISOString() + ' start error: ' + String(err) + '\n';
200
+ fs.appendFileSync(log_file, message, 'utf8');
201
+ } catch {
202
+ // ignore
203
+ }
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Send SIGTERM then (optionally) SIGKILL to stop a process and wait for exit.
210
+ *
211
+ * @param {number} pid
212
+ * @param {number} timeout_ms
213
+ * @returns {Promise<boolean>} Resolves true if the process is gone.
214
+ */
215
+ export async function terminateProcess(pid, timeout_ms) {
216
+ try {
217
+ process.kill(pid, 'SIGTERM');
218
+ } catch (err) {
219
+ const code = /** @type {{ code?: string }} */ (err).code;
220
+ if (code === 'ESRCH') {
221
+ return true;
222
+ }
223
+ // On EPERM or others, continue to wait/poll
224
+ }
225
+
226
+ const start_time = Date.now();
227
+ // Poll until process no longer exists or timeout
228
+ while (Date.now() - start_time < timeout_ms) {
229
+ if (!isProcessRunning(pid)) {
230
+ return true;
231
+ }
232
+ await sleep(100);
233
+ }
234
+
235
+ // Fallback to SIGKILL
236
+ try {
237
+ process.kill(pid, 'SIGKILL');
238
+ } catch {
239
+ // ignore
240
+ }
241
+
242
+ // Give a brief moment after SIGKILL
243
+ await sleep(50);
244
+ return !isProcessRunning(pid);
245
+ }
246
+
247
+ /**
248
+ * @param {number} ms
249
+ * @returns {Promise<void>}
250
+ */
251
+ function sleep(ms) {
252
+ return new Promise((resolve) => {
253
+ setTimeout(() => {
254
+ resolve();
255
+ }, ms);
256
+ });
257
+ }
258
+
259
+ /**
260
+ * Print the server URL derived from current config.
261
+ */
262
+ export function printServerUrl() {
263
+ // Resolve from the caller's working directory by default
264
+ const resolved_db = resolveWorkspaceDatabase();
265
+ console.log(
266
+ `beads db ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
267
+ );
268
+
269
+ const { url } = getConfig();
270
+ console.log(`beads ui listening on ${url}`);
271
+ }
@@ -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,139 @@
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
+ * Register a workspace with the running server.
103
+ * Makes a POST request to /api/register-workspace.
104
+ *
105
+ * @param {string} base_url - Server base URL (e.g., "http://127.0.0.1:3000")
106
+ * @param {{ path: string, database: string }} workspace
107
+ * @returns {Promise<boolean>} True if registration succeeded
108
+ */
109
+ export async function registerWorkspaceWithServer(base_url, workspace) {
110
+ return new Promise((resolve) => {
111
+ const url = new URL('/api/register-workspace', base_url);
112
+ const body = JSON.stringify(workspace);
113
+ const req = http.request(
114
+ url,
115
+ {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Content-Type': 'application/json',
119
+ 'Content-Length': Buffer.byteLength(body)
120
+ }
121
+ },
122
+ (res) => {
123
+ res.resume();
124
+ resolve(res.statusCode === 200);
125
+ }
126
+ );
127
+ req.on('error', () => resolve(false));
128
+ req.setTimeout(2000, () => {
129
+ try {
130
+ req.destroy();
131
+ } catch {
132
+ void 0;
133
+ }
134
+ resolve(false);
135
+ });
136
+ req.write(body);
137
+ req.end();
138
+ });
139
+ }
@@ -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
+ }