beads-ui 0.11.3 → 0.12.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 +17 -0
- package/package.json +1 -1
- package/server/app.js +10 -1
- package/server/cli/commands.js +92 -10
- package/server/cli/daemon.js +70 -1
- package/server/cli/open.js +39 -0
package/CHANGES.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
- [`8559d4a`](https://github.com/mantoni/beads-ui/commit/8559d4af699555b9943914a2e790965c9e4d8da7)
|
|
6
|
+
feat(cli): auto-increment port when default is in use (#73) (Leon Letto)
|
|
7
|
+
- [`527e9a5`](https://github.com/mantoni/beads-ui/commit/527e9a59a01e1b93c1488cb1e2ed26ae346b358c)
|
|
8
|
+
feat(cli): preserve workspaces across bdui restart (#72) (Leon Letto)
|
|
9
|
+
- [`5996b39`](https://github.com/mantoni/beads-ui/commit/5996b39499bcf0e460133c27a7ee20b30c677ab5)
|
|
10
|
+
chore: add dev-docs to .prettierignore (Leon Letto)
|
|
11
|
+
- [`08f1439`](https://github.com/mantoni/beads-ui/commit/08f1439d13fc5b534de13e1ea94af4407174d76f)
|
|
12
|
+
style: fix prettier formatting in daemon and test files (Leon Letto)
|
|
13
|
+
- [`4a0c791`](https://github.com/mantoni/beads-ui/commit/4a0c791300f12e47faae74e8237f823857be7dd9)
|
|
14
|
+
fix: resolve TS18048 type error in restart test (Leon Letto)
|
|
15
|
+
- [`c973d86`](https://github.com/mantoni/beads-ui/commit/c973d8693c6cfa3a5f8ad0905134465903e527a2)
|
|
16
|
+
feat(cli): preserve listening port across bdui restart (Leon Letto)
|
|
17
|
+
|
|
18
|
+
_Released by [Maximilian Antoni](https://github.com/mantoni) on 2026-04-02._
|
|
19
|
+
|
|
3
20
|
## 0.11.3
|
|
4
21
|
|
|
5
22
|
- [`47261a7`](https://github.com/mantoni/beads-ui/commit/47261a7a95d5a17b480ae56c4a10b5eeb49d1007)
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
import express from 'express';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getAvailableWorkspaces,
|
|
9
|
+
registerWorkspace
|
|
10
|
+
} from './registry-watcher.js';
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Create and configure the Express application.
|
|
@@ -51,6 +54,12 @@ export function createApp(config) {
|
|
|
51
54
|
res.status(200).json({ ok: true, registered: workspace_path });
|
|
52
55
|
});
|
|
53
56
|
|
|
57
|
+
// List all known workspaces (file-based registry + in-memory)
|
|
58
|
+
app.get('/api/workspaces', (_req, res) => {
|
|
59
|
+
const workspaces = getAvailableWorkspaces();
|
|
60
|
+
res.status(200).json({ ok: true, workspaces });
|
|
61
|
+
});
|
|
62
|
+
|
|
54
63
|
if (
|
|
55
64
|
!fs.statSync(path.resolve(config.app_dir, 'main.bundle.js'), {
|
|
56
65
|
throwIfNoEntry: false
|
package/server/cli/commands.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { getConfig } from '../config.js';
|
|
2
2
|
import { resolveWorkspaceDatabase } from '../db.js';
|
|
3
3
|
import {
|
|
4
|
+
detectListeningPort,
|
|
5
|
+
findAvailablePort,
|
|
4
6
|
isProcessRunning,
|
|
5
7
|
printServerUrl,
|
|
6
8
|
readPidFile,
|
|
@@ -8,7 +10,14 @@ import {
|
|
|
8
10
|
startDaemon,
|
|
9
11
|
terminateProcess
|
|
10
12
|
} from './daemon.js';
|
|
11
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
fetchWorkspacesFromServer,
|
|
15
|
+
openUrl,
|
|
16
|
+
registerWorkspaceWithServer,
|
|
17
|
+
waitForServer
|
|
18
|
+
} from './open.js';
|
|
19
|
+
|
|
20
|
+
const RESTART_SERVER_READY_MS = 400;
|
|
12
21
|
|
|
13
22
|
const STARTUP_SETTLE_MS = 200;
|
|
14
23
|
const REGISTER_RETRY_ATTEMPTS = 5;
|
|
@@ -55,12 +64,50 @@ export async function handleStart(options) {
|
|
|
55
64
|
removePidFile();
|
|
56
65
|
}
|
|
57
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
|
+
}
|
|
58
105
|
const { url } = getConfig();
|
|
59
106
|
|
|
60
107
|
const started = startDaemon({
|
|
61
108
|
is_debug: options?.is_debug,
|
|
62
109
|
host: options?.host,
|
|
63
|
-
port:
|
|
110
|
+
port: effective_port
|
|
64
111
|
});
|
|
65
112
|
if (started && started.pid > 0) {
|
|
66
113
|
// Give the spawned daemon a brief moment to fail fast (for example EADDRINUSE).
|
|
@@ -179,25 +226,60 @@ export async function handleStop() {
|
|
|
179
226
|
return 1;
|
|
180
227
|
}
|
|
181
228
|
|
|
182
|
-
/**
|
|
183
|
-
* Handle `restart` command: stop (ignore not-running) then start.
|
|
184
|
-
*
|
|
185
|
-
* @returns {Promise<number>} Exit code (0 on success)
|
|
186
|
-
*/
|
|
187
229
|
/**
|
|
188
230
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
189
231
|
* Accepts the same options as `handleStart` and passes them through,
|
|
190
232
|
* so restart only opens a browser when `open` is explicitly true.
|
|
191
233
|
*
|
|
192
|
-
*
|
|
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]
|
|
193
238
|
* @returns {Promise<number>}
|
|
194
239
|
*/
|
|
195
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
|
+
|
|
196
253
|
const stop_code = await handleStop();
|
|
197
254
|
// 0 = stopped, 2 = not running; both are acceptable to proceed
|
|
198
255
|
if (stop_code !== 0 && stop_code !== 2) {
|
|
199
256
|
return 1;
|
|
200
257
|
}
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
285
|
}
|
package/server/cli/daemon.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @import { SpawnOptions } from 'node:child_process'
|
|
3
3
|
*/
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
6
7
|
import os from 'node:os';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
@@ -256,6 +257,74 @@ function sleep(ms) {
|
|
|
256
257
|
});
|
|
257
258
|
}
|
|
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
|
+
|
|
259
328
|
/**
|
|
260
329
|
* Print the server URL derived from current config.
|
|
261
330
|
*/
|
package/server/cli/open.js
CHANGED
|
@@ -98,6 +98,45 @@ function sleep(ms) {
|
|
|
98
98
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
99
99
|
}
|
|
100
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
|
+
|
|
101
140
|
/**
|
|
102
141
|
* Register a workspace with the running server.
|
|
103
142
|
* Makes a POST request to /api/register-workspace.
|