@rsktash/beads-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -0,0 +1,408 @@
1
+ import path from 'node:path';
2
+ import { afterEach, describe, expect, test, vi } from 'vitest';
3
+ import { handleRestart, handleStart, handleStop } from './commands.js';
4
+ import * as daemon from './daemon.js';
5
+ import * as open from './open.js';
6
+
7
+ // Mock open.js to avoid external effects
8
+ vi.mock('./open.js', () => ({
9
+ openUrl: async () => true,
10
+ waitForServer: async () => {},
11
+ fetchWorkspacesFromServer: vi.fn(async () => []),
12
+ registerWorkspaceWithServer: vi.fn(async () => true)
13
+ }));
14
+
15
+ // Mock db resolution
16
+ vi.mock('../db.js', () => ({
17
+ resolveDbPath: () => ({
18
+ path: path.join(process.cwd(), '.beads', 'workspace.db'),
19
+ source: 'nearest',
20
+ exists: true
21
+ }),
22
+ resolveWorkspaceDatabase: () => ({
23
+ path: path.join(process.cwd(), '.beads'),
24
+ source: 'metadata',
25
+ exists: true
26
+ })
27
+ }));
28
+
29
+ // Mock config - mirrors real getConfig() so env var overrides are testable
30
+ vi.mock('../config.js', () => ({
31
+ getConfig: () => {
32
+ const port = Number.parseInt(process.env.PORT || '', 10) || 3000;
33
+ const host = process.env.HOST || '127.0.0.1';
34
+ return { host, port, url: `http://${host}:${port}` };
35
+ }
36
+ }));
37
+
38
+ afterEach(() => {
39
+ delete process.env.PORT;
40
+ delete process.env.HOST;
41
+ });
42
+
43
+ describe('handleStart (unit)', () => {
44
+ test('returns 1 when daemon start fails', async () => {
45
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
46
+ vi.spyOn(daemon, 'isProcessRunning').mockReturnValue(false);
47
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(3000);
48
+ vi.spyOn(daemon, 'startDaemon').mockReturnValue(null);
49
+
50
+ const code = await handleStart({ open: false });
51
+
52
+ expect(code).toBe(1);
53
+ });
54
+
55
+ test('returns 0 when already running', async () => {
56
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(12345);
57
+ vi.spyOn(daemon, 'isProcessRunning').mockReturnValue(true);
58
+ const print_url = vi
59
+ .spyOn(daemon, 'printServerUrl')
60
+ .mockImplementation(() => {});
61
+
62
+ const code = await handleStart({ open: false });
63
+
64
+ expect(code).toBe(0);
65
+ expect(print_url).not.toHaveBeenCalled();
66
+ });
67
+
68
+ test('registers workspace from metadata when already running', async () => {
69
+ const register_workspace_with_server =
70
+ /** @type {import('vitest').Mock} */ (open.registerWorkspaceWithServer);
71
+ register_workspace_with_server.mockReset();
72
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(12345);
73
+ vi.spyOn(daemon, 'isProcessRunning').mockReturnValue(true);
74
+
75
+ const code = await handleStart({ open: false });
76
+
77
+ expect(code).toBe(0);
78
+ expect(register_workspace_with_server).toHaveBeenCalledTimes(1);
79
+ expect(register_workspace_with_server).toHaveBeenCalledWith(
80
+ 'http://127.0.0.1:3000',
81
+ {
82
+ path: process.cwd(),
83
+ database: path.join(process.cwd(), '.beads')
84
+ }
85
+ );
86
+ });
87
+
88
+ test('registers workspace at custom port when already running', async () => {
89
+ const register_workspace_with_server =
90
+ /** @type {import('vitest').Mock} */ (open.registerWorkspaceWithServer);
91
+ register_workspace_with_server.mockReset();
92
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(12345);
93
+ vi.spyOn(daemon, 'isProcessRunning').mockReturnValue(true);
94
+
95
+ const code = await handleStart({ open: false, port: 3030 });
96
+
97
+ expect(code).toBe(0);
98
+ expect(register_workspace_with_server).toHaveBeenCalledTimes(1);
99
+ expect(register_workspace_with_server).toHaveBeenCalledWith(
100
+ 'http://127.0.0.1:3030',
101
+ {
102
+ path: process.cwd(),
103
+ database: path.join(process.cwd(), '.beads')
104
+ }
105
+ );
106
+ });
107
+
108
+ test('registers workspace with existing server when spawned daemon exits early', async () => {
109
+ const register_workspace_with_server =
110
+ /** @type {import('vitest').Mock} */ (open.registerWorkspaceWithServer);
111
+ register_workspace_with_server.mockReset();
112
+
113
+ const remove_pid = vi
114
+ .spyOn(daemon, 'removePidFile')
115
+ .mockImplementation(() => {});
116
+
117
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
118
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(3000);
119
+ vi.spyOn(daemon, 'startDaemon').mockReturnValue({ pid: 7777 });
120
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation((pid) => pid === 1);
121
+
122
+ const code = await handleStart({ open: false });
123
+
124
+ expect(code).toBe(0);
125
+ expect(remove_pid).toHaveBeenCalledTimes(1);
126
+ expect(register_workspace_with_server).toHaveBeenCalledTimes(1);
127
+ expect(register_workspace_with_server).toHaveBeenCalledWith(
128
+ 'http://127.0.0.1:3000',
129
+ {
130
+ path: process.cwd(),
131
+ database: path.join(process.cwd(), '.beads')
132
+ }
133
+ );
134
+ });
135
+
136
+ test('attempts workspace registration after successful daemon start', async () => {
137
+ const register_workspace_with_server =
138
+ /** @type {import('vitest').Mock} */ (open.registerWorkspaceWithServer);
139
+ register_workspace_with_server.mockReset();
140
+
141
+ const print_url = vi
142
+ .spyOn(daemon, 'printServerUrl')
143
+ .mockImplementation(() => {});
144
+
145
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
146
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(3000);
147
+ vi.spyOn(daemon, 'startDaemon').mockReturnValue({ pid: 4321 });
148
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation(
149
+ (pid) => pid === 4321
150
+ );
151
+
152
+ const code = await handleStart({ open: false });
153
+
154
+ expect(code).toBe(0);
155
+ expect(print_url).toHaveBeenCalledTimes(1);
156
+ expect(register_workspace_with_server).toHaveBeenCalledTimes(1);
157
+ expect(register_workspace_with_server).toHaveBeenCalledWith(
158
+ 'http://127.0.0.1:3000',
159
+ {
160
+ path: process.cwd(),
161
+ database: path.join(process.cwd(), '.beads')
162
+ }
163
+ );
164
+ });
165
+ });
166
+
167
+ describe('handleStop (unit)', () => {
168
+ test('returns 2 when not running and no PID file', async () => {
169
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
170
+
171
+ const code = await handleStop();
172
+
173
+ expect(code).toBe(2);
174
+ });
175
+
176
+ test('returns 2 on stale PID and removes file', async () => {
177
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(1111);
178
+ vi.spyOn(daemon, 'isProcessRunning').mockReturnValue(false);
179
+ const remove_pid = vi
180
+ .spyOn(daemon, 'removePidFile')
181
+ .mockImplementation(() => {});
182
+
183
+ const code = await handleStop();
184
+
185
+ expect(code).toBe(2);
186
+ expect(remove_pid).toHaveBeenCalledTimes(1);
187
+ });
188
+
189
+ test('returns 0 when process terminates and removes PID', async () => {
190
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(2222);
191
+ vi.spyOn(daemon, 'isProcessRunning').mockReturnValue(true);
192
+ vi.spyOn(daemon, 'terminateProcess').mockResolvedValue(true);
193
+ const remove_pid = vi
194
+ .spyOn(daemon, 'removePidFile')
195
+ .mockImplementation(() => {});
196
+
197
+ const code = await handleStop();
198
+
199
+ expect(code).toBe(0);
200
+ expect(remove_pid).toHaveBeenCalledTimes(1);
201
+ });
202
+ });
203
+
204
+ describe('handleRestart (unit)', () => {
205
+ test('reuses detected port when no explicit port given', async () => {
206
+ // First call: restart reads PID (running daemon)
207
+ // Second call: handleStop reads PID (to terminate)
208
+ // Third call: handleStart reads PID (no existing daemon after stop)
209
+ vi.spyOn(daemon, 'readPidFile')
210
+ .mockReturnValueOnce(3333) // restart: detect port
211
+ .mockReturnValueOnce(3333) // handleStop: find process
212
+ .mockReturnValueOnce(null); // handleStart: no existing
213
+ vi.spyOn(daemon, 'detectListeningPort').mockReturnValue(4000);
214
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(4000);
215
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation(
216
+ (pid) => pid === 3333 || pid === 5555
217
+ );
218
+ vi.spyOn(daemon, 'terminateProcess').mockResolvedValue(true);
219
+ vi.spyOn(daemon, 'removePidFile').mockImplementation(() => {});
220
+ vi.spyOn(daemon, 'printServerUrl').mockImplementation(() => {});
221
+
222
+ const start_daemon = vi
223
+ .spyOn(daemon, 'startDaemon')
224
+ .mockReturnValue({ pid: 5555 });
225
+
226
+ const code = await handleRestart();
227
+
228
+ expect(code).toBe(0);
229
+ expect(start_daemon).toHaveBeenCalledWith(
230
+ expect.objectContaining({ port: 4000 })
231
+ );
232
+ });
233
+
234
+ test('explicit port overrides detected port', async () => {
235
+ vi.spyOn(daemon, 'readPidFile')
236
+ .mockReturnValueOnce(3333)
237
+ .mockReturnValueOnce(3333)
238
+ .mockReturnValueOnce(null);
239
+ vi.spyOn(daemon, 'detectListeningPort').mockReturnValue(4000);
240
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation(
241
+ (pid) => pid === 3333 || pid === 6666
242
+ );
243
+ vi.spyOn(daemon, 'terminateProcess').mockResolvedValue(true);
244
+ vi.spyOn(daemon, 'removePidFile').mockImplementation(() => {});
245
+
246
+ const start_daemon = vi
247
+ .spyOn(daemon, 'startDaemon')
248
+ .mockReturnValue({ pid: 6666 });
249
+
250
+ const code = await handleRestart({ port: 9999 });
251
+
252
+ expect(code).toBe(0);
253
+ expect(start_daemon).toHaveBeenCalledWith(
254
+ expect.objectContaining({ port: 9999 })
255
+ );
256
+ });
257
+
258
+ test('falls back to default when port detection fails', async () => {
259
+ vi.spyOn(daemon, 'readPidFile')
260
+ .mockReturnValueOnce(3333)
261
+ .mockReturnValueOnce(3333)
262
+ .mockReturnValueOnce(null);
263
+ vi.spyOn(daemon, 'detectListeningPort').mockReturnValue(null);
264
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(3000);
265
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation(
266
+ (pid) => pid === 3333 || pid === 7777
267
+ );
268
+ vi.spyOn(daemon, 'terminateProcess').mockResolvedValue(true);
269
+ vi.spyOn(daemon, 'removePidFile').mockImplementation(() => {});
270
+ vi.spyOn(daemon, 'printServerUrl').mockImplementation(() => {});
271
+
272
+ const start_daemon = vi
273
+ .spyOn(daemon, 'startDaemon')
274
+ .mockReturnValue({ pid: 7777 });
275
+
276
+ const code = await handleRestart();
277
+
278
+ expect(code).toBe(0);
279
+ // port should not be set — falls through to default
280
+ expect(start_daemon.mock.calls[0]?.[0]).toEqual(
281
+ expect.not.objectContaining({ port: expect.any(Number) })
282
+ );
283
+ });
284
+
285
+ test('re-registers workspaces from previous server after restart', async () => {
286
+ const fetch_workspaces = /** @type {import('vitest').Mock} */ (
287
+ open.fetchWorkspacesFromServer
288
+ );
289
+ fetch_workspaces.mockResolvedValueOnce([
290
+ { path: '/project/a', database: '/project/a/.beads' },
291
+ { path: '/project/b', database: '/project/b/.beads' }
292
+ ]);
293
+
294
+ const register_workspace = /** @type {import('vitest').Mock} */ (
295
+ open.registerWorkspaceWithServer
296
+ );
297
+ register_workspace.mockReset();
298
+
299
+ vi.spyOn(daemon, 'readPidFile')
300
+ .mockReturnValueOnce(3333) // restart: detect port
301
+ .mockReturnValueOnce(3333) // handleStop: find process
302
+ .mockReturnValueOnce(null); // handleStart: no existing
303
+ vi.spyOn(daemon, 'detectListeningPort').mockReturnValue(null);
304
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation(
305
+ (pid) => pid === 3333 || pid === 9999
306
+ );
307
+ vi.spyOn(daemon, 'terminateProcess').mockResolvedValue(true);
308
+ vi.spyOn(daemon, 'removePidFile').mockImplementation(() => {});
309
+ vi.spyOn(daemon, 'printServerUrl').mockImplementation(() => {});
310
+ vi.spyOn(daemon, 'startDaemon').mockReturnValue({ pid: 9999 });
311
+
312
+ const code = await handleRestart();
313
+
314
+ expect(code).toBe(0);
315
+ // The cwd workspace is registered by handleStart, plus the two saved ones
316
+ expect(register_workspace).toHaveBeenCalledWith('http://127.0.0.1:3000', {
317
+ path: '/project/a',
318
+ database: '/project/a/.beads'
319
+ });
320
+ expect(register_workspace).toHaveBeenCalledWith('http://127.0.0.1:3000', {
321
+ path: '/project/b',
322
+ database: '/project/b/.beads'
323
+ });
324
+ });
325
+ });
326
+
327
+ describe('port auto-increment (unit)', () => {
328
+ test('auto-increments port when default is in use by non-bdui', async () => {
329
+ const register_workspace = /** @type {import('vitest').Mock} */ (
330
+ open.registerWorkspaceWithServer
331
+ );
332
+ // Registration fails — not a bdui instance on that port
333
+ register_workspace.mockResolvedValueOnce(false);
334
+
335
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
336
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(3001);
337
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation(
338
+ (pid) => pid === 8888
339
+ );
340
+ vi.spyOn(daemon, 'printServerUrl').mockImplementation(() => {});
341
+
342
+ const start_daemon = vi
343
+ .spyOn(daemon, 'startDaemon')
344
+ .mockReturnValue({ pid: 8888 });
345
+
346
+ const code = await handleStart({ open: false });
347
+
348
+ expect(code).toBe(0);
349
+ expect(start_daemon).toHaveBeenCalledWith(
350
+ expect.objectContaining({ port: 3001 })
351
+ );
352
+ });
353
+
354
+ test('reuses existing bdui when default port is occupied', async () => {
355
+ const register_workspace = /** @type {import('vitest').Mock} */ (
356
+ open.registerWorkspaceWithServer
357
+ );
358
+ // Registration succeeds — existing bdui on that port
359
+ register_workspace.mockResolvedValueOnce(true);
360
+
361
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
362
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(3001);
363
+
364
+ const start_daemon = vi
365
+ .spyOn(daemon, 'startDaemon')
366
+ .mockReturnValue({ pid: 8888 });
367
+
368
+ const code = await handleStart({ open: false });
369
+
370
+ expect(code).toBe(0);
371
+ // Should NOT have started a new daemon — reused existing
372
+ expect(start_daemon).not.toHaveBeenCalled();
373
+ });
374
+
375
+ test('does not auto-increment when explicit port is given', async () => {
376
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
377
+ vi.spyOn(daemon, 'isProcessRunning').mockImplementation(
378
+ (pid) => pid === 8888
379
+ );
380
+ vi.spyOn(daemon, 'printServerUrl').mockImplementation(() => {});
381
+
382
+ const find_port = vi
383
+ .spyOn(daemon, 'findAvailablePort')
384
+ .mockResolvedValue(5000);
385
+
386
+ const start_daemon = vi
387
+ .spyOn(daemon, 'startDaemon')
388
+ .mockReturnValue({ pid: 8888 });
389
+
390
+ const code = await handleStart({ open: false, port: 5000 });
391
+
392
+ expect(code).toBe(0);
393
+ // findAvailablePort should not be called when port is explicit
394
+ expect(find_port).not.toHaveBeenCalled();
395
+ expect(start_daemon).toHaveBeenCalledWith(
396
+ expect.objectContaining({ port: 5000 })
397
+ );
398
+ });
399
+
400
+ test('returns 1 when no port is available', async () => {
401
+ vi.spyOn(daemon, 'readPidFile').mockReturnValue(null);
402
+ vi.spyOn(daemon, 'findAvailablePort').mockResolvedValue(null);
403
+
404
+ const code = await handleStart({ open: false });
405
+
406
+ expect(code).toBe(1);
407
+ });
408
+ });