@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,285 @@
1
+ import { getConfig } from '../config.js';
2
+ import { resolveWorkspaceDatabase } from '../db.js';
3
+ import {
4
+ detectListeningPort,
5
+ findAvailablePort,
6
+ isProcessRunning,
7
+ printServerUrl,
8
+ readPidFile,
9
+ removePidFile,
10
+ startDaemon,
11
+ terminateProcess
12
+ } from './daemon.js';
13
+ import {
14
+ fetchWorkspacesFromServer,
15
+ openUrl,
16
+ registerWorkspaceWithServer,
17
+ waitForServer
18
+ } from './open.js';
19
+
20
+ const RESTART_SERVER_READY_MS = 400;
21
+
22
+ const STARTUP_SETTLE_MS = 200;
23
+ const REGISTER_RETRY_ATTEMPTS = 5;
24
+ const REGISTER_RETRY_DELAY_MS = 150;
25
+
26
+ /**
27
+ * Handle `start` command. Idempotent when already running.
28
+ * - Spawns a detached server process, writes PID file, returns 0.
29
+ * - If already running (PID file present and process alive), prints URL and returns 0.
30
+ *
31
+ * @param {{ open?: boolean, is_debug?: boolean, host?: string, port?: number }} [options]
32
+ * @returns {Promise<number>} Exit code (0 on success)
33
+ */
34
+ export async function handleStart(options) {
35
+ // Default: do not open a browser unless explicitly requested via `open: true`.
36
+ const should_open = options?.open === true;
37
+ const cwd = process.cwd();
38
+
39
+ // Set env vars early so getConfig() reflects CLI overrides in ALL branches,
40
+ // including the "already running" path that registers workspaces via HTTP.
41
+ if (options?.host) {
42
+ process.env.HOST = options.host;
43
+ }
44
+ if (options?.port) {
45
+ process.env.PORT = String(options.port);
46
+ }
47
+
48
+ const existing_pid = readPidFile();
49
+ if (existing_pid && isProcessRunning(existing_pid)) {
50
+ // Server is already running - register this workspace dynamically
51
+ const { url } = getConfig();
52
+ const registered = await registerCurrentWorkspace(url, cwd);
53
+ if (registered) {
54
+ console.log('Workspace registered: %s', cwd);
55
+ }
56
+ console.warn('Server is already running.');
57
+ if (should_open) {
58
+ await openUrl(url);
59
+ }
60
+ return 0;
61
+ }
62
+ if (existing_pid && !isProcessRunning(existing_pid)) {
63
+ // stale PID file
64
+ removePidFile();
65
+ }
66
+
67
+ const { port: config_port, host: config_host } = getConfig();
68
+
69
+ // When the user did not pass an explicit --port, check whether the default
70
+ // port is already in use. If something is already listening, try to register
71
+ // with it first — it may be an existing bdui instance we can reuse.
72
+ // Only auto-increment to the next port if registration fails.
73
+ let effective_port = options?.port;
74
+ if (!effective_port) {
75
+ const available = await findAvailablePort(config_port, config_host);
76
+ if (available === null) {
77
+ console.error(
78
+ 'No available port found (tried %d–%d).',
79
+ config_port,
80
+ config_port + 9
81
+ );
82
+ return 1;
83
+ }
84
+ if (available !== config_port) {
85
+ // Default port is busy — try to register with whatever is there.
86
+ const existing_url = `http://${config_host}:${config_port}`;
87
+ const registered = await registerCurrentWorkspace(existing_url, cwd);
88
+ if (registered) {
89
+ console.log('Workspace registered with existing server: %s', cwd);
90
+ if (should_open) {
91
+ await openUrl(existing_url);
92
+ }
93
+ return 0;
94
+ }
95
+ // Not a bdui instance — auto-increment to the next available port.
96
+ console.log('Port %d in use, using %d instead.', config_port, available);
97
+ effective_port = available;
98
+ }
99
+ }
100
+
101
+ // Set PORT env so getConfig() returns the correct URL for registration
102
+ if (effective_port) {
103
+ process.env.PORT = String(effective_port);
104
+ }
105
+ const { url } = getConfig();
106
+
107
+ const started = startDaemon({
108
+ is_debug: options?.is_debug,
109
+ host: options?.host,
110
+ port: effective_port
111
+ });
112
+ if (started && started.pid > 0) {
113
+ // Give the spawned daemon a brief moment to fail fast (for example EADDRINUSE).
114
+ await sleep(STARTUP_SETTLE_MS);
115
+
116
+ if (!isProcessRunning(started.pid)) {
117
+ removePidFile();
118
+
119
+ // If another server is already running at the configured URL, register this
120
+ // workspace there so it appears in the picker instead of silently missing.
121
+ const registered = await registerCurrentWorkspaceWithRetry(url, cwd);
122
+ if (registered) {
123
+ console.warn(
124
+ 'Daemon exited early; registered workspace with existing server: %s',
125
+ cwd
126
+ );
127
+ return 0;
128
+ }
129
+ return 1;
130
+ }
131
+
132
+ // Register against the currently reachable server to ensure this workspace
133
+ // appears in the picker even when startup races with other daemons.
134
+ void registerCurrentWorkspaceWithRetry(url, cwd);
135
+
136
+ printServerUrl();
137
+ // Auto-open the browser once for a fresh daemon start
138
+ if (should_open) {
139
+ // Wait briefly for the server to accept connections (single retry window)
140
+ await waitForServer(url, 600);
141
+ // Best-effort open; ignore result
142
+ await openUrl(url);
143
+ }
144
+ return 0;
145
+ }
146
+
147
+ return 1;
148
+ }
149
+
150
+ /**
151
+ * @param {number} ms
152
+ * @returns {Promise<void>}
153
+ */
154
+ function sleep(ms) {
155
+ return new Promise((resolve) => {
156
+ setTimeout(() => {
157
+ resolve();
158
+ }, ms);
159
+ });
160
+ }
161
+
162
+ /**
163
+ * @param {string} url
164
+ * @param {string} cwd
165
+ * @returns {Promise<boolean>}
166
+ */
167
+ async function registerCurrentWorkspace(url, cwd) {
168
+ const workspace_database = resolveWorkspaceDatabase({ cwd });
169
+ if (
170
+ workspace_database.source === 'home-default' ||
171
+ !workspace_database.exists
172
+ ) {
173
+ return false;
174
+ }
175
+
176
+ return registerWorkspaceWithServer(url, {
177
+ path: cwd,
178
+ database: workspace_database.path
179
+ });
180
+ }
181
+
182
+ /**
183
+ * @param {string} url
184
+ * @param {string} cwd
185
+ * @returns {Promise<boolean>}
186
+ */
187
+ async function registerCurrentWorkspaceWithRetry(url, cwd) {
188
+ for (let i = 0; i < REGISTER_RETRY_ATTEMPTS; i++) {
189
+ const registered = await registerCurrentWorkspace(url, cwd);
190
+ if (registered) {
191
+ return true;
192
+ }
193
+ if (i < REGISTER_RETRY_ATTEMPTS - 1) {
194
+ await sleep(REGISTER_RETRY_DELAY_MS);
195
+ }
196
+ }
197
+ return false;
198
+ }
199
+
200
+ /**
201
+ * Handle `stop` command.
202
+ * - Sends SIGTERM and waits for exit (with SIGKILL fallback), removes PID file.
203
+ * - Returns 2 if not running.
204
+ *
205
+ * @returns {Promise<number>} Exit code
206
+ */
207
+ export async function handleStop() {
208
+ const existing_pid = readPidFile();
209
+ if (!existing_pid) {
210
+ return 2;
211
+ }
212
+
213
+ if (!isProcessRunning(existing_pid)) {
214
+ // stale PID file
215
+ removePidFile();
216
+ return 2;
217
+ }
218
+
219
+ const terminated = await terminateProcess(existing_pid, 5000);
220
+ if (terminated) {
221
+ removePidFile();
222
+ return 0;
223
+ }
224
+
225
+ // Not terminated within timeout
226
+ return 1;
227
+ }
228
+
229
+ /**
230
+ * Handle `restart` command: stop (ignore not-running) then start.
231
+ * Accepts the same options as `handleStart` and passes them through,
232
+ * so restart only opens a browser when `open` is explicitly true.
233
+ *
234
+ * When the user does not pass explicit `--port`, the restart detects the
235
+ * port the running daemon is listening on and reuses it.
236
+ *
237
+ * @param {{ open?: boolean, host?: string, port?: number }} [options]
238
+ * @returns {Promise<number>}
239
+ */
240
+ export async function handleRestart(options) {
241
+ // Capture state from the running server before stopping it.
242
+ let detected_port = null;
243
+ /** @type {Array<{ path: string, database: string }>} */
244
+ let saved_workspaces = [];
245
+ const existing_pid = readPidFile();
246
+ if (existing_pid && isProcessRunning(existing_pid)) {
247
+ detected_port = detectListeningPort(existing_pid);
248
+
249
+ const { url } = getConfig();
250
+ saved_workspaces = await fetchWorkspacesFromServer(url);
251
+ }
252
+
253
+ const stop_code = await handleStop();
254
+ // 0 = stopped, 2 = not running; both are acceptable to proceed
255
+ if (stop_code !== 0 && stop_code !== 2) {
256
+ return 1;
257
+ }
258
+
259
+ // Reuse detected port unless the user explicitly passed one.
260
+ const merged_options = { ...options };
261
+ if (!merged_options.port && detected_port) {
262
+ merged_options.port = detected_port;
263
+ }
264
+
265
+ const start_code = await handleStart(merged_options);
266
+ if (start_code !== 0) {
267
+ return 1;
268
+ }
269
+
270
+ // Re-register workspaces from the previous server.
271
+ if (saved_workspaces.length > 0) {
272
+ const { url } = getConfig();
273
+ await waitForServer(url, RESTART_SERVER_READY_MS);
274
+ for (const ws of saved_workspaces) {
275
+ if (ws.path && ws.database) {
276
+ await registerWorkspaceWithServer(url, {
277
+ path: ws.path,
278
+ database: ws.database
279
+ });
280
+ }
281
+ }
282
+ }
283
+
284
+ return 0;
285
+ }
@@ -0,0 +1,340 @@
1
+ /**
2
+ * @import { SpawnOptions } from 'node:child_process'
3
+ */
4
+ import { execFileSync, spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import net from 'node:net';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { getConfig } from '../config.js';
11
+ import { resolveWorkspaceDatabase } from '../db.js';
12
+
13
+ /**
14
+ * Resolve the runtime directory used for PID and log files.
15
+ * Prefers `BDUI_RUNTIME_DIR`, then `$XDG_RUNTIME_DIR/beads-ui`,
16
+ * and finally `os.tmpdir()/beads-ui`.
17
+ *
18
+ * @returns {string}
19
+ */
20
+ export function getRuntimeDir() {
21
+ const override_dir = process.env.BDUI_RUNTIME_DIR;
22
+ if (override_dir && override_dir.length > 0) {
23
+ return ensureDir(override_dir);
24
+ }
25
+
26
+ const xdg_dir = process.env.XDG_RUNTIME_DIR;
27
+ if (xdg_dir && xdg_dir.length > 0) {
28
+ return ensureDir(path.join(xdg_dir, 'beads-ui'));
29
+ }
30
+
31
+ return ensureDir(path.join(os.tmpdir(), 'beads-ui'));
32
+ }
33
+
34
+ /**
35
+ * Ensure a directory exists with safe permissions and return its path.
36
+ *
37
+ * @param {string} dir_path
38
+ * @returns {string}
39
+ */
40
+ function ensureDir(dir_path) {
41
+ try {
42
+ fs.mkdirSync(dir_path, { recursive: true, mode: 0o700 });
43
+ } catch {
44
+ // Best-effort; permission errors will surface on file ops later.
45
+ }
46
+ return dir_path;
47
+ }
48
+
49
+ /**
50
+ * @returns {string}
51
+ */
52
+ export function getPidFilePath() {
53
+ const runtime_dir = getRuntimeDir();
54
+ return path.join(runtime_dir, 'server.pid');
55
+ }
56
+
57
+ /**
58
+ * @returns {string}
59
+ */
60
+ export function getLogFilePath() {
61
+ const runtime_dir = getRuntimeDir();
62
+ return path.join(runtime_dir, 'daemon.log');
63
+ }
64
+
65
+ /**
66
+ * Read PID from the PID file if present.
67
+ *
68
+ * @returns {number | null}
69
+ */
70
+ export function readPidFile() {
71
+ const pid_file = getPidFilePath();
72
+ try {
73
+ const text = fs.readFileSync(pid_file, 'utf8');
74
+ const pid_value = Number.parseInt(text.trim(), 10);
75
+ if (Number.isFinite(pid_value) && pid_value > 0) {
76
+ return pid_value;
77
+ }
78
+ } catch {
79
+ // ignore missing or unreadable
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * @param {number} pid
86
+ */
87
+ export function writePidFile(pid) {
88
+ const pid_file = getPidFilePath();
89
+ try {
90
+ fs.writeFileSync(pid_file, String(pid) + '\n', { encoding: 'utf8' });
91
+ } catch {
92
+ // ignore write errors; daemon still runs but management degrades
93
+ }
94
+ }
95
+
96
+ export function removePidFile() {
97
+ const pid_file = getPidFilePath();
98
+ try {
99
+ fs.unlinkSync(pid_file);
100
+ } catch {
101
+ // ignore
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Check whether a process is running.
107
+ *
108
+ * @param {number} pid
109
+ * @returns {boolean}
110
+ */
111
+ export function isProcessRunning(pid) {
112
+ try {
113
+ if (pid <= 0) {
114
+ return false;
115
+ }
116
+ process.kill(pid, 0);
117
+ return true;
118
+ } catch (err) {
119
+ const code = /** @type {{ code?: string }} */ (err).code;
120
+ if (code === 'ESRCH') {
121
+ return false;
122
+ }
123
+ // EPERM or other errors imply the process likely exists but is not killable
124
+ return true;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Compute the absolute path to the server entry file.
130
+ *
131
+ * @returns {string}
132
+ */
133
+ export function getServerEntryPath() {
134
+ const here = fileURLToPath(new URL(import.meta.url));
135
+ const cli_dir = path.dirname(here);
136
+ const server_entry = path.resolve(cli_dir, '..', 'index.js');
137
+ return server_entry;
138
+ }
139
+
140
+ /**
141
+ * Spawn the server as a detached daemon, redirecting stdio to the log file.
142
+ * Writes the PID file upon success.
143
+ *
144
+ * @param {{ is_debug?: boolean, host?: string, port?: number }} [options]
145
+ * @returns {{ pid: number } | null} Returns child PID on success; null on failure.
146
+ */
147
+ export function startDaemon(options = {}) {
148
+ const server_entry = getServerEntryPath();
149
+ const log_file = getLogFilePath();
150
+
151
+ // Open the log file for appending; reuse for both stdout and stderr
152
+ /** @type {number} */
153
+ let log_fd;
154
+ try {
155
+ log_fd = fs.openSync(log_file, 'a');
156
+ if (options.is_debug) {
157
+ console.debug('log file ', log_file);
158
+ }
159
+ } catch {
160
+ // If log cannot be opened, fallback to ignoring stdio
161
+ log_fd = -1;
162
+ }
163
+
164
+ /** @type {Record<string, string | undefined>} */
165
+ const spawn_env = { ...process.env };
166
+ if (options.host) {
167
+ spawn_env.HOST = options.host;
168
+ }
169
+ if (options.port) {
170
+ spawn_env.PORT = String(options.port);
171
+ }
172
+
173
+ /** @type {SpawnOptions} */
174
+ const opts = {
175
+ cwd: process.cwd(),
176
+ detached: true,
177
+ env: spawn_env,
178
+ stdio: log_fd >= 0 ? ['ignore', log_fd, log_fd] : 'ignore',
179
+ windowsHide: true
180
+ };
181
+
182
+ try {
183
+ const child = spawn(process.execPath, [server_entry], opts);
184
+ // Detach fully from the parent
185
+ child.unref();
186
+ const child_pid = typeof child.pid === 'number' ? child.pid : -1;
187
+ if (child_pid > 0) {
188
+ if (options.is_debug) {
189
+ console.debug('starting ', child_pid);
190
+ }
191
+ writePidFile(child_pid);
192
+ return { pid: child_pid };
193
+ }
194
+ return null;
195
+ } catch (err) {
196
+ console.error('start error', err);
197
+ // Log startup error to log file for traceability
198
+ try {
199
+ const message =
200
+ new Date().toISOString() + ' start error: ' + String(err) + '\n';
201
+ fs.appendFileSync(log_file, message, 'utf8');
202
+ } catch {
203
+ // ignore
204
+ }
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Send SIGTERM then (optionally) SIGKILL to stop a process and wait for exit.
211
+ *
212
+ * @param {number} pid
213
+ * @param {number} timeout_ms
214
+ * @returns {Promise<boolean>} Resolves true if the process is gone.
215
+ */
216
+ export async function terminateProcess(pid, timeout_ms) {
217
+ try {
218
+ process.kill(pid, 'SIGTERM');
219
+ } catch (err) {
220
+ const code = /** @type {{ code?: string }} */ (err).code;
221
+ if (code === 'ESRCH') {
222
+ return true;
223
+ }
224
+ // On EPERM or others, continue to wait/poll
225
+ }
226
+
227
+ const start_time = Date.now();
228
+ // Poll until process no longer exists or timeout
229
+ while (Date.now() - start_time < timeout_ms) {
230
+ if (!isProcessRunning(pid)) {
231
+ return true;
232
+ }
233
+ await sleep(100);
234
+ }
235
+
236
+ // Fallback to SIGKILL
237
+ try {
238
+ process.kill(pid, 'SIGKILL');
239
+ } catch {
240
+ // ignore
241
+ }
242
+
243
+ // Give a brief moment after SIGKILL
244
+ await sleep(50);
245
+ return !isProcessRunning(pid);
246
+ }
247
+
248
+ /**
249
+ * @param {number} ms
250
+ * @returns {Promise<void>}
251
+ */
252
+ function sleep(ms) {
253
+ return new Promise((resolve) => {
254
+ setTimeout(() => {
255
+ resolve();
256
+ }, ms);
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Detect the TCP port a process is listening on by inspecting OS state.
262
+ * Returns the first LISTEN port found for the given PID, or null.
263
+ *
264
+ * @param {number} pid
265
+ * @returns {number | null}
266
+ */
267
+ export function detectListeningPort(pid) {
268
+ try {
269
+ const output = execFileSync(
270
+ 'lsof',
271
+ ['-iTCP', '-sTCP:LISTEN', '-a', '-p', String(pid), '-Fn', '-P'],
272
+ { encoding: 'utf8', timeout: 3000 }
273
+ );
274
+
275
+ // lsof -Fn outputs lines like "n*:3000" or "n127.0.0.1:4000"
276
+ for (const line of output.split('\n')) {
277
+ if (line.startsWith('n')) {
278
+ const colon_index = line.lastIndexOf(':');
279
+ if (colon_index >= 0) {
280
+ const port_value = Number.parseInt(line.slice(colon_index + 1), 10);
281
+ if (Number.isFinite(port_value) && port_value > 0) {
282
+ return port_value;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ } catch {
288
+ // lsof not available or process gone — fall through
289
+ }
290
+ return null;
291
+ }
292
+
293
+ /**
294
+ * Check whether a TCP port is available on the given host.
295
+ *
296
+ * @param {number} port
297
+ * @param {string} host
298
+ * @returns {Promise<boolean>}
299
+ */
300
+ export function isPortAvailable(port, host) {
301
+ return new Promise((resolve) => {
302
+ const server = net.createServer();
303
+ server.once('error', () => resolve(false));
304
+ server.listen(port, host, () => {
305
+ server.close(() => resolve(true));
306
+ });
307
+ });
308
+ }
309
+
310
+ /**
311
+ * Starting from `port`, find the first available port on `host`.
312
+ * Tries up to `max_attempts` consecutive ports.
313
+ *
314
+ * @param {number} port
315
+ * @param {string} host
316
+ * @param {number} [max_attempts]
317
+ * @returns {Promise<number | null>}
318
+ */
319
+ export async function findAvailablePort(port, host, max_attempts = 10) {
320
+ for (let i = 0; i < max_attempts; i++) {
321
+ if (await isPortAvailable(port + i, host)) {
322
+ return port + i;
323
+ }
324
+ }
325
+ return null;
326
+ }
327
+
328
+ /**
329
+ * Print the server URL derived from current config.
330
+ */
331
+ export function printServerUrl() {
332
+ // Resolve from the caller's working directory by default
333
+ const resolved_db = resolveWorkspaceDatabase();
334
+ console.log(
335
+ `beads db ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
336
+ );
337
+
338
+ const { url } = getConfig();
339
+ console.log(`beads ui listening on ${url}`);
340
+ }