@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.
- package/.github/workflows/publish.yml +28 -0
- package/app/protocol.js +216 -0
- package/bin/bdui +19 -0
- package/client/index.html +12 -0
- package/client/postcss.config.js +11 -0
- package/client/src/App.tsx +35 -0
- package/client/src/components/IssueCard.tsx +73 -0
- package/client/src/components/Layout.tsx +175 -0
- package/client/src/components/Markdown.tsx +77 -0
- package/client/src/components/PriorityBadge.tsx +26 -0
- package/client/src/components/SearchDialog.tsx +137 -0
- package/client/src/components/SectionEditor.tsx +212 -0
- package/client/src/components/StatusBadge.tsx +64 -0
- package/client/src/components/TypeBadge.tsx +26 -0
- package/client/src/hooks/use-mutation.ts +55 -0
- package/client/src/hooks/use-search.ts +19 -0
- package/client/src/hooks/use-subscription.ts +187 -0
- package/client/src/index.css +133 -0
- package/client/src/lib/avatar.ts +17 -0
- package/client/src/lib/types.ts +115 -0
- package/client/src/lib/ws-client.ts +214 -0
- package/client/src/lib/ws-context.tsx +28 -0
- package/client/src/main.tsx +10 -0
- package/client/src/views/Board.tsx +200 -0
- package/client/src/views/Detail.tsx +398 -0
- package/client/src/views/List.tsx +461 -0
- package/client/tailwind.config.ts +68 -0
- package/client/tsconfig.json +16 -0
- package/client/vite.config.ts +20 -0
- package/package.json +43 -0
- package/server/app.js +120 -0
- package/server/app.test.js +30 -0
- package/server/bd.js +227 -0
- package/server/bd.test.js +194 -0
- package/server/cli/cli.test.js +207 -0
- package/server/cli/commands.integration.test.js +148 -0
- package/server/cli/commands.js +285 -0
- package/server/cli/commands.unit.test.js +408 -0
- package/server/cli/daemon.js +340 -0
- package/server/cli/daemon.test.js +31 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +178 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/db.test.js +169 -0
- package/server/dolt-pool.js +257 -0
- package/server/dolt-queries.js +646 -0
- package/server/index.js +97 -0
- package/server/list-adapters.js +395 -0
- package/server/list-adapters.test.js +208 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +299 -0
- package/server/subscriptions.test.js +128 -0
- package/server/validators.js +124 -0
- package/server/watcher.js +139 -0
- package/server/watcher.test.js +120 -0
- package/server/ws.comments.test.js +262 -0
- package/server/ws.delete.test.js +119 -0
- package/server/ws.js +1309 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.list-refresh.coalesce.test.js +95 -0
- package/server/ws.list-subscriptions.test.js +403 -0
- package/server/ws.mutation-window.test.js +147 -0
- package/server/ws.mutations.test.js +389 -0
- 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
|
+
}
|