@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,597 @@
1
+ /**
2
+ * Tests for legio up command.
3
+ *
4
+ * Uses DI (UpDeps) to inject mock subprocess calls, filesystem checks,
5
+ * and PID reads. No real init/server/coordinator in tests.
6
+ *
7
+ * WHY DI instead of mock.module: mock.module() in vitest is process-global
8
+ * and leaks across test files. DI keeps mocks scoped to each test invocation.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { ServerError, ValidationError } from "../errors.ts";
13
+ import type { UpDeps } from "./up.ts";
14
+ import { upCommand } from "./up.ts";
15
+
16
+ /** Builds a mock runCommand with configurable results per command prefix. */
17
+ function makeRunCommand(
18
+ responses: Record<string, { stdout: string; stderr: string; exitCode: number }>,
19
+ ): NonNullable<UpDeps["_runCommand"]> {
20
+ return async (cmd) => {
21
+ const key = cmd.join(" ");
22
+ // Find matching prefix
23
+ for (const [prefix, result] of Object.entries(responses)) {
24
+ if (key.startsWith(prefix)) return result;
25
+ }
26
+ return { stdout: "", stderr: `Unexpected command: ${key}`, exitCode: 1 };
27
+ };
28
+ }
29
+
30
+ /** Standard successful subprocess responses. */
31
+ const GIT_OK = { stdout: "true\n", stderr: "", exitCode: 0 };
32
+ const INIT_OK = { stdout: "Initialized .legio/\n", stderr: "", exitCode: 0 };
33
+ const SERVER_START_OK = { stdout: "Server started\n", stderr: "", exitCode: 0 };
34
+ const GATEWAY_STATUS_NOT_RUNNING = {
35
+ stdout: JSON.stringify({ running: false }),
36
+ stderr: "",
37
+ exitCode: 0,
38
+ };
39
+ const GATEWAY_START_OK = { stdout: "Gateway started\n", stderr: "", exitCode: 0 };
40
+
41
+ describe("upCommand", () => {
42
+ let capturedStdout: string;
43
+ let _capturedStderr: string;
44
+ let originalStdout: typeof process.stdout.write;
45
+ let originalStderr: typeof process.stderr.write;
46
+
47
+ beforeEach(() => {
48
+ capturedStdout = "";
49
+ _capturedStderr = "";
50
+ originalStdout = process.stdout.write;
51
+ originalStderr = process.stderr.write;
52
+ process.stdout.write = vi.fn((chunk: unknown) => {
53
+ capturedStdout += String(chunk);
54
+ return true;
55
+ }) as typeof process.stdout.write;
56
+ process.stderr.write = vi.fn((chunk: unknown) => {
57
+ _capturedStderr += String(chunk);
58
+ return true;
59
+ }) as typeof process.stderr.write;
60
+ });
61
+
62
+ afterEach(() => {
63
+ process.stdout.write = originalStdout;
64
+ process.stderr.write = originalStderr;
65
+ });
66
+
67
+ it("prints help for --help", async () => {
68
+ await upCommand(["--help"]);
69
+ expect(capturedStdout).toContain("legio up");
70
+ expect(capturedStdout).toContain("--port");
71
+ expect(capturedStdout).toContain("--no-open");
72
+ });
73
+
74
+ it("prints help for -h", async () => {
75
+ await upCommand(["-h"]);
76
+ expect(capturedStdout).toContain("legio up");
77
+ });
78
+
79
+ it("throws ValidationError when not in a git repo", async () => {
80
+ const deps: UpDeps = {
81
+ _runCommand: makeRunCommand({
82
+ "git rev-parse": { stdout: "", stderr: "not a git repo", exitCode: 128 },
83
+ }),
84
+ _fileExists: async () => false,
85
+ _projectRoot: "/tmp/not-a-repo",
86
+ };
87
+ await expect(upCommand([], deps)).rejects.toThrow(ValidationError);
88
+ });
89
+
90
+ it("throws ValidationError for invalid port", async () => {
91
+ await expect(upCommand(["--port", "99999"])).rejects.toThrow(ValidationError);
92
+ await expect(upCommand(["--port", "abc"])).rejects.toThrow(ValidationError);
93
+ await expect(upCommand(["--port", "0"])).rejects.toThrow(ValidationError);
94
+ });
95
+
96
+ it("runs init when .legio/ not initialized", async () => {
97
+ const commands: string[][] = [];
98
+ const deps: UpDeps = {
99
+ _runCommand: async (cmd) => {
100
+ commands.push(cmd);
101
+ if (cmd[0] === "git") return GIT_OK;
102
+ if (cmd[0] === "legio" && cmd[1] === "init") return INIT_OK;
103
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
104
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
105
+ return GATEWAY_STATUS_NOT_RUNNING;
106
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
107
+ return GATEWAY_START_OK;
108
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
109
+ },
110
+ _fileExists: async () => false, // config.yaml not found, no server.pid
111
+ _readPid: async () => null,
112
+ _isProcessRunning: () => false,
113
+ _openBrowser: () => {},
114
+ _projectRoot: "/tmp/test-project",
115
+ };
116
+
117
+ await upCommand([], deps);
118
+
119
+ const ranInit = commands.some((c) => c[0] === "legio" && c[1] === "init");
120
+ expect(ranInit).toBe(true);
121
+ // Should NOT have --force
122
+ const initCmd = commands.find((c) => c[0] === "legio" && c[1] === "init");
123
+ expect(initCmd).not.toContain("--force");
124
+ });
125
+
126
+ it("skips init when already initialized", async () => {
127
+ const commands: string[][] = [];
128
+ const deps: UpDeps = {
129
+ _runCommand: async (cmd) => {
130
+ commands.push(cmd);
131
+ if (cmd[0] === "git") return GIT_OK;
132
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
133
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
134
+ return GATEWAY_STATUS_NOT_RUNNING;
135
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
136
+ return GATEWAY_START_OK;
137
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
138
+ },
139
+ _fileExists: async (p) => p.endsWith("config.yaml"), // config exists, no server.pid
140
+ _readPid: async () => null,
141
+ _isProcessRunning: () => false,
142
+ _openBrowser: () => {},
143
+ _projectRoot: "/tmp/test-project",
144
+ };
145
+
146
+ await upCommand([], deps);
147
+
148
+ const ranInit = commands.some((c) => c[0] === "legio" && c[1] === "init");
149
+ expect(ranInit).toBe(false);
150
+ });
151
+
152
+ it("runs init --force when --force flag and already initialized", async () => {
153
+ const commands: string[][] = [];
154
+ const deps: UpDeps = {
155
+ _runCommand: async (cmd) => {
156
+ commands.push(cmd);
157
+ if (cmd[0] === "git") return GIT_OK;
158
+ if (cmd[0] === "legio" && cmd[1] === "init") return INIT_OK;
159
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
160
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
161
+ return GATEWAY_STATUS_NOT_RUNNING;
162
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
163
+ return GATEWAY_START_OK;
164
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
165
+ },
166
+ _fileExists: async (p) => p.endsWith("config.yaml"), // initialized, no server.pid
167
+ _readPid: async () => null,
168
+ _isProcessRunning: () => false,
169
+ _openBrowser: () => {},
170
+ _projectRoot: "/tmp/test-project",
171
+ };
172
+
173
+ await upCommand(["--force"], deps);
174
+
175
+ const initCmd = commands.find((c) => c[0] === "legio" && c[1] === "init");
176
+ expect(initCmd).toContain("--force");
177
+ });
178
+
179
+ it("skips server start when server already running", async () => {
180
+ const commands: string[][] = [];
181
+ const deps: UpDeps = {
182
+ _runCommand: async (cmd) => {
183
+ commands.push(cmd);
184
+ if (cmd[0] === "git") return GIT_OK;
185
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
186
+ return GATEWAY_STATUS_NOT_RUNNING;
187
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
188
+ return GATEWAY_START_OK;
189
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
190
+ },
191
+ _fileExists: async () => true, // config.yaml exists AND server.pid exists
192
+ _readPid: async () => 12345,
193
+ _isProcessRunning: () => true, // server is alive
194
+ _openBrowser: () => {},
195
+ _projectRoot: "/tmp/test-project",
196
+ };
197
+
198
+ await upCommand([], deps);
199
+
200
+ const serverStarted = commands.some(
201
+ (c) => c[0] === "legio" && c[1] === "server" && c[2] === "start",
202
+ );
203
+ expect(serverStarted).toBe(false);
204
+ expect(capturedStdout).toContain("already running");
205
+ expect(capturedStdout).toContain("12345");
206
+ });
207
+
208
+ it("starts server when not running (dead PID)", async () => {
209
+ const commands: string[][] = [];
210
+ const deps: UpDeps = {
211
+ _runCommand: async (cmd) => {
212
+ commands.push(cmd);
213
+ if (cmd[0] === "git") return GIT_OK;
214
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
215
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
216
+ return GATEWAY_STATUS_NOT_RUNNING;
217
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
218
+ return GATEWAY_START_OK;
219
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
220
+ },
221
+ _fileExists: async (p) => p.endsWith("config.yaml") || p.endsWith("server.pid"),
222
+ _readPid: async () => 12345,
223
+ _isProcessRunning: () => false, // PID exists but process is dead
224
+ _openBrowser: () => {},
225
+ _projectRoot: "/tmp/test-project",
226
+ };
227
+
228
+ await upCommand([], deps);
229
+
230
+ const serverStarted = commands.some(
231
+ (c) => c[0] === "legio" && c[1] === "server" && c[2] === "start",
232
+ );
233
+ expect(serverStarted).toBe(true);
234
+ });
235
+
236
+ it("starts server when no PID file", async () => {
237
+ const commands: string[][] = [];
238
+ const deps: UpDeps = {
239
+ _runCommand: async (cmd) => {
240
+ commands.push(cmd);
241
+ if (cmd[0] === "git") return GIT_OK;
242
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
243
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
244
+ return GATEWAY_STATUS_NOT_RUNNING;
245
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
246
+ return GATEWAY_START_OK;
247
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
248
+ },
249
+ _fileExists: async (p) => p.endsWith("config.yaml"), // no server.pid
250
+ _readPid: async () => null,
251
+ _isProcessRunning: () => false,
252
+ _openBrowser: () => {},
253
+ _projectRoot: "/tmp/test-project",
254
+ };
255
+
256
+ await upCommand([], deps);
257
+
258
+ const serverCmd = commands.find(
259
+ (c) => c[0] === "legio" && c[1] === "server" && c[2] === "start",
260
+ );
261
+ expect(serverCmd).toBeDefined();
262
+ // Verify default port and host
263
+ expect(serverCmd).toContain("4173");
264
+ expect(serverCmd).toContain("127.0.0.1");
265
+ });
266
+
267
+ it("passes custom port and host to server start", async () => {
268
+ const commands: string[][] = [];
269
+ const deps: UpDeps = {
270
+ _runCommand: async (cmd) => {
271
+ commands.push(cmd);
272
+ if (cmd[0] === "git") return GIT_OK;
273
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
274
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
275
+ return GATEWAY_STATUS_NOT_RUNNING;
276
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
277
+ return GATEWAY_START_OK;
278
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
279
+ },
280
+ _fileExists: async (p) => p.endsWith("config.yaml"),
281
+ _readPid: async () => null,
282
+ _isProcessRunning: () => false,
283
+ _openBrowser: () => {},
284
+ _projectRoot: "/tmp/test-project",
285
+ };
286
+
287
+ await upCommand(["--port", "8080", "--host", "0.0.0.0"], deps);
288
+
289
+ const serverCmd = commands.find(
290
+ (c) => c[0] === "legio" && c[1] === "server" && c[2] === "start",
291
+ );
292
+ expect(serverCmd).toContain("8080");
293
+ expect(serverCmd).toContain("0.0.0.0");
294
+ expect(serverCmd).toContain("--daemon");
295
+ });
296
+
297
+ it("does not open browser when --no-open is set", async () => {
298
+ let browserOpened = false;
299
+ const deps: UpDeps = {
300
+ _runCommand: async (cmd) => {
301
+ if (cmd[0] === "git") return GIT_OK;
302
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
303
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
304
+ return GATEWAY_STATUS_NOT_RUNNING;
305
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
306
+ return GATEWAY_START_OK;
307
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
308
+ },
309
+ _fileExists: async (p) => p.endsWith("config.yaml"),
310
+ _readPid: async () => null,
311
+ _isProcessRunning: () => false,
312
+ _openBrowser: () => {
313
+ browserOpened = true;
314
+ },
315
+ _projectRoot: "/tmp/test-project",
316
+ };
317
+
318
+ await upCommand(["--no-open"], deps);
319
+ expect(browserOpened).toBe(false);
320
+ });
321
+
322
+ it("opens browser when server starts (default)", async () => {
323
+ let openedUrl = "";
324
+ const deps: UpDeps = {
325
+ _runCommand: async (cmd) => {
326
+ if (cmd[0] === "git") return GIT_OK;
327
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
328
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
329
+ return GATEWAY_STATUS_NOT_RUNNING;
330
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
331
+ return GATEWAY_START_OK;
332
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
333
+ },
334
+ _fileExists: async (p) => p.endsWith("config.yaml"),
335
+ _readPid: async () => null,
336
+ _isProcessRunning: () => false,
337
+ _openBrowser: (url) => {
338
+ openedUrl = url;
339
+ },
340
+ _projectRoot: "/tmp/test-project",
341
+ };
342
+
343
+ await upCommand([], deps);
344
+ expect(openedUrl).toBe("http://127.0.0.1:4173");
345
+ });
346
+
347
+ it("throws ServerError when server start fails", async () => {
348
+ const deps: UpDeps = {
349
+ _runCommand: async (cmd) => {
350
+ if (cmd[0] === "git") return GIT_OK;
351
+ if (cmd[0] === "legio" && cmd[1] === "server") {
352
+ return { stdout: "", stderr: "port already in use", exitCode: 1 };
353
+ }
354
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
355
+ },
356
+ _fileExists: async (p) => p.endsWith("config.yaml"),
357
+ _readPid: async () => null,
358
+ _isProcessRunning: () => false,
359
+ _openBrowser: () => {},
360
+ _projectRoot: "/tmp/test-project",
361
+ };
362
+
363
+ await expect(upCommand([], deps)).rejects.toThrow(ServerError);
364
+ });
365
+
366
+ it("throws ValidationError when init fails", async () => {
367
+ const deps: UpDeps = {
368
+ _runCommand: async (cmd) => {
369
+ if (cmd[0] === "git") return GIT_OK;
370
+ if (cmd[0] === "legio" && cmd[1] === "init") {
371
+ return { stdout: "", stderr: "init failed", exitCode: 1 };
372
+ }
373
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
374
+ },
375
+ _fileExists: async () => false, // not initialized
376
+ _readPid: async () => null,
377
+ _isProcessRunning: () => false,
378
+ _openBrowser: () => {},
379
+ _projectRoot: "/tmp/test-project",
380
+ };
381
+
382
+ await expect(upCommand([], deps)).rejects.toThrow(ValidationError);
383
+ });
384
+
385
+ it("outputs JSON when --json is passed", async () => {
386
+ const deps: UpDeps = {
387
+ _runCommand: async (cmd) => {
388
+ if (cmd[0] === "git") return GIT_OK;
389
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
390
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
391
+ return GATEWAY_STATUS_NOT_RUNNING;
392
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
393
+ return GATEWAY_START_OK;
394
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
395
+ },
396
+ _fileExists: async (p) => p.endsWith("config.yaml"),
397
+ _readPid: async () => null,
398
+ _isProcessRunning: () => false,
399
+ _openBrowser: () => {},
400
+ _projectRoot: "/tmp/test-project",
401
+ };
402
+
403
+ await upCommand(["--json"], deps);
404
+
405
+ const parsed = JSON.parse(capturedStdout.trim()) as {
406
+ url: string;
407
+ initRan: boolean;
408
+ serverStarted: boolean;
409
+ serverAlreadyRunning: boolean;
410
+ gatewayStarted: boolean;
411
+ gatewayAlreadyRunning: boolean;
412
+ };
413
+ expect(parsed.url).toBe("http://127.0.0.1:4173");
414
+ expect(parsed.serverStarted).toBe(true);
415
+ expect(parsed.initRan).toBe(false);
416
+ expect(parsed.serverAlreadyRunning).toBe(false);
417
+ expect(parsed.gatewayStarted).toBe(true);
418
+ expect(parsed.gatewayAlreadyRunning).toBe(false);
419
+ });
420
+
421
+ it("JSON output shows serverAlreadyRunning when server is up", async () => {
422
+ const deps: UpDeps = {
423
+ _runCommand: async (cmd) => {
424
+ if (cmd[0] === "git") return GIT_OK;
425
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
426
+ return GATEWAY_STATUS_NOT_RUNNING;
427
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
428
+ return GATEWAY_START_OK;
429
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
430
+ },
431
+ _fileExists: async () => true,
432
+ _readPid: async () => 99,
433
+ _isProcessRunning: () => true,
434
+ _openBrowser: () => {},
435
+ _projectRoot: "/tmp/test-project",
436
+ };
437
+
438
+ await upCommand(["--json"], deps);
439
+
440
+ const parsed = JSON.parse(capturedStdout.trim()) as {
441
+ serverAlreadyRunning: boolean;
442
+ serverStarted: boolean;
443
+ };
444
+ expect(parsed.serverAlreadyRunning).toBe(true);
445
+ expect(parsed.serverStarted).toBe(false);
446
+ });
447
+
448
+ it("prints URL summary when server starts", async () => {
449
+ const deps: UpDeps = {
450
+ _runCommand: async (cmd) => {
451
+ if (cmd[0] === "git") return GIT_OK;
452
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
453
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
454
+ return GATEWAY_STATUS_NOT_RUNNING;
455
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
456
+ return GATEWAY_START_OK;
457
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
458
+ },
459
+ _fileExists: async (p) => p.endsWith("config.yaml"),
460
+ _readPid: async () => null,
461
+ _isProcessRunning: () => false,
462
+ _openBrowser: () => {},
463
+ _projectRoot: "/tmp/test-project",
464
+ };
465
+
466
+ await upCommand(["--port", "5000"], deps);
467
+ expect(capturedStdout).toContain("http://127.0.0.1:5000");
468
+ });
469
+
470
+ it("starts gateway when not already running", async () => {
471
+ const commands: string[][] = [];
472
+ const deps: UpDeps = {
473
+ _runCommand: async (cmd) => {
474
+ commands.push(cmd);
475
+ if (cmd[0] === "git") return GIT_OK;
476
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
477
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
478
+ return GATEWAY_STATUS_NOT_RUNNING;
479
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
480
+ return GATEWAY_START_OK;
481
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
482
+ },
483
+ _fileExists: async (p) => p.endsWith("config.yaml"),
484
+ _readPid: async () => null,
485
+ _isProcessRunning: () => false,
486
+ _openBrowser: () => {},
487
+ _projectRoot: "/tmp/test-project",
488
+ };
489
+
490
+ await upCommand(["--json"], deps);
491
+
492
+ const gatewayStartCalled = commands.some(
493
+ (c) =>
494
+ c[0] === "legio" && c[1] === "gateway" && c[2] === "start" && c.includes("--no-attach"),
495
+ );
496
+ expect(gatewayStartCalled).toBe(true);
497
+
498
+ const parsed = JSON.parse(capturedStdout.trim()) as {
499
+ gatewayStarted: boolean;
500
+ gatewayAlreadyRunning: boolean;
501
+ };
502
+ expect(parsed.gatewayStarted).toBe(true);
503
+ expect(parsed.gatewayAlreadyRunning).toBe(false);
504
+ });
505
+
506
+ it("skips gateway start when already running", async () => {
507
+ const commands: string[][] = [];
508
+ const deps: UpDeps = {
509
+ _runCommand: async (cmd) => {
510
+ commands.push(cmd);
511
+ if (cmd[0] === "git") return GIT_OK;
512
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
513
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status") {
514
+ return { stdout: JSON.stringify({ running: true }), stderr: "", exitCode: 0 };
515
+ }
516
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
517
+ },
518
+ _fileExists: async (p) => p.endsWith("config.yaml"),
519
+ _readPid: async () => null,
520
+ _isProcessRunning: () => false,
521
+ _openBrowser: () => {},
522
+ _projectRoot: "/tmp/test-project",
523
+ };
524
+
525
+ await upCommand(["--json"], deps);
526
+
527
+ const gatewayStartCalled = commands.some(
528
+ (c) => c[0] === "legio" && c[1] === "gateway" && c[2] === "start",
529
+ );
530
+ expect(gatewayStartCalled).toBe(false);
531
+
532
+ const parsed = JSON.parse(capturedStdout.trim()) as {
533
+ gatewayStarted: boolean;
534
+ gatewayAlreadyRunning: boolean;
535
+ };
536
+ expect(parsed.gatewayStarted).toBe(false);
537
+ expect(parsed.gatewayAlreadyRunning).toBe(true);
538
+ });
539
+
540
+ it("continues when gateway start fails (non-fatal)", async () => {
541
+ const deps: UpDeps = {
542
+ _runCommand: async (cmd) => {
543
+ if (cmd[0] === "git") return GIT_OK;
544
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
545
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status")
546
+ return GATEWAY_STATUS_NOT_RUNNING;
547
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start") {
548
+ return { stdout: "", stderr: "gateway failed", exitCode: 1 };
549
+ }
550
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
551
+ },
552
+ _fileExists: async (p) => p.endsWith("config.yaml"),
553
+ _readPid: async () => null,
554
+ _isProcessRunning: () => false,
555
+ _openBrowser: () => {},
556
+ _projectRoot: "/tmp/test-project",
557
+ };
558
+
559
+ await expect(upCommand(["--json"], deps)).resolves.toBeUndefined();
560
+
561
+ const parsed = JSON.parse(capturedStdout.trim()) as {
562
+ gatewayStarted: boolean;
563
+ gatewayAlreadyRunning: boolean;
564
+ };
565
+ expect(parsed.gatewayStarted).toBe(false);
566
+ expect(parsed.gatewayAlreadyRunning).toBe(false);
567
+ });
568
+
569
+ it("continues when gateway status check fails", async () => {
570
+ const commands: string[][] = [];
571
+ const deps: UpDeps = {
572
+ _runCommand: async (cmd) => {
573
+ commands.push(cmd);
574
+ if (cmd[0] === "git") return GIT_OK;
575
+ if (cmd[0] === "legio" && cmd[1] === "server") return SERVER_START_OK;
576
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "status") {
577
+ return { stdout: "", stderr: "status failed", exitCode: 1 };
578
+ }
579
+ if (cmd[0] === "legio" && cmd[1] === "gateway" && cmd[2] === "start")
580
+ return GATEWAY_START_OK;
581
+ return { stdout: "", stderr: "unexpected", exitCode: 1 };
582
+ },
583
+ _fileExists: async (p) => p.endsWith("config.yaml"),
584
+ _readPid: async () => null,
585
+ _isProcessRunning: () => false,
586
+ _openBrowser: () => {},
587
+ _projectRoot: "/tmp/test-project",
588
+ };
589
+
590
+ await expect(upCommand([], deps)).resolves.toBeUndefined();
591
+
592
+ const gatewayStartCalled = commands.some(
593
+ (c) => c[0] === "legio" && c[1] === "gateway" && c[2] === "start",
594
+ );
595
+ expect(gatewayStartCalled).toBe(true);
596
+ });
597
+ });