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/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import * as commands from './commands.js';
|
|
3
|
+
import { main, parseArgs } from './index.js';
|
|
4
|
+
|
|
5
|
+
vi.mock('./commands.js', () => ({
|
|
6
|
+
handleStart: vi.fn().mockResolvedValue(0),
|
|
7
|
+
handleStop: vi.fn().mockResolvedValue(0),
|
|
8
|
+
handleRestart: vi.fn().mockResolvedValue(0)
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
/** @type {import('vitest').MockInstance} */
|
|
12
|
+
let write_mock;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
write_mock = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
write_mock.mockRestore();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('parseArgs', () => {
|
|
23
|
+
test('returns help flag when -h or --help present', () => {
|
|
24
|
+
const r1 = parseArgs(['-h']);
|
|
25
|
+
const r2 = parseArgs(['--help']);
|
|
26
|
+
|
|
27
|
+
expect(r1.flags.includes('help')).toBe(true);
|
|
28
|
+
expect(r2.flags.includes('help')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('returns command token when valid', () => {
|
|
32
|
+
expect(parseArgs(['start']).command).toBe('start');
|
|
33
|
+
expect(parseArgs(['stop']).command).toBe('stop');
|
|
34
|
+
expect(parseArgs(['restart']).command).toBe('restart');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('recognizes --no-open flag', () => {
|
|
38
|
+
const r = parseArgs(['start', '--no-open']);
|
|
39
|
+
|
|
40
|
+
expect(r.flags.includes('no-open')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('main', () => {
|
|
45
|
+
test('prints usage and exits 0 on --help', async () => {
|
|
46
|
+
const code = await main(['--help']);
|
|
47
|
+
|
|
48
|
+
expect(code).toBe(0);
|
|
49
|
+
expect(write_mock).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('prints usage and exits 1 on no command', async () => {
|
|
53
|
+
const code = await main([]);
|
|
54
|
+
|
|
55
|
+
expect(code).toBe(1);
|
|
56
|
+
expect(write_mock).toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('dispatches to start handler', async () => {
|
|
60
|
+
const code = await main(['start']);
|
|
61
|
+
|
|
62
|
+
expect(code).toBe(0);
|
|
63
|
+
expect(commands.handleStart).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('propagates --no-open to start handler', async () => {
|
|
67
|
+
await main(['start', '--no-open']);
|
|
68
|
+
|
|
69
|
+
expect(commands.handleStart).toHaveBeenCalledWith({ no_open: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('reads BDUI_NO_OPEN=1 to disable open', async () => {
|
|
73
|
+
const prev = process.env.BDUI_NO_OPEN;
|
|
74
|
+
try {
|
|
75
|
+
process.env.BDUI_NO_OPEN = '1';
|
|
76
|
+
|
|
77
|
+
await main(['start']);
|
|
78
|
+
|
|
79
|
+
expect(commands.handleStart).toHaveBeenCalledWith({ no_open: true });
|
|
80
|
+
} finally {
|
|
81
|
+
if (prev === undefined) {
|
|
82
|
+
delete process.env.BDUI_NO_OPEN;
|
|
83
|
+
} else {
|
|
84
|
+
process.env.BDUI_NO_OPEN = prev;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('dispatches to stop handler', async () => {
|
|
90
|
+
const code = await main(['stop']);
|
|
91
|
+
|
|
92
|
+
expect(code).toBe(0);
|
|
93
|
+
expect(commands.handleStop).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('dispatches to restart handler', async () => {
|
|
97
|
+
const code = await main(['restart']);
|
|
98
|
+
|
|
99
|
+
expect(code).toBe(0);
|
|
100
|
+
expect(commands.handleRestart).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('unknown command prints usage and exits 1', async () => {
|
|
104
|
+
const code = await main(['unknown']);
|
|
105
|
+
|
|
106
|
+
expect(code).toBe(1);
|
|
107
|
+
expect(write_mock).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
}));
|
|
21
|
+
|
|
22
|
+
/** @type {string} */
|
|
23
|
+
let tmp_runtime_dir;
|
|
24
|
+
/** @type {Record<string, string | undefined>} */
|
|
25
|
+
let prev_env;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
// Snapshot selected env vars to restore later
|
|
29
|
+
prev_env = {
|
|
30
|
+
BDUI_RUNTIME_DIR: process.env.BDUI_RUNTIME_DIR,
|
|
31
|
+
PORT: process.env.PORT,
|
|
32
|
+
BDUI_NO_OPEN: process.env.BDUI_NO_OPEN
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
tmp_runtime_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bdui-it-'));
|
|
36
|
+
process.env.BDUI_RUNTIME_DIR = tmp_runtime_dir;
|
|
37
|
+
// Use port 0 so OS assigns an ephemeral port; URL printing still occurs
|
|
38
|
+
process.env.PORT = '0';
|
|
39
|
+
// Ensure default start path would not attempt to open the browser if called via CLI
|
|
40
|
+
process.env.BDUI_NO_OPEN = '1';
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
// Ensure no stray daemon is left between tests
|
|
45
|
+
const pid = daemon.readPidFile();
|
|
46
|
+
if (pid && daemon.isProcessRunning(pid)) {
|
|
47
|
+
await daemon.terminateProcess(pid, 2000);
|
|
48
|
+
}
|
|
49
|
+
daemon.removePidFile();
|
|
50
|
+
// Clear the daemon log to keep noise down in CI
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(daemon.getLogFilePath(), '', 'utf8');
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
// Restore env
|
|
60
|
+
if (prev_env.BDUI_RUNTIME_DIR === undefined) {
|
|
61
|
+
delete process.env.BDUI_RUNTIME_DIR;
|
|
62
|
+
} else {
|
|
63
|
+
process.env.BDUI_RUNTIME_DIR = prev_env.BDUI_RUNTIME_DIR;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (prev_env.PORT === undefined) {
|
|
67
|
+
delete process.env.PORT;
|
|
68
|
+
} else {
|
|
69
|
+
process.env.PORT = prev_env.PORT;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (prev_env.BDUI_NO_OPEN === undefined) {
|
|
73
|
+
delete process.env.BDUI_NO_OPEN;
|
|
74
|
+
} else {
|
|
75
|
+
process.env.BDUI_NO_OPEN = prev_env.BDUI_NO_OPEN;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(tmp_runtime_dir, { recursive: true, force: true });
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('commands integration', () => {
|
|
86
|
+
test('start then stop returns 0 and manages PID file', async () => {
|
|
87
|
+
// setup
|
|
88
|
+
const print_spy = vi
|
|
89
|
+
.spyOn(daemon, 'printServerUrl')
|
|
90
|
+
.mockImplementation(() => {});
|
|
91
|
+
|
|
92
|
+
// execution
|
|
93
|
+
const start_code = await handleStart({ no_open: true });
|
|
94
|
+
|
|
95
|
+
// assertion
|
|
96
|
+
expect(start_code).toBe(0);
|
|
97
|
+
const pid_after_start = daemon.readPidFile();
|
|
98
|
+
expect(typeof pid_after_start).toBe('number');
|
|
99
|
+
expect(Number(pid_after_start)).toBeGreaterThan(0);
|
|
100
|
+
|
|
101
|
+
// execution
|
|
102
|
+
const stop_code = await handleStop();
|
|
103
|
+
|
|
104
|
+
// assertion
|
|
105
|
+
expect(stop_code).toBe(0);
|
|
106
|
+
const pid_after_stop = daemon.readPidFile();
|
|
107
|
+
expect(pid_after_stop).toBeNull();
|
|
108
|
+
|
|
109
|
+
print_spy.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('stop returns 2 when not running', async () => {
|
|
113
|
+
// execution
|
|
114
|
+
const code = await handleStop();
|
|
115
|
+
|
|
116
|
+
// assertion
|
|
117
|
+
expect(code).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('start is idempotent when already running', async () => {
|
|
121
|
+
// setup
|
|
122
|
+
await handleStart({ no_open: true });
|
|
123
|
+
const start_spy = vi.spyOn(daemon, 'startDaemon');
|
|
124
|
+
|
|
125
|
+
// execution
|
|
126
|
+
const code = await handleStart({ no_open: true });
|
|
127
|
+
|
|
128
|
+
// assertion
|
|
129
|
+
expect(code).toBe(0);
|
|
130
|
+
expect(start_spy).not.toHaveBeenCalled();
|
|
131
|
+
|
|
132
|
+
// cleanup
|
|
133
|
+
start_spy.mockRestore();
|
|
134
|
+
await handleStop();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('restart stops (when needed) and starts', async () => {
|
|
138
|
+
// setup
|
|
139
|
+
const print_spy = vi
|
|
140
|
+
.spyOn(daemon, 'printServerUrl')
|
|
141
|
+
.mockImplementation(() => {});
|
|
142
|
+
|
|
143
|
+
// execution
|
|
144
|
+
const code = await handleRestart();
|
|
145
|
+
|
|
146
|
+
// assertion
|
|
147
|
+
expect(code).toBe(0);
|
|
148
|
+
const pid = daemon.readPidFile();
|
|
149
|
+
expect(typeof pid).toBe('number');
|
|
150
|
+
|
|
151
|
+
// cleanup
|
|
152
|
+
await handleStop();
|
|
153
|
+
print_spy.mockRestore();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getConfig } from '../config.js';
|
|
2
|
+
import {
|
|
3
|
+
isProcessRunning,
|
|
4
|
+
printServerUrl,
|
|
5
|
+
readPidFile,
|
|
6
|
+
removePidFile,
|
|
7
|
+
startDaemon,
|
|
8
|
+
terminateProcess
|
|
9
|
+
} from './daemon.js';
|
|
10
|
+
import { openUrl, waitForServer } from './open.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Handle `start` command. Idempotent when already running.
|
|
14
|
+
* - Spawns a detached server process, writes PID file, returns 0.
|
|
15
|
+
* - If already running (PID file present and process alive), prints URL and returns 0.
|
|
16
|
+
* @returns {Promise<number>} Exit code (0 on success)
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ no_open?: boolean }} [options]
|
|
20
|
+
*/
|
|
21
|
+
export async function handleStart(options) {
|
|
22
|
+
const no_open = options?.no_open === true;
|
|
23
|
+
const existing_pid = readPidFile();
|
|
24
|
+
if (existing_pid && isProcessRunning(existing_pid)) {
|
|
25
|
+
printServerUrl();
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
if (existing_pid && !isProcessRunning(existing_pid)) {
|
|
29
|
+
// stale PID file
|
|
30
|
+
removePidFile();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const started = startDaemon();
|
|
34
|
+
if (started && started.pid > 0) {
|
|
35
|
+
printServerUrl();
|
|
36
|
+
// Auto-open the browser once for a fresh daemon start
|
|
37
|
+
if (!no_open) {
|
|
38
|
+
const cfg = getConfig();
|
|
39
|
+
const url = 'http://' + cfg.host + ':' + String(cfg.port);
|
|
40
|
+
// Wait briefly for the server to accept connections (single retry window)
|
|
41
|
+
await waitForServer(url, 600);
|
|
42
|
+
// Best-effort open; ignore result
|
|
43
|
+
await openUrl(url);
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handle `stop` command.
|
|
53
|
+
* - Sends SIGTERM and waits for exit (with SIGKILL fallback), removes PID file.
|
|
54
|
+
* - Returns 2 if not running.
|
|
55
|
+
* @returns {Promise<number>} Exit code
|
|
56
|
+
*/
|
|
57
|
+
export async function handleStop() {
|
|
58
|
+
const existing_pid = readPidFile();
|
|
59
|
+
if (!existing_pid) {
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!isProcessRunning(existing_pid)) {
|
|
64
|
+
// stale PID file
|
|
65
|
+
removePidFile();
|
|
66
|
+
return 2;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const terminated = await terminateProcess(existing_pid, 5000);
|
|
70
|
+
if (terminated) {
|
|
71
|
+
removePidFile();
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Not terminated within timeout
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Handle `restart` command: stop (ignore not-running) then start.
|
|
81
|
+
* @returns {Promise<number>} Exit code (0 on success)
|
|
82
|
+
*/
|
|
83
|
+
export async function handleRestart() {
|
|
84
|
+
const stop_code = await handleStop();
|
|
85
|
+
// 0 = stopped, 2 = not running; both are acceptable to proceed
|
|
86
|
+
if (stop_code !== 0 && stop_code !== 2) {
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
const start_code = await handleStart();
|
|
90
|
+
return start_code === 0 ? 0 : 1;
|
|
91
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { handleStart, handleStop } from './commands.js';
|
|
3
|
+
import * as daemon from './daemon.js';
|
|
4
|
+
|
|
5
|
+
describe('handleStart (unit)', () => {
|
|
6
|
+
test('returns 1 when daemon start fails', async () => {
|
|
7
|
+
const read_pid = vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
|
|
8
|
+
const is_running = vi
|
|
9
|
+
.spyOn(daemon, 'isProcessRunning')
|
|
10
|
+
.mockReturnValue(false);
|
|
11
|
+
const start = vi.spyOn(daemon, 'startDaemon').mockReturnValue(null);
|
|
12
|
+
|
|
13
|
+
const code = await handleStart({ no_open: true });
|
|
14
|
+
|
|
15
|
+
expect(code).toBe(1);
|
|
16
|
+
|
|
17
|
+
read_pid.mockRestore();
|
|
18
|
+
is_running.mockRestore();
|
|
19
|
+
start.mockRestore();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('returns 0 when already running', async () => {
|
|
23
|
+
const read_pid = vi.spyOn(daemon, 'readPidFile').mockReturnValue(12345);
|
|
24
|
+
const is_running = vi
|
|
25
|
+
.spyOn(daemon, 'isProcessRunning')
|
|
26
|
+
.mockReturnValue(true);
|
|
27
|
+
const print_url = vi
|
|
28
|
+
.spyOn(daemon, 'printServerUrl')
|
|
29
|
+
.mockImplementation(() => {});
|
|
30
|
+
|
|
31
|
+
const code = await handleStart({ no_open: true });
|
|
32
|
+
|
|
33
|
+
expect(code).toBe(0);
|
|
34
|
+
expect(print_url).toHaveBeenCalledTimes(1);
|
|
35
|
+
|
|
36
|
+
read_pid.mockRestore();
|
|
37
|
+
is_running.mockRestore();
|
|
38
|
+
print_url.mockRestore();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('handleStop (unit)', () => {
|
|
43
|
+
test('returns 2 when not running and no PID file', async () => {
|
|
44
|
+
const read_pid = vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
|
|
45
|
+
|
|
46
|
+
const code = await handleStop();
|
|
47
|
+
|
|
48
|
+
expect(code).toBe(2);
|
|
49
|
+
|
|
50
|
+
read_pid.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('returns 2 on stale PID and removes file', async () => {
|
|
54
|
+
const read_pid = vi.spyOn(daemon, 'readPidFile').mockReturnValue(1111);
|
|
55
|
+
const is_running = vi
|
|
56
|
+
.spyOn(daemon, 'isProcessRunning')
|
|
57
|
+
.mockReturnValue(false);
|
|
58
|
+
const remove_pid = vi
|
|
59
|
+
.spyOn(daemon, 'removePidFile')
|
|
60
|
+
.mockImplementation(() => {});
|
|
61
|
+
|
|
62
|
+
const code = await handleStop();
|
|
63
|
+
|
|
64
|
+
expect(code).toBe(2);
|
|
65
|
+
expect(remove_pid).toHaveBeenCalledTimes(1);
|
|
66
|
+
|
|
67
|
+
read_pid.mockRestore();
|
|
68
|
+
is_running.mockRestore();
|
|
69
|
+
remove_pid.mockRestore();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('returns 0 when process terminates and removes PID', async () => {
|
|
73
|
+
const read_pid = vi.spyOn(daemon, 'readPidFile').mockReturnValue(2222);
|
|
74
|
+
const is_running = vi
|
|
75
|
+
.spyOn(daemon, 'isProcessRunning')
|
|
76
|
+
.mockReturnValue(true);
|
|
77
|
+
const terminate = vi
|
|
78
|
+
.spyOn(daemon, 'terminateProcess')
|
|
79
|
+
.mockResolvedValue(true);
|
|
80
|
+
const remove_pid = vi
|
|
81
|
+
.spyOn(daemon, 'removePidFile')
|
|
82
|
+
.mockImplementation(() => {});
|
|
83
|
+
|
|
84
|
+
const code = await handleStop();
|
|
85
|
+
|
|
86
|
+
expect(code).toBe(0);
|
|
87
|
+
expect(remove_pid).toHaveBeenCalledTimes(1);
|
|
88
|
+
|
|
89
|
+
read_pid.mockRestore();
|
|
90
|
+
is_running.mockRestore();
|
|
91
|
+
terminate.mockRestore();
|
|
92
|
+
remove_pid.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { getConfig } from '../config.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the runtime directory used for PID and log files.
|
|
10
|
+
* Prefers `BDUI_RUNTIME_DIR`, then `$XDG_RUNTIME_DIR/beads-ui`,
|
|
11
|
+
* and finally `os.tmpdir()/beads-ui`.
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function getRuntimeDir() {
|
|
15
|
+
/** @type {string | undefined} */
|
|
16
|
+
const override_dir = process.env.BDUI_RUNTIME_DIR;
|
|
17
|
+
if (override_dir && override_dir.length > 0) {
|
|
18
|
+
return ensureDir(override_dir);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @type {string | undefined} */
|
|
22
|
+
const xdg_dir = process.env.XDG_RUNTIME_DIR;
|
|
23
|
+
if (xdg_dir && xdg_dir.length > 0) {
|
|
24
|
+
return ensureDir(path.join(xdg_dir, 'beads-ui'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return ensureDir(path.join(os.tmpdir(), 'beads-ui'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure a directory exists with safe permissions and return its path.
|
|
32
|
+
* @param {string} dir_path
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function ensureDir(dir_path) {
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(dir_path, { recursive: true, mode: 0o700 });
|
|
38
|
+
} catch {
|
|
39
|
+
// Best-effort; permission errors will surface on file ops later.
|
|
40
|
+
}
|
|
41
|
+
return dir_path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function getPidFilePath() {
|
|
48
|
+
const runtime_dir = getRuntimeDir();
|
|
49
|
+
return path.join(runtime_dir, 'server.pid');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function getLogFilePath() {
|
|
56
|
+
const runtime_dir = getRuntimeDir();
|
|
57
|
+
return path.join(runtime_dir, 'daemon.log');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read PID from the PID file if present.
|
|
62
|
+
* @returns {number | null}
|
|
63
|
+
*/
|
|
64
|
+
export function readPidFile() {
|
|
65
|
+
const pid_file = getPidFilePath();
|
|
66
|
+
try {
|
|
67
|
+
const text = fs.readFileSync(pid_file, 'utf8');
|
|
68
|
+
const pid_value = Number.parseInt(text.trim(), 10);
|
|
69
|
+
if (Number.isFinite(pid_value) && pid_value > 0) {
|
|
70
|
+
return pid_value;
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// ignore missing or unreadable
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {number} pid
|
|
80
|
+
*/
|
|
81
|
+
export function writePidFile(pid) {
|
|
82
|
+
const pid_file = getPidFilePath();
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(pid_file, String(pid) + '\n', { encoding: 'utf8' });
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore write errors; daemon still runs but management degrades
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function removePidFile() {
|
|
91
|
+
const pid_file = getPidFilePath();
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(pid_file);
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check whether a process is running.
|
|
101
|
+
* @param {number} pid
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
export function isProcessRunning(pid) {
|
|
105
|
+
try {
|
|
106
|
+
if (pid <= 0) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const code = /** @type {{ code?: string }} */ (err).code;
|
|
113
|
+
if (code === 'ESRCH') {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
// EPERM or other errors imply the process likely exists but is not killable
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compute the absolute path to the server entry file.
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
export function getServerEntryPath() {
|
|
126
|
+
const here = fileURLToPath(new URL(import.meta.url));
|
|
127
|
+
const cli_dir = path.dirname(here);
|
|
128
|
+
const server_entry = path.resolve(cli_dir, '..', 'index.js');
|
|
129
|
+
return server_entry;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Spawn the server as a detached daemon, redirecting stdio to the log file.
|
|
134
|
+
* Writes the PID file upon success.
|
|
135
|
+
* @returns {{ pid: number } | null} Returns child PID on success; null on failure.
|
|
136
|
+
*/
|
|
137
|
+
export function startDaemon() {
|
|
138
|
+
const server_entry = getServerEntryPath();
|
|
139
|
+
const log_file = getLogFilePath();
|
|
140
|
+
|
|
141
|
+
// Open the log file for appending; reuse for both stdout and stderr
|
|
142
|
+
/** @type {number} */
|
|
143
|
+
let log_fd;
|
|
144
|
+
try {
|
|
145
|
+
log_fd = fs.openSync(log_file, 'a');
|
|
146
|
+
} catch {
|
|
147
|
+
// If log cannot be opened, fallback to ignoring stdio
|
|
148
|
+
log_fd = -1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @type {import('node:child_process').SpawnOptions} */
|
|
152
|
+
const opts = {
|
|
153
|
+
detached: true,
|
|
154
|
+
env: { ...process.env },
|
|
155
|
+
stdio: log_fd >= 0 ? ['ignore', log_fd, log_fd] : 'ignore',
|
|
156
|
+
windowsHide: true
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const child = spawn(process.execPath, [server_entry], opts);
|
|
161
|
+
// Detach fully from the parent
|
|
162
|
+
child.unref();
|
|
163
|
+
const child_pid = typeof child.pid === 'number' ? child.pid : -1;
|
|
164
|
+
if (child_pid > 0) {
|
|
165
|
+
writePidFile(child_pid);
|
|
166
|
+
return { pid: child_pid };
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
// Log startup error to log file for traceability
|
|
171
|
+
try {
|
|
172
|
+
const message =
|
|
173
|
+
new Date().toISOString() + ' start error: ' + String(err) + '\n';
|
|
174
|
+
fs.appendFileSync(log_file, message, 'utf8');
|
|
175
|
+
} catch {
|
|
176
|
+
// ignore
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Send SIGTERM then (optionally) SIGKILL to stop a process and wait for exit.
|
|
184
|
+
* @param {number} pid
|
|
185
|
+
* @param {number} timeout_ms
|
|
186
|
+
* @returns {Promise<boolean>} Resolves true if the process is gone.
|
|
187
|
+
*/
|
|
188
|
+
export async function terminateProcess(pid, timeout_ms) {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 'SIGTERM');
|
|
191
|
+
} catch (err) {
|
|
192
|
+
const code = /** @type {{ code?: string }} */ (err).code;
|
|
193
|
+
if (code === 'ESRCH') {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
// On EPERM or others, continue to wait/poll
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const start_time = Date.now();
|
|
200
|
+
// Poll until process no longer exists or timeout
|
|
201
|
+
while (Date.now() - start_time < timeout_ms) {
|
|
202
|
+
if (!isProcessRunning(pid)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
await sleep(100);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Fallback to SIGKILL
|
|
209
|
+
try {
|
|
210
|
+
process.kill(pid, 'SIGKILL');
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Give a brief moment after SIGKILL
|
|
216
|
+
await sleep(50);
|
|
217
|
+
return !isProcessRunning(pid);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {number} ms
|
|
222
|
+
* @returns {Promise<void>}
|
|
223
|
+
*/
|
|
224
|
+
function sleep(ms) {
|
|
225
|
+
return new Promise((resolve) => {
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
resolve();
|
|
228
|
+
}, ms);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Print the server URL derived from current config.
|
|
234
|
+
*/
|
|
235
|
+
export function printServerUrl() {
|
|
236
|
+
const cfg = getConfig();
|
|
237
|
+
const url = 'http://' + cfg.host + ':' + String(cfg.port);
|
|
238
|
+
console.log(url);
|
|
239
|
+
}
|