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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "beads-ui",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "Local UI for Beads — Collaborate on issues with your coding agent.",
5
5
  "keywords": [
6
6
  "agent",
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 { registerWorkspace } from './registry-watcher.js';
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
@@ -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 { openUrl, registerWorkspaceWithServer, waitForServer } from './open.js';
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: options?.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
- * @param {{ open?: boolean }} [options]
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
- const start_code = await handleStart(options);
202
- return start_code === 0 ? 0 : 1;
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
  }
@@ -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
  */
@@ -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.