@rsktash/beads-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.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -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
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { resolveWorkspaceDatabase } from '../db.js';
3
+ import { printServerUrl } from './daemon.js';
4
+
5
+ vi.mock('../db.js', () => ({
6
+ resolveWorkspaceDatabase: vi.fn(() => ({
7
+ path: '/repo/.beads',
8
+ source: 'metadata',
9
+ exists: true
10
+ }))
11
+ }));
12
+
13
+ vi.mock('../config.js', () => ({
14
+ getConfig: () => ({ url: 'http://127.0.0.1:3000' })
15
+ }));
16
+
17
+ describe('printServerUrl', () => {
18
+ test('prints workspace-aware database resolution', () => {
19
+ const log_spy = vi.spyOn(console, 'log').mockImplementation(() => {});
20
+
21
+ printServerUrl();
22
+
23
+ expect(resolveWorkspaceDatabase).toHaveBeenCalledTimes(1);
24
+ expect(log_spy).toHaveBeenCalledWith('beads db /repo/.beads (metadata)');
25
+ expect(log_spy).toHaveBeenCalledWith(
26
+ 'beads ui listening on http://127.0.0.1:3000'
27
+ );
28
+
29
+ log_spy.mockRestore();
30
+ });
31
+ });
@@ -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,178 @@
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
+ * 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
+
140
+ /**
141
+ * Register a workspace with the running server.
142
+ * Makes a POST request to /api/register-workspace.
143
+ *
144
+ * @param {string} base_url - Server base URL (e.g., "http://127.0.0.1:3000")
145
+ * @param {{ path: string, database: string }} workspace
146
+ * @returns {Promise<boolean>} True if registration succeeded
147
+ */
148
+ export async function registerWorkspaceWithServer(base_url, workspace) {
149
+ return new Promise((resolve) => {
150
+ const url = new URL('/api/register-workspace', base_url);
151
+ const body = JSON.stringify(workspace);
152
+ const req = http.request(
153
+ url,
154
+ {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/json',
158
+ 'Content-Length': Buffer.byteLength(body)
159
+ }
160
+ },
161
+ (res) => {
162
+ res.resume();
163
+ resolve(res.statusCode === 200);
164
+ }
165
+ );
166
+ req.on('error', () => resolve(false));
167
+ req.setTimeout(2000, () => {
168
+ try {
169
+ req.destroy();
170
+ } catch {
171
+ void 0;
172
+ }
173
+ resolve(false);
174
+ });
175
+ req.write(body);
176
+ req.end();
177
+ });
178
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { computeOpenCommand } from './open.js';
3
+
4
+ describe('computeOpenCommand', () => {
5
+ test('returns macOS open command', () => {
6
+ const r = computeOpenCommand('http://127.0.0.1:3000', 'darwin');
7
+
8
+ expect(r.cmd).toBe('open');
9
+ expect(r.args).toEqual(['http://127.0.0.1:3000']);
10
+ });
11
+
12
+ test('returns Linux xdg-open command', () => {
13
+ const r = computeOpenCommand('http://127.0.0.1:3000', 'linux');
14
+
15
+ expect(r.cmd).toBe('xdg-open');
16
+ expect(r.args).toEqual(['http://127.0.0.1:3000']);
17
+ });
18
+
19
+ test('returns Windows start command via cmd', () => {
20
+ const r = computeOpenCommand('http://127.0.0.1:3000', 'win32');
21
+
22
+ expect(r.cmd).toBe('cmd');
23
+ expect(r.args.slice(0, 3)).toEqual(['/c', 'start', '']);
24
+ expect(r.args[r.args.length - 1]).toBe('http://127.0.0.1:3000');
25
+ });
26
+ });