@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,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
|
+
}
|