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 (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. 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
+ }