@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,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
|
+
});
|