@katyella/legio 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 (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,626 @@
1
+ /**
2
+ * Tests for the server command.
3
+ *
4
+ * Tests arg parsing, PID file management, stop, and status subcommands.
5
+ * No real daemon spawning or HTTP server startup occurs in these tests.
6
+ * Uses temp git repos + DI (ServerDeps) to avoid tmux/spawn side effects.
7
+ *
8
+ * Actual server startup is tested in src/server/index.test.ts.
9
+ */
10
+
11
+ import { mkdir, realpath, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { ValidationError } from "../errors.ts";
15
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
16
+ import {
17
+ readServerPid,
18
+ removeServerPid,
19
+ type ServerDeps,
20
+ serverCommand,
21
+ writeServerPid,
22
+ } from "./server.ts";
23
+
24
+ // --- Test Setup ---
25
+
26
+ let tempDir: string;
27
+ let legioDir: string;
28
+ const originalCwd = process.cwd();
29
+
30
+ beforeEach(async () => {
31
+ process.chdir(originalCwd);
32
+
33
+ tempDir = await realpath(await createTempGitRepo());
34
+ legioDir = join(tempDir, ".legio");
35
+ await mkdir(legioDir, { recursive: true });
36
+
37
+ // Minimal config.yaml for loadConfig
38
+ await writeFile(
39
+ join(legioDir, "config.yaml"),
40
+ ["project:", " name: test-server", ` root: ${tempDir}`, " canonicalBranch: main"].join("\n"),
41
+ );
42
+
43
+ process.chdir(tempDir);
44
+ });
45
+
46
+ afterEach(async () => {
47
+ process.chdir(originalCwd);
48
+ await cleanupTempDir(tempDir);
49
+ });
50
+
51
+ // --- Help / Usage ---
52
+
53
+ describe("serverCommand — help", () => {
54
+ it("should print help when --help is passed", async () => {
55
+ const originalWrite = process.stdout.write;
56
+ let output = "";
57
+ process.stdout.write = vi.fn((chunk: unknown) => {
58
+ output += String(chunk);
59
+ return true;
60
+ }) as typeof process.stdout.write;
61
+ try {
62
+ await serverCommand(["--help"]);
63
+ expect(output).toContain("server");
64
+ expect(output).toContain("start");
65
+ expect(output).toContain("stop");
66
+ expect(output).toContain("status");
67
+ expect(output).toContain("--daemon");
68
+ } finally {
69
+ process.stdout.write = originalWrite;
70
+ }
71
+ });
72
+
73
+ it("should print help when -h is passed", async () => {
74
+ const originalWrite = process.stdout.write;
75
+ let output = "";
76
+ process.stdout.write = vi.fn((chunk: unknown) => {
77
+ output += String(chunk);
78
+ return true;
79
+ }) as typeof process.stdout.write;
80
+ try {
81
+ await serverCommand(["-h"]);
82
+ expect(output).toContain("server");
83
+ } finally {
84
+ process.stdout.write = originalWrite;
85
+ }
86
+ });
87
+
88
+ it("should print help when no args are passed", async () => {
89
+ const originalWrite = process.stdout.write;
90
+ let output = "";
91
+ process.stdout.write = vi.fn((chunk: unknown) => {
92
+ output += String(chunk);
93
+ return true;
94
+ }) as typeof process.stdout.write;
95
+ try {
96
+ await serverCommand([]);
97
+ expect(output).toContain("server");
98
+ } finally {
99
+ process.stdout.write = originalWrite;
100
+ }
101
+ });
102
+
103
+ it("should exit with error for unknown subcommand", async () => {
104
+ const originalExit = process.exit;
105
+ const originalStderr = process.stderr.write;
106
+ let exitCode: number | undefined;
107
+ let stderrOutput = "";
108
+
109
+ process.exit = vi.fn((code?: string | number | null | undefined) => {
110
+ exitCode = typeof code === "number" ? code : 1;
111
+ throw new Error("process.exit called");
112
+ }) as never;
113
+
114
+ process.stderr.write = vi.fn((chunk: unknown) => {
115
+ stderrOutput += String(chunk);
116
+ return true;
117
+ }) as typeof process.stderr.write;
118
+
119
+ try {
120
+ await expect(serverCommand(["bogus"])).rejects.toThrow("process.exit called");
121
+ expect(exitCode).toBe(1);
122
+ expect(stderrOutput).toContain("bogus");
123
+ } finally {
124
+ process.exit = originalExit;
125
+ process.stderr.write = originalStderr;
126
+ }
127
+ });
128
+ });
129
+
130
+ // --- Port Validation ---
131
+
132
+ describe("serverCommand start — port validation", () => {
133
+ it("should throw ValidationError for non-numeric port", async () => {
134
+ await expect(serverCommand(["start", "--port", "abc"])).rejects.toBeInstanceOf(ValidationError);
135
+ });
136
+
137
+ it("should throw ValidationError for port 0", async () => {
138
+ await expect(serverCommand(["start", "--port", "0"])).rejects.toBeInstanceOf(ValidationError);
139
+ });
140
+
141
+ it("should throw ValidationError for port > 65535", async () => {
142
+ await expect(serverCommand(["start", "--port", "99999"])).rejects.toBeInstanceOf(
143
+ ValidationError,
144
+ );
145
+ });
146
+
147
+ it("should throw ValidationError for negative port", async () => {
148
+ await expect(serverCommand(["start", "--port", "-1"])).rejects.toBeInstanceOf(ValidationError);
149
+ });
150
+ });
151
+
152
+ // --- PID File Helpers ---
153
+
154
+ describe("PID file helpers", () => {
155
+ it("readServerPid returns null when no file exists", async () => {
156
+ const pid = await readServerPid(tempDir);
157
+ expect(pid).toBeNull();
158
+ });
159
+
160
+ it("writeServerPid and readServerPid round-trip", async () => {
161
+ await writeServerPid(tempDir, 12345);
162
+ const pid = await readServerPid(tempDir);
163
+ expect(pid).toBe(12345);
164
+ });
165
+
166
+ it("removeServerPid removes the file", async () => {
167
+ await writeServerPid(tempDir, 12345);
168
+ await removeServerPid(tempDir);
169
+ const pid = await readServerPid(tempDir);
170
+ expect(pid).toBeNull();
171
+ });
172
+
173
+ it("removeServerPid is idempotent (no error if file missing)", async () => {
174
+ await expect(removeServerPid(tempDir)).resolves.toBeUndefined();
175
+ });
176
+ });
177
+
178
+ // --- Daemon Flag ---
179
+
180
+ describe("serverCommand start --daemon", () => {
181
+ it("prints already running message when process is alive", async () => {
182
+ // Write a PID file with a fake alive PID (use current process PID)
183
+ await writeServerPid(tempDir, process.pid);
184
+
185
+ const deps: ServerDeps = {
186
+ _isProcessRunning: () => true,
187
+ };
188
+
189
+ let output = "";
190
+ const originalWrite = process.stdout.write;
191
+ process.stdout.write = vi.fn((chunk: unknown) => {
192
+ output += String(chunk);
193
+ return true;
194
+ }) as typeof process.stdout.write;
195
+
196
+ try {
197
+ await serverCommand(["start", "--daemon"], deps);
198
+ expect(output).toContain("already running");
199
+ expect(output).toContain(String(process.pid));
200
+ } finally {
201
+ process.stdout.write = originalWrite;
202
+ }
203
+
204
+ // PID file should still exist (we didn't stop it)
205
+ const pid = await readServerPid(tempDir);
206
+ expect(pid).toBe(process.pid);
207
+ });
208
+
209
+ it("spawns daemon and writes PID file when not running", async () => {
210
+ const fakePid = 55555;
211
+ let spawnCalled = false;
212
+ let spawnArgs: string[] | undefined;
213
+ let spawnEnv: NodeJS.ProcessEnv | undefined;
214
+
215
+ let spawnCmd: string | undefined;
216
+ const deps: ServerDeps = {
217
+ _isProcessRunning: (pid) => pid === fakePid,
218
+ _spawn: (cmd, args, opts) => {
219
+ spawnCalled = true;
220
+ spawnCmd = cmd;
221
+ spawnArgs = args;
222
+ spawnEnv = opts.env as NodeJS.ProcessEnv;
223
+ return { pid: fakePid, unref: () => {} };
224
+ },
225
+ _sleep: async () => {},
226
+ };
227
+
228
+ let output = "";
229
+ const originalWrite = process.stdout.write;
230
+ process.stdout.write = vi.fn((chunk: unknown) => {
231
+ output += String(chunk);
232
+ return true;
233
+ }) as typeof process.stdout.write;
234
+
235
+ try {
236
+ await serverCommand(["start", "--daemon", "--port", "4200"], deps);
237
+ } finally {
238
+ process.stdout.write = originalWrite;
239
+ }
240
+
241
+ expect(spawnCalled).toBe(true);
242
+ expect(spawnCmd).toBe("legio");
243
+ expect(spawnArgs).toContain("server");
244
+ expect(spawnArgs).toContain("start");
245
+ expect(spawnArgs).toContain("--port");
246
+ expect(spawnArgs).toContain("4200");
247
+ // Should NOT include --daemon (prevents recursion)
248
+ expect(spawnArgs).not.toContain("--daemon");
249
+ // Should set LEGIO_SERVER_DAEMON env var
250
+ expect(spawnEnv?.LEGIO_SERVER_DAEMON).toBe("1");
251
+ // Should NOT inherit __LEGIO_TSX_LOADED — daemon must re-exec with tsx
252
+ expect(spawnEnv?.__LEGIO_TSX_LOADED).toBeUndefined();
253
+
254
+ // PID file should be written with the child's PID
255
+ const pid = await readServerPid(tempDir);
256
+ expect(pid).toBe(fakePid);
257
+
258
+ // Output should mention the PID and URL
259
+ expect(output).toContain(String(fakePid));
260
+ expect(output).toContain("http://");
261
+ });
262
+
263
+ it("strips __LEGIO_TSX_LOADED from daemon env to prevent Node v23 type-stripping errors", async () => {
264
+ const originalTsxLoaded = process.env.__LEGIO_TSX_LOADED;
265
+ process.env.__LEGIO_TSX_LOADED = "1";
266
+
267
+ const fakePid = 55557;
268
+ let capturedEnv: NodeJS.ProcessEnv | undefined;
269
+
270
+ const deps: ServerDeps = {
271
+ _isProcessRunning: (pid) => pid === fakePid,
272
+ _spawn: (_cmd, _args, opts) => {
273
+ capturedEnv = opts.env as NodeJS.ProcessEnv;
274
+ return { pid: fakePid, unref: () => {} };
275
+ },
276
+ _sleep: async () => {},
277
+ };
278
+
279
+ const originalWrite = process.stdout.write;
280
+ process.stdout.write = vi.fn(() => true) as typeof process.stdout.write;
281
+
282
+ try {
283
+ await serverCommand(["start", "--daemon"], deps);
284
+ } finally {
285
+ process.stdout.write = originalWrite;
286
+ if (originalTsxLoaded === undefined) {
287
+ delete process.env.__LEGIO_TSX_LOADED;
288
+ } else {
289
+ process.env.__LEGIO_TSX_LOADED = originalTsxLoaded;
290
+ }
291
+ }
292
+
293
+ // __LEGIO_TSX_LOADED must be absent so the daemon shim re-execs with tsx
294
+ expect(capturedEnv?.__LEGIO_TSX_LOADED).toBeUndefined();
295
+ // LEGIO_SERVER_DAEMON must still be set
296
+ expect(capturedEnv?.LEGIO_SERVER_DAEMON).toBe("1");
297
+ });
298
+
299
+ it("cleans up stale PID file when process is dead before spawning", async () => {
300
+ // Write a stale PID file
301
+ await writeServerPid(tempDir, 99991);
302
+
303
+ const fakePid = 55556;
304
+ const deps: ServerDeps = {
305
+ _isProcessRunning: (pid) => pid === fakePid,
306
+ _spawn: () => ({ pid: fakePid, unref: () => {} }),
307
+ _sleep: async () => {},
308
+ };
309
+
310
+ const originalWrite = process.stdout.write;
311
+ process.stdout.write = vi.fn(() => true) as typeof process.stdout.write;
312
+
313
+ try {
314
+ await serverCommand(["start", "--daemon"], deps);
315
+ } finally {
316
+ process.stdout.write = originalWrite;
317
+ }
318
+
319
+ // PID file should now have the new daemon's PID
320
+ const pid = await readServerPid(tempDir);
321
+ expect(pid).toBe(fakePid);
322
+ });
323
+
324
+ it("reports failure and cleans up PID when daemon exits immediately (port conflict)", async () => {
325
+ const fakePid = 55558;
326
+ const deps: ServerDeps = {
327
+ _isProcessRunning: () => false,
328
+ _spawn: () => ({ pid: fakePid, unref: () => {} }),
329
+ _sleep: async () => {},
330
+ };
331
+
332
+ const originalExit = process.exit;
333
+ let exitCode: number | undefined;
334
+ process.exit = vi.fn((code?: string | number | null | undefined) => {
335
+ exitCode = typeof code === "number" ? code : 1;
336
+ throw new Error("process.exit called");
337
+ }) as never;
338
+
339
+ let stderrOutput = "";
340
+ const originalStderr = process.stderr.write;
341
+ process.stderr.write = vi.fn((chunk: unknown) => {
342
+ stderrOutput += String(chunk);
343
+ return true;
344
+ }) as typeof process.stderr.write;
345
+
346
+ const originalStdout = process.stdout.write;
347
+ process.stdout.write = vi.fn(() => true) as typeof process.stdout.write;
348
+
349
+ try {
350
+ await expect(serverCommand(["start", "--daemon"], deps)).rejects.toThrow(
351
+ "process.exit called",
352
+ );
353
+ expect(exitCode).toBe(1);
354
+ expect(stderrOutput).toContain("exited immediately");
355
+ } finally {
356
+ process.exit = originalExit;
357
+ process.stderr.write = originalStderr;
358
+ process.stdout.write = originalStdout;
359
+ }
360
+
361
+ const pid = await readServerPid(tempDir);
362
+ expect(pid).toBeNull();
363
+ });
364
+ });
365
+
366
+ // --- Stop Subcommand ---
367
+
368
+ describe("serverCommand stop", () => {
369
+ it("prints 'Server not running' when no PID file exists", async () => {
370
+ let output = "";
371
+ const originalWrite = process.stdout.write;
372
+ process.stdout.write = vi.fn((chunk: unknown) => {
373
+ output += String(chunk);
374
+ return true;
375
+ }) as typeof process.stdout.write;
376
+
377
+ try {
378
+ await serverCommand(["stop"]);
379
+ } finally {
380
+ process.stdout.write = originalWrite;
381
+ }
382
+
383
+ expect(output).toContain("Server not running");
384
+ });
385
+
386
+ it("cleans up stale PID file when process is dead", async () => {
387
+ await writeServerPid(tempDir, 99992);
388
+
389
+ const deps: ServerDeps = {
390
+ _isProcessRunning: () => false,
391
+ };
392
+
393
+ let output = "";
394
+ const originalWrite = process.stdout.write;
395
+ process.stdout.write = vi.fn((chunk: unknown) => {
396
+ output += String(chunk);
397
+ return true;
398
+ }) as typeof process.stdout.write;
399
+
400
+ try {
401
+ await serverCommand(["stop"], deps);
402
+ } finally {
403
+ process.stdout.write = originalWrite;
404
+ }
405
+
406
+ expect(output).toContain("not running");
407
+ // PID file should be removed
408
+ const pid = await readServerPid(tempDir);
409
+ expect(pid).toBeNull();
410
+ });
411
+
412
+ it("sends SIGTERM and removes PID file when process is running", async () => {
413
+ const fakePid = 99993;
414
+ await writeServerPid(tempDir, fakePid);
415
+
416
+ const killCalls: Array<{ pid: number; signal: string }> = [];
417
+ const originalKill = process.kill;
418
+ process.kill = vi.fn((pid: number, signal?: string | number) => {
419
+ killCalls.push({ pid, signal: String(signal ?? "SIGTERM") });
420
+ return true;
421
+ }) as typeof process.kill;
422
+
423
+ const deps: ServerDeps = {
424
+ _isProcessRunning: (pid) => pid === fakePid,
425
+ };
426
+
427
+ let output = "";
428
+ const originalWrite = process.stdout.write;
429
+ process.stdout.write = vi.fn((chunk: unknown) => {
430
+ output += String(chunk);
431
+ return true;
432
+ }) as typeof process.stdout.write;
433
+
434
+ try {
435
+ await serverCommand(["stop"], deps);
436
+ } finally {
437
+ process.stdout.write = originalWrite;
438
+ process.kill = originalKill;
439
+ }
440
+
441
+ expect(killCalls.length).toBeGreaterThan(0);
442
+ expect(killCalls[0]?.signal).toBe("SIGTERM");
443
+ expect(output).toContain("stopped");
444
+ expect(output).toContain(String(fakePid));
445
+
446
+ // PID file removed
447
+ const pid = await readServerPid(tempDir);
448
+ expect(pid).toBeNull();
449
+ });
450
+ });
451
+
452
+ // --- Status Subcommand ---
453
+
454
+ describe("serverCommand status", () => {
455
+ it("prints 'Server not running' when no PID file", async () => {
456
+ let output = "";
457
+ const originalWrite = process.stdout.write;
458
+ process.stdout.write = vi.fn((chunk: unknown) => {
459
+ output += String(chunk);
460
+ return true;
461
+ }) as typeof process.stdout.write;
462
+
463
+ try {
464
+ await serverCommand(["status"]);
465
+ } finally {
466
+ process.stdout.write = originalWrite;
467
+ }
468
+
469
+ expect(output).toContain("not running");
470
+ });
471
+
472
+ it("reports running with PID when process is alive", async () => {
473
+ const fakePid = 99994;
474
+ await writeServerPid(tempDir, fakePid);
475
+
476
+ const deps: ServerDeps = {
477
+ _isProcessRunning: (pid) => pid === fakePid,
478
+ };
479
+
480
+ let output = "";
481
+ const originalWrite = process.stdout.write;
482
+ process.stdout.write = vi.fn((chunk: unknown) => {
483
+ output += String(chunk);
484
+ return true;
485
+ }) as typeof process.stdout.write;
486
+
487
+ try {
488
+ await serverCommand(["status"], deps);
489
+ } finally {
490
+ process.stdout.write = originalWrite;
491
+ }
492
+
493
+ expect(output).toContain("running");
494
+ expect(output).toContain(String(fakePid));
495
+ });
496
+
497
+ it("cleans up stale PID and reports not running", async () => {
498
+ await writeServerPid(tempDir, 99995);
499
+
500
+ const deps: ServerDeps = {
501
+ _isProcessRunning: () => false,
502
+ };
503
+
504
+ let output = "";
505
+ const originalWrite = process.stdout.write;
506
+ process.stdout.write = vi.fn((chunk: unknown) => {
507
+ output += String(chunk);
508
+ return true;
509
+ }) as typeof process.stdout.write;
510
+
511
+ try {
512
+ await serverCommand(["status"], deps);
513
+ } finally {
514
+ process.stdout.write = originalWrite;
515
+ }
516
+
517
+ expect(output).toContain("not running");
518
+ // PID file cleaned up
519
+ const pid = await readServerPid(tempDir);
520
+ expect(pid).toBeNull();
521
+ });
522
+
523
+ it("--json flag outputs JSON when not running", async () => {
524
+ let output = "";
525
+ const originalWrite = process.stdout.write;
526
+ process.stdout.write = vi.fn((chunk: unknown) => {
527
+ output += String(chunk);
528
+ return true;
529
+ }) as typeof process.stdout.write;
530
+
531
+ try {
532
+ await serverCommand(["status", "--json"]);
533
+ } finally {
534
+ process.stdout.write = originalWrite;
535
+ }
536
+
537
+ const parsed = JSON.parse(output.trim());
538
+ expect(parsed.running).toBe(false);
539
+ });
540
+
541
+ it("--json flag outputs JSON with PID when running", async () => {
542
+ const fakePid = 99996;
543
+ await writeServerPid(tempDir, fakePid);
544
+
545
+ const deps: ServerDeps = {
546
+ _isProcessRunning: (pid) => pid === fakePid,
547
+ };
548
+
549
+ let output = "";
550
+ const originalWrite = process.stdout.write;
551
+ process.stdout.write = vi.fn((chunk: unknown) => {
552
+ output += String(chunk);
553
+ return true;
554
+ }) as typeof process.stdout.write;
555
+
556
+ try {
557
+ await serverCommand(["status", "--json"], deps);
558
+ } finally {
559
+ process.stdout.write = originalWrite;
560
+ }
561
+
562
+ const parsed = JSON.parse(output.trim());
563
+ expect(parsed.running).toBe(true);
564
+ expect(parsed.pid).toBe(fakePid);
565
+ expect(typeof parsed.port).toBe("number");
566
+ });
567
+ });
568
+
569
+ // --- autoStartCoordinator via LEGIO_SERVER_DAEMON ---
570
+
571
+ describe("serverCommand start — autoStartCoordinator", () => {
572
+ const originalEnv = process.env.LEGIO_SERVER_DAEMON;
573
+
574
+ afterEach(() => {
575
+ if (originalEnv === undefined) {
576
+ delete process.env.LEGIO_SERVER_DAEMON;
577
+ } else {
578
+ process.env.LEGIO_SERVER_DAEMON = originalEnv;
579
+ }
580
+ });
581
+
582
+ it("passes autoStartCoordinator=true when LEGIO_SERVER_DAEMON=1", async () => {
583
+ process.env.LEGIO_SERVER_DAEMON = "1";
584
+
585
+ let capturedOpts: { autoStartCoordinator: boolean } | undefined;
586
+ const deps: ServerDeps = {
587
+ _startServer: async (opts) => {
588
+ capturedOpts = opts;
589
+ },
590
+ };
591
+
592
+ await serverCommand(["start"], deps);
593
+
594
+ expect(capturedOpts?.autoStartCoordinator).toBe(true);
595
+ });
596
+
597
+ it("passes autoStartCoordinator=false when LEGIO_SERVER_DAEMON is unset", async () => {
598
+ delete process.env.LEGIO_SERVER_DAEMON;
599
+
600
+ let capturedOpts: { autoStartCoordinator: boolean } | undefined;
601
+ const deps: ServerDeps = {
602
+ _startServer: async (opts) => {
603
+ capturedOpts = opts;
604
+ },
605
+ };
606
+
607
+ await serverCommand(["start"], deps);
608
+
609
+ expect(capturedOpts?.autoStartCoordinator).toBe(false);
610
+ });
611
+
612
+ it("passes autoStartCoordinator=false when LEGIO_SERVER_DAEMON=0", async () => {
613
+ process.env.LEGIO_SERVER_DAEMON = "0";
614
+
615
+ let capturedOpts: { autoStartCoordinator: boolean } | undefined;
616
+ const deps: ServerDeps = {
617
+ _startServer: async (opts) => {
618
+ capturedOpts = opts;
619
+ },
620
+ };
621
+
622
+ await serverCommand(["start"], deps);
623
+
624
+ expect(capturedOpts?.autoStartCoordinator).toBe(false);
625
+ });
626
+ });