@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,207 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
3
+ import * as logging from '../logging.js';
4
+ import * as commands from './commands.js';
5
+ import { main, parseArgs } from './index.js';
6
+
7
+ vi.mock('node:fs/promises', () => ({
8
+ readFile: vi.fn()
9
+ }));
10
+
11
+ vi.mock('../logging.js', () => ({
12
+ enableAllDebug: vi.fn(),
13
+ debug: () => () => {}
14
+ }));
15
+
16
+ vi.mock('./commands.js', () => ({
17
+ handleStart: vi.fn().mockResolvedValue(0),
18
+ handleStop: vi.fn().mockResolvedValue(0),
19
+ handleRestart: vi.fn().mockResolvedValue(0)
20
+ }));
21
+
22
+ /** @type {import('vitest').MockInstance} */
23
+ let write_mock;
24
+ /** @type {import('vitest').MockInstance} */
25
+ let read_file_mock;
26
+
27
+ beforeEach(() => {
28
+ write_mock = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
29
+ read_file_mock = vi.mocked(readFile);
30
+ read_file_mock.mockResolvedValue(JSON.stringify({ version: '1.2.3' }));
31
+ });
32
+
33
+ describe('parseArgs', () => {
34
+ test('returns help flag when -h or --help present', () => {
35
+ const r1 = parseArgs(['-h']);
36
+ const r2 = parseArgs(['--help']);
37
+
38
+ expect(r1.flags.includes('help')).toBe(true);
39
+ expect(r2.flags.includes('help')).toBe(true);
40
+ });
41
+
42
+ test('returns command token when valid', () => {
43
+ expect(parseArgs(['start']).command).toBe('start');
44
+ expect(parseArgs(['stop']).command).toBe('stop');
45
+ expect(parseArgs(['restart']).command).toBe('restart');
46
+ });
47
+
48
+ test('recognizes --debug and -d flags', () => {
49
+ const r1 = parseArgs(['--debug']);
50
+ const r2 = parseArgs(['-d']);
51
+
52
+ expect(r1.flags.includes('debug')).toBe(true);
53
+ expect(r2.flags.includes('debug')).toBe(true);
54
+ });
55
+ test('recognizes --open flag', () => {
56
+ const r = parseArgs(['start', '--open']);
57
+
58
+ expect(r.flags.includes('open')).toBe(true);
59
+ });
60
+
61
+ test('recognizes -v and --version flags', () => {
62
+ const r1 = parseArgs(['-v']);
63
+ const r2 = parseArgs(['--version']);
64
+
65
+ expect(r1.flags.includes('version')).toBe(true);
66
+ expect(r2.flags.includes('version')).toBe(true);
67
+ });
68
+
69
+ test('parses --host option', () => {
70
+ const r = parseArgs(['start', '--host', '0.0.0.0']);
71
+
72
+ expect(r.options.host).toBe('0.0.0.0');
73
+ });
74
+
75
+ test('parses --port option', () => {
76
+ const r = parseArgs(['start', '--port', '8080']);
77
+
78
+ expect(r.options.port).toBe(8080);
79
+ });
80
+
81
+ test('ignores invalid --port value', () => {
82
+ const r = parseArgs(['start', '--port', 'abc']);
83
+
84
+ expect(r.options.port).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe('main', () => {
89
+ test('prints usage and exits 0 on --help', async () => {
90
+ const code = await main(['--help']);
91
+
92
+ expect(code).toBe(0);
93
+ expect(write_mock).toHaveBeenCalled();
94
+ });
95
+
96
+ test('prints version and exits 0 on --version', async () => {
97
+ const code = await main(['--version']);
98
+
99
+ expect(code).toBe(0);
100
+ expect(write_mock).toHaveBeenCalled();
101
+ const output = write_mock.mock.calls.map((c) => String(c[0])).join('');
102
+ expect(output.trim()).toBe('1.2.3');
103
+ });
104
+
105
+ test('enables debug when --debug passed', async () => {
106
+ const spy = vi.spyOn(logging, 'enableAllDebug');
107
+
108
+ await main(['--debug', '--help']);
109
+
110
+ expect(spy).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ test('prints usage and exits 1 on no command', async () => {
114
+ const code = await main([]);
115
+
116
+ expect(code).toBe(1);
117
+ expect(write_mock).toHaveBeenCalled();
118
+ });
119
+
120
+ test('dispatches to start handler', async () => {
121
+ const code = await main(['start']);
122
+
123
+ expect(code).toBe(0);
124
+ expect(commands.handleStart).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ test('propagates --open to start handler', async () => {
128
+ await main(['start', '--open']);
129
+
130
+ expect(commands.handleStart).toHaveBeenCalledWith({
131
+ open: true,
132
+ is_debug: false,
133
+ host: undefined,
134
+ port: undefined
135
+ });
136
+ });
137
+
138
+ test('propagates --host and --port to start handler', async () => {
139
+ await main(['start', '--host', '0.0.0.0', '--port', '8080']);
140
+
141
+ expect(commands.handleStart).toHaveBeenCalledWith({
142
+ open: false,
143
+ is_debug: false,
144
+ host: '0.0.0.0',
145
+ port: 8080
146
+ });
147
+ });
148
+
149
+ test('help lists --open', async () => {
150
+ const write_spy = write_mock;
151
+ write_spy.mockClear();
152
+
153
+ await main(['--help']);
154
+
155
+ const output = write_spy.mock.calls.map((c) => String(c[0])).join('');
156
+ expect(output.includes('--open')).toBe(true);
157
+ });
158
+
159
+ test('dispatches to stop handler', async () => {
160
+ const code = await main(['stop']);
161
+
162
+ expect(code).toBe(0);
163
+ expect(commands.handleStop).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ test('dispatches to restart handler', async () => {
167
+ const code = await main(['restart']);
168
+
169
+ expect(code).toBe(0);
170
+ expect(commands.handleRestart).toHaveBeenCalledTimes(1);
171
+ expect(commands.handleRestart).toHaveBeenCalledWith({
172
+ open: false,
173
+ is_debug: false,
174
+ host: undefined,
175
+ port: undefined
176
+ });
177
+ });
178
+
179
+ test('propagates --open to restart handler', async () => {
180
+ await main(['restart', '--open']);
181
+
182
+ expect(commands.handleRestart).toHaveBeenCalledWith({
183
+ open: true,
184
+ is_debug: false,
185
+ host: undefined,
186
+ port: undefined
187
+ });
188
+ });
189
+
190
+ test('propagates --host and --port to restart handler', async () => {
191
+ await main(['restart', '--host', '0.0.0.0', '--port', '9000']);
192
+
193
+ expect(commands.handleRestart).toHaveBeenCalledWith({
194
+ open: false,
195
+ is_debug: false,
196
+ host: '0.0.0.0',
197
+ port: 9000
198
+ });
199
+ });
200
+
201
+ test('unknown command prints usage and exits 1', async () => {
202
+ const code = await main(['unknown']);
203
+
204
+ expect(code).toBe(1);
205
+ expect(write_mock).toHaveBeenCalled();
206
+ });
207
+ });
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ test,
11
+ vi
12
+ } from 'vitest';
13
+ import { handleRestart, handleStart, handleStop } from './commands.js';
14
+ import * as daemon from './daemon.js';
15
+
16
+ // Mock browser open + readiness wait to avoid external effects and flakiness
17
+ vi.mock('./open.js', () => ({
18
+ openUrl: async () => true,
19
+ waitForServer: async () => {},
20
+ registerWorkspaceWithServer: async () => true
21
+ }));
22
+
23
+ // Mock db resolution to avoid file system dependencies
24
+ vi.mock('../db.js', () => ({
25
+ resolveDbPath: () => ({
26
+ path: '/mock/test.db',
27
+ source: 'nearest',
28
+ exists: true
29
+ }),
30
+ resolveWorkspaceDatabase: () => ({
31
+ path: '/mock/test.db',
32
+ source: 'nearest',
33
+ exists: true
34
+ })
35
+ }));
36
+
37
+ /** @type {string} */
38
+ let tmp_runtime_dir;
39
+ /** @type {Record<string, string | undefined>} */
40
+ let prev_env;
41
+
42
+ beforeAll(() => {
43
+ // Snapshot selected env vars to restore later
44
+ prev_env = {
45
+ BDUI_RUNTIME_DIR: process.env.BDUI_RUNTIME_DIR,
46
+ PORT: process.env.PORT
47
+ };
48
+
49
+ tmp_runtime_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bdui-it-'));
50
+ process.env.BDUI_RUNTIME_DIR = tmp_runtime_dir;
51
+ // Use port 0 so OS assigns an ephemeral port; URL printing still occurs
52
+ process.env.PORT = '0';
53
+ vi.spyOn(console, 'log').mockImplementation(() => {});
54
+ });
55
+
56
+ afterEach(async () => {
57
+ // Ensure no stray daemon is left between tests
58
+ const pid = daemon.readPidFile();
59
+ if (pid && daemon.isProcessRunning(pid)) {
60
+ await daemon.terminateProcess(pid, 2000);
61
+ }
62
+ daemon.removePidFile();
63
+ });
64
+
65
+ afterAll(() => {
66
+ // Restore env
67
+ if (prev_env.BDUI_RUNTIME_DIR === undefined) {
68
+ delete process.env.BDUI_RUNTIME_DIR;
69
+ } else {
70
+ process.env.BDUI_RUNTIME_DIR = prev_env.BDUI_RUNTIME_DIR;
71
+ }
72
+
73
+ if (prev_env.PORT === undefined) {
74
+ delete process.env.PORT;
75
+ } else {
76
+ process.env.PORT = prev_env.PORT;
77
+ }
78
+
79
+ try {
80
+ fs.rmSync(tmp_runtime_dir, { recursive: true, force: true });
81
+ } catch {
82
+ // ignore
83
+ }
84
+ });
85
+
86
+ describe('commands integration', () => {
87
+ test('start then stop returns 0 and manages PID file', async () => {
88
+ // setup
89
+ vi.spyOn(daemon, 'printServerUrl').mockImplementation(() => {});
90
+
91
+ // execution
92
+ const start_code = await handleStart({ open: false });
93
+
94
+ // assertion
95
+ expect(start_code).toBe(0);
96
+ const pid_after_start = daemon.readPidFile();
97
+ expect(typeof pid_after_start).toBe('number');
98
+ expect(Number(pid_after_start)).toBeGreaterThan(0);
99
+
100
+ // execution
101
+ const stop_code = await handleStop();
102
+
103
+ // assertion
104
+ expect(stop_code).toBe(0);
105
+ const pid_after_stop = daemon.readPidFile();
106
+ expect(pid_after_stop).toBeNull();
107
+ });
108
+
109
+ test('stop returns 2 when not running', async () => {
110
+ // execution
111
+ const code = await handleStop();
112
+
113
+ // assertion
114
+ expect(code).toBe(2);
115
+ });
116
+
117
+ test('start is idempotent when already running', async () => {
118
+ // setup
119
+ await handleStart({ open: false });
120
+ const start_spy = vi.spyOn(daemon, 'startDaemon');
121
+
122
+ // execution
123
+ const code = await handleStart({ open: false });
124
+
125
+ // assertion
126
+ expect(code).toBe(0);
127
+ expect(start_spy).not.toHaveBeenCalled();
128
+
129
+ // cleanup
130
+ await handleStop();
131
+ });
132
+
133
+ test('restart stops (when needed) and starts', async () => {
134
+ // setup
135
+ vi.spyOn(daemon, 'printServerUrl').mockImplementation(() => {});
136
+
137
+ // execution
138
+ const code = await handleRestart();
139
+
140
+ // assertion
141
+ expect(code).toBe(0);
142
+ const pid = daemon.readPidFile();
143
+ expect(typeof pid).toBe('number');
144
+
145
+ // cleanup
146
+ await handleStop();
147
+ });
148
+ });
@@ -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
+ }