@marcusrbrown/infra 0.13.1 → 0.13.3

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 (108) hide show
  1. package/dist/cli.js +11965 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,755 +0,0 @@
1
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
-
3
- import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
4
- import {gatewayStatusAction, parseComposePs, parseComposePsOutput, type ComposePsEntry, type ServiceRow} from './status'
5
-
6
- // ─── parseComposePs ──────────────────────────────────────────────────────────
7
-
8
- describe('parseComposePs', () => {
9
- it('parses all 3 services running with gateway and mitmproxy healthy, workspace n-a', () => {
10
- const raw: ComposePsEntry[] = [
11
- {Name: 'gateway', State: 'running', Health: 'healthy'},
12
- {Name: 'workspace', State: 'running', Health: ''},
13
- {Name: 'mitmproxy', State: 'running', Health: 'healthy'},
14
- ]
15
-
16
- const rows = parseComposePs(raw)
17
-
18
- expect(rows).toHaveLength(3)
19
- expect(rows[0]).toEqual({service: 'gateway', state: 'running', health: 'healthy'} satisfies ServiceRow)
20
- expect(rows[1]).toEqual({service: 'workspace', state: 'running', health: 'n-a'} satisfies ServiceRow)
21
- expect(rows[2]).toEqual({service: 'mitmproxy', state: 'running', health: 'healthy'} satisfies ServiceRow)
22
- })
23
-
24
- it('shows exited state when a container has exited', () => {
25
- const raw: ComposePsEntry[] = [
26
- {Name: 'gateway', State: 'exited', Health: ''},
27
- {Name: 'workspace', State: 'running', Health: ''},
28
- {Name: 'mitmproxy', State: 'running', Health: 'healthy'},
29
- ]
30
-
31
- const rows = parseComposePs(raw)
32
-
33
- expect(rows[0]).toEqual({service: 'gateway', state: 'exited', health: 'n-a'} satisfies ServiceRow)
34
- })
35
-
36
- it('maps unhealthy health status', () => {
37
- const raw: ComposePsEntry[] = [{Name: 'gateway', State: 'running', Health: 'unhealthy'}]
38
-
39
- const rows = parseComposePs(raw)
40
-
41
- expect(rows[0]?.health).toBe('unhealthy')
42
- })
43
-
44
- it('maps starting health status', () => {
45
- const raw: ComposePsEntry[] = [{Name: 'gateway', State: 'running', Health: 'starting'}]
46
-
47
- const rows = parseComposePs(raw)
48
-
49
- expect(rows[0]?.health).toBe('starting')
50
- })
51
-
52
- it('returns n-a for unknown health values', () => {
53
- const raw: ComposePsEntry[] = [{Name: 'gateway', State: 'running', Health: 'something-weird'}]
54
-
55
- const rows = parseComposePs(raw)
56
-
57
- expect(rows[0]?.health).toBe('n-a')
58
- })
59
-
60
- it('returns empty array for empty input', () => {
61
- expect(parseComposePs([])).toEqual([])
62
- })
63
- })
64
-
65
- // ─── isAllRunning ─────────────────────────────────────────────────────────────
66
-
67
- describe('isAllRunning', () => {
68
- it('returns true when all services are running', async () => {
69
- const {isAllRunning} = await import('./status')
70
- const rows: ServiceRow[] = [
71
- {service: 'gateway', state: 'running', health: 'healthy'},
72
- {service: 'workspace', state: 'running', health: 'n-a'},
73
- {service: 'mitmproxy', state: 'running', health: 'healthy'},
74
- ]
75
-
76
- expect(isAllRunning(rows)).toBe(true)
77
- })
78
-
79
- it('returns false when any service is not running', async () => {
80
- const {isAllRunning} = await import('./status')
81
- const rows: ServiceRow[] = [
82
- {service: 'gateway', state: 'exited', health: 'n-a'},
83
- {service: 'workspace', state: 'running', health: 'n-a'},
84
- {service: 'mitmproxy', state: 'running', health: 'healthy'},
85
- ]
86
-
87
- expect(isAllRunning(rows)).toBe(false)
88
- })
89
- })
90
-
91
- // ─── getGatewayComposeStatus (SSH mocked) ────────────────────────────────────
92
-
93
- type SpawnFn = (
94
- cmd: string[],
95
- opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
96
- ) => {
97
- stdout: ReadableStream<Uint8Array>
98
- stderr: ReadableStream<Uint8Array>
99
- exited: Promise<number>
100
- }
101
-
102
- function makeSpawnOk(jsonOutput: string): SpawnFn {
103
- return (_cmd, _opts) => {
104
- const encoder = new TextEncoder()
105
- return {
106
- stdout: new ReadableStream({
107
- start(controller) {
108
- controller.enqueue(encoder.encode(jsonOutput))
109
- controller.close()
110
- },
111
- }),
112
- stderr: new ReadableStream({
113
- start(controller) {
114
- controller.close()
115
- },
116
- }),
117
- exited: Promise.resolve(0),
118
- }
119
- }
120
- }
121
-
122
- function makeSpawnError(message: string): SpawnFn {
123
- return (_cmd, _opts) => {
124
- const encoder = new TextEncoder()
125
- return {
126
- stdout: new ReadableStream({
127
- start(controller) {
128
- controller.close()
129
- },
130
- }),
131
- stderr: new ReadableStream({
132
- start(controller) {
133
- controller.enqueue(encoder.encode(message))
134
- controller.close()
135
- },
136
- }),
137
- exited: Promise.resolve(1),
138
- }
139
- }
140
- }
141
-
142
- // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
143
-
144
- describe('getGatewayComposeStatus — SSH command includes UserKnownHostsFile', () => {
145
- it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
146
- const {getGatewayComposeStatus} = await import('./status')
147
- let capturedCmd: string[] = []
148
-
149
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
150
- capturedCmd = cmd
151
- const encoder = new TextEncoder()
152
- return {
153
- stdout: new ReadableStream({
154
- start(controller) {
155
- controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
156
- controller.close()
157
- },
158
- }),
159
- stderr: new ReadableStream({
160
- start(c) {
161
- c.close()
162
- },
163
- }),
164
- exited: Promise.resolve(0),
165
- }
166
- }
167
-
168
- await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
169
-
170
- const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
171
- expect(knownHostsIdx).toBeGreaterThan(-1)
172
- expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
173
- expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
174
- })
175
-
176
- it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
177
- const {getGatewayComposeStatus} = await import('./status')
178
- let capturedCmd: string[] = []
179
-
180
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
181
- capturedCmd = cmd
182
- const encoder = new TextEncoder()
183
- return {
184
- stdout: new ReadableStream({
185
- start(controller) {
186
- controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
187
- controller.close()
188
- },
189
- }),
190
- stderr: new ReadableStream({
191
- start(c) {
192
- c.close()
193
- },
194
- }),
195
- exited: Promise.resolve(0),
196
- }
197
- }
198
-
199
- await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
200
-
201
- const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
202
- expect(strictIdx).toBeGreaterThan(-1)
203
- expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
204
- })
205
- })
206
-
207
- describe('getGatewayComposeStatus — host validation (SEC1)', () => {
208
- it('rejects a leading-hyphen host and does not invoke ssh', async () => {
209
- const {getGatewayComposeStatus} = await import('./status')
210
-
211
- const neverSpawn: SpawnFn = () => {
212
- throw new Error('spawn must not be called for an invalid host')
213
- }
214
-
215
- await expect(getGatewayComposeStatus('-oProxyCommand=evil', neverSpawn)).rejects.toThrow('Invalid GATEWAY_HOST')
216
- })
217
-
218
- it('rejects a host with shell metacharacters and does not invoke ssh', async () => {
219
- const {getGatewayComposeStatus} = await import('./status')
220
-
221
- const neverSpawn: SpawnFn = () => {
222
- throw new Error('spawn must not be called for an invalid host')
223
- }
224
-
225
- await expect(getGatewayComposeStatus('gateway.example.com;rm -rf', neverSpawn)).rejects.toThrow(
226
- 'Invalid GATEWAY_HOST',
227
- )
228
- })
229
-
230
- it('rejects an empty host and does not invoke ssh', async () => {
231
- const {getGatewayComposeStatus} = await import('./status')
232
-
233
- const neverSpawn: SpawnFn = () => {
234
- throw new Error('spawn must not be called for an invalid host')
235
- }
236
-
237
- await expect(getGatewayComposeStatus('', neverSpawn)).rejects.toThrow('Invalid GATEWAY_HOST')
238
- })
239
-
240
- it('accepts a valid FQDN and invokes ssh normally', async () => {
241
- const {getGatewayComposeStatus} = await import('./status')
242
-
243
- const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
244
- const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(psOutput))
245
-
246
- expect(result.ok).toBe(true)
247
- })
248
-
249
- it('accepts localhost as a valid host', async () => {
250
- const {getGatewayComposeStatus} = await import('./status')
251
-
252
- const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
253
- const result = await getGatewayComposeStatus('localhost', makeSpawnOk(psOutput))
254
-
255
- expect(result.ok).toBe(true)
256
- })
257
-
258
- it('accepts an IPv4 address as a valid host', async () => {
259
- const {getGatewayComposeStatus} = await import('./status')
260
-
261
- const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
262
- const result = await getGatewayComposeStatus('147.182.133.210', makeSpawnOk(psOutput))
263
-
264
- expect(result.ok).toBe(true)
265
- })
266
- })
267
-
268
- describe('getGatewayComposeStatus', () => {
269
- let originalEnv: Record<string, string | undefined>
270
-
271
- beforeEach(() => {
272
- originalEnv = {GATEWAY_HOST: process.env.GATEWAY_HOST}
273
- process.env.GATEWAY_HOST = 'gateway.example.com'
274
- })
275
-
276
- afterEach(() => {
277
- if (originalEnv.GATEWAY_HOST === undefined) {
278
- delete process.env.GATEWAY_HOST
279
- } else {
280
- process.env.GATEWAY_HOST = originalEnv.GATEWAY_HOST
281
- }
282
- })
283
-
284
- it('returns ok=true with 3 service rows when all services are running', async () => {
285
- const {getGatewayComposeStatus} = await import('./status')
286
-
287
- const psOutput = JSON.stringify([
288
- {Name: 'gateway', State: 'running', Health: 'healthy'},
289
- {Name: 'workspace', State: 'running', Health: ''},
290
- {Name: 'mitmproxy', State: 'running', Health: 'healthy'},
291
- ])
292
-
293
- const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(psOutput))
294
-
295
- expect(result.ok).toBe(true)
296
- expect(result.services).toHaveLength(3)
297
- expect(result.services[0]).toEqual({service: 'gateway', state: 'running', health: 'healthy'})
298
- expect(result.services[1]).toEqual({service: 'workspace', state: 'running', health: 'n-a'})
299
- expect(result.services[2]).toEqual({service: 'mitmproxy', state: 'running', health: 'healthy'})
300
- })
301
-
302
- it('returns ok=false when gateway service is exited', async () => {
303
- const {getGatewayComposeStatus} = await import('./status')
304
-
305
- const psOutput = JSON.stringify([
306
- {Name: 'gateway', State: 'exited', Health: ''},
307
- {Name: 'workspace', State: 'running', Health: ''},
308
- {Name: 'mitmproxy', State: 'running', Health: 'healthy'},
309
- ])
310
-
311
- const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(psOutput))
312
-
313
- expect(result.ok).toBe(false)
314
- expect(result.services[0]?.state).toBe('exited')
315
- })
316
-
317
- it('returns ok=false with error when SSH fails', async () => {
318
- const {getGatewayComposeStatus} = await import('./status')
319
-
320
- const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnError('Connection refused'))
321
-
322
- expect(result.ok).toBe(false)
323
- expect(result.error).toContain('SSH')
324
- })
325
- })
326
-
327
- // ─── parseComposePsOutput ─────────────────────────────────────────────────────
328
-
329
- // Realistic fixture shape matching live droplet output
330
- const ndjsonFixture = [
331
- String.raw`{"Command":"\"docker-entrypoint.sh\"","CreatedAt":"2026-05-20 08:20:18 +0000 UTC","ExitCode":0,"Health":"healthy","ID":"01e1a16f5752","Image":"fro-bot-gateway","Labels":"","LocalVolumes":"0","Mounts":"","Name":"fro-bot-gateway-1","Names":"fro-bot-gateway-1","Networks":"gateway_default","Ports":"","Project":"gateway","Publishers":null,"RunningFor":"2 hours ago","Service":"gateway","Size":"0B","State":"running","Status":"Up 2 hours (healthy)"}`,
332
- String.raw`{"Command":"\"mitmproxy\"","CreatedAt":"2026-05-20 08:20:18 +0000 UTC","ExitCode":0,"Health":"healthy","ID":"02b2c27f6863","Image":"fro-bot-mitmproxy","Labels":"","LocalVolumes":"0","Mounts":"","Name":"fro-bot-mitmproxy-1","Names":"fro-bot-mitmproxy-1","Networks":"gateway_default","Ports":"","Project":"gateway","Publishers":null,"RunningFor":"2 hours ago","Service":"mitmproxy","Size":"0B","State":"running","Status":"Up 2 hours (healthy)"}`,
333
- String.raw`{"Command":"\"sleep infinity\"","CreatedAt":"2026-05-20 08:20:18 +0000 UTC","ExitCode":0,"Health":"","ID":"03c3d38g7974","Image":"fro-bot-workspace","Labels":"","LocalVolumes":"0","Mounts":"","Name":"fro-bot-workspace-1","Names":"fro-bot-workspace-1","Networks":"gateway_default","Ports":"","Project":"gateway","Publishers":null,"RunningFor":"2 hours ago","Service":"workspace","Size":"0B","State":"running","Status":"Up 2 hours"}`,
334
- ]
335
-
336
- describe('parseComposePsOutput', () => {
337
- it('parses NDJSON with 3 lines into 3 entries with correct Name/State/Health', () => {
338
- const raw = ndjsonFixture.join('\n')
339
- const entries = parseComposePsOutput(raw)
340
-
341
- expect(entries).toHaveLength(3)
342
- expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
343
- expect(entries[0]?.State).toBe('running')
344
- expect(entries[0]?.Health).toBe('healthy')
345
- expect(entries[1]?.Name).toBe('fro-bot-mitmproxy-1')
346
- expect(entries[1]?.State).toBe('running')
347
- expect(entries[1]?.Health).toBe('healthy')
348
- expect(entries[2]?.Name).toBe('fro-bot-workspace-1')
349
- expect(entries[2]?.State).toBe('running')
350
- expect(entries[2]?.Health).toBe('')
351
- })
352
-
353
- it('parses legacy single JSON array format', () => {
354
- const raw = JSON.stringify([
355
- {Name: 'fro-bot-gateway-1', State: 'running', Health: 'healthy'},
356
- {Name: 'fro-bot-mitmproxy-1', State: 'running', Health: 'healthy'},
357
- ])
358
- const entries = parseComposePsOutput(raw)
359
-
360
- expect(entries).toHaveLength(2)
361
- expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
362
- expect(entries[1]?.Name).toBe('fro-bot-mitmproxy-1')
363
- })
364
-
365
- it('returns empty array for empty string', () => {
366
- expect(parseComposePsOutput('')).toEqual([])
367
- })
368
-
369
- it('returns empty array for whitespace-only input', () => {
370
- expect(parseComposePsOutput(' \n \n ')).toEqual([])
371
- })
372
-
373
- it('parses a single NDJSON line', () => {
374
- const raw = ndjsonFixture.at(0) ?? ''
375
- const entries = parseComposePsOutput(raw)
376
-
377
- expect(entries).toHaveLength(1)
378
- expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
379
- })
380
-
381
- it('handles trailing newline correctly', () => {
382
- const raw = `${ndjsonFixture.join('\n')}\n`
383
- const entries = parseComposePsOutput(raw)
384
-
385
- expect(entries).toHaveLength(3)
386
- })
387
-
388
- it('throws on a malformed NDJSON line', () => {
389
- const raw = `${ndjsonFixture[0]}\nnot-valid-json\n${ndjsonFixture[2]}`
390
-
391
- expect(() => parseComposePsOutput(raw)).toThrow()
392
- })
393
- })
394
-
395
- // ─── getGatewayComposeStatus — NDJSON integration ────────────────────────────
396
-
397
- describe('getGatewayComposeStatus — NDJSON stdout', () => {
398
- it('parses NDJSON docker compose ps output and returns correct services', async () => {
399
- const {getGatewayComposeStatus} = await import('./status')
400
-
401
- const ndjsonOutput = ndjsonFixture.join('\n')
402
- const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(ndjsonOutput))
403
-
404
- expect(result.ok).toBe(true)
405
- expect(result.services).toHaveLength(3)
406
- expect(result.services[0]).toEqual({service: 'fro-bot-gateway-1', state: 'running', health: 'healthy'})
407
- expect(result.services[1]).toEqual({service: 'fro-bot-mitmproxy-1', state: 'running', health: 'healthy'})
408
- expect(result.services[2]).toEqual({service: 'fro-bot-workspace-1', state: 'running', health: 'n-a'})
409
- })
410
-
411
- it('returns ok=false with error message when NDJSON contains a malformed line', async () => {
412
- const {getGatewayComposeStatus} = await import('./status')
413
-
414
- const badOutput = `${ndjsonFixture[0]}\nnot-valid-json`
415
- const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(badOutput))
416
-
417
- expect(result.ok).toBe(false)
418
- expect(result.error).toContain('Failed to parse docker compose ps output')
419
- })
420
- })
421
-
422
- // ─── Tier-2: gatewayStatusAction ctx capture ─────────────────────────────────
423
-
424
- type StatusSpawnFn = (
425
- cmd: string[],
426
- opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
427
- ) => {
428
- stdout: ReadableStream<Uint8Array>
429
- stderr: ReadableStream<Uint8Array>
430
- exited: Promise<number>
431
- }
432
-
433
- function makeStatusSpawnOk(jsonOutput: string): StatusSpawnFn {
434
- return (_cmd, _opts) => {
435
- const encoder = new TextEncoder()
436
- return {
437
- stdout: new ReadableStream({
438
- start(controller) {
439
- controller.enqueue(encoder.encode(jsonOutput))
440
- controller.close()
441
- },
442
- }),
443
- stderr: new ReadableStream({
444
- start(controller) {
445
- controller.close()
446
- },
447
- }),
448
- exited: Promise.resolve(0),
449
- }
450
- }
451
- }
452
-
453
- function makeStatusSpawnError(message: string): StatusSpawnFn {
454
- return (_cmd, _opts) => {
455
- const encoder = new TextEncoder()
456
- return {
457
- stdout: new ReadableStream({
458
- start(controller) {
459
- controller.close()
460
- },
461
- }),
462
- stderr: new ReadableStream({
463
- start(controller) {
464
- controller.enqueue(encoder.encode(message))
465
- controller.close()
466
- },
467
- }),
468
- exited: Promise.resolve(1),
469
- }
470
- }
471
- }
472
-
473
- describe('gatewayStatusAction — ctx capture (Tier-2)', () => {
474
- let originalEnv: Record<string, string | undefined>
475
-
476
- beforeEach(() => {
477
- originalEnv = {GATEWAY_HOST: process.env.GATEWAY_HOST}
478
- process.env.GATEWAY_HOST = 'gateway.example.com'
479
- })
480
-
481
- afterEach(() => {
482
- if (originalEnv.GATEWAY_HOST === undefined) {
483
- delete process.env.GATEWAY_HOST
484
- } else {
485
- process.env.GATEWAY_HOST = originalEnv.GATEWAY_HOST
486
- }
487
- })
488
-
489
- it('routes "Status: OK" to ctx.console.log when all services are running', async () => {
490
- const {ctx, captured} = createCapturedCtx()
491
-
492
- const psOutput = JSON.stringify([
493
- {Name: 'gateway', State: 'running', Health: 'healthy'},
494
- {Name: 'mitmproxy', State: 'running', Health: 'healthy'},
495
- ])
496
-
497
- await gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnOk(psOutput))
498
-
499
- expect(expectCapturedToInclude(captured, 'Status: OK')).toBe(true)
500
- })
501
-
502
- it('routes "Gateway status" header to ctx.console.log', async () => {
503
- const {ctx, captured} = createCapturedCtx()
504
-
505
- const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
506
-
507
- await gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnOk(psOutput))
508
-
509
- expect(expectCapturedToInclude(captured, 'Gateway status')).toBe(true)
510
- })
511
-
512
- it('routes error to ctx.console.error and calls ctx.process.exit(1) when SSH fails', async () => {
513
- const {ctx, captured} = createCapturedCtx()
514
-
515
- await expect(
516
- gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnError('Connection refused')),
517
- ).rejects.toBeInstanceOf(MockProcessExit)
518
-
519
- expect(captured.stderr.join('').includes('Error')).toBe(true)
520
- expect(captured.exit?.code).toBe(1)
521
- })
522
-
523
- it('routes error to ctx.console.error and calls ctx.process.exit(1) when GATEWAY_HOST is unset', async () => {
524
- delete process.env.GATEWAY_HOST
525
- const {ctx, captured} = createCapturedCtx()
526
-
527
- await expect(gatewayStatusAction({key: undefined}, ctx)).rejects.toBeInstanceOf(MockProcessExit)
528
-
529
- expect(captured.stderr.join('').includes('GATEWAY_HOST')).toBe(true)
530
- expect(captured.exit?.code).toBe(1)
531
- })
532
-
533
- it('routes "Status: DEGRADED" to ctx.console.log and exits 1 when a service is not running', async () => {
534
- const {ctx, captured} = createCapturedCtx()
535
-
536
- const psOutput = JSON.stringify([
537
- {Name: 'gateway', State: 'exited', Health: ''},
538
- {Name: 'mitmproxy', State: 'running', Health: 'healthy'},
539
- ])
540
-
541
- await expect(gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnOk(psOutput))).rejects.toBeInstanceOf(
542
- MockProcessExit,
543
- )
544
-
545
- expect(expectCapturedToInclude(captured, 'DEGRADED')).toBe(true)
546
- expect(captured.exit?.code).toBe(1)
547
- })
548
- })
549
-
550
- // ─── SSH error redaction — host value must not leak in returned error strings ──
551
-
552
- describe('getGatewayComposeStatus — SSH error redaction', () => {
553
- it('does not include the resolved host value in the error when SSH fails', async () => {
554
- const {getGatewayComposeStatus} = await import('./status')
555
- const secretLookingHost = 'TOPSECRETHOSTNAME'
556
-
557
- const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
558
- const encoder = new TextEncoder()
559
- return {
560
- stdout: new ReadableStream({
561
- start(c) {
562
- c.enqueue(encoder.encode(''))
563
- c.close()
564
- },
565
- }),
566
- stderr: new ReadableStream({
567
- start(c) {
568
- c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
569
- c.close()
570
- },
571
- }),
572
- exited: Promise.resolve(255),
573
- }
574
- }
575
-
576
- const result = await getGatewayComposeStatus(secretLookingHost, mockSpawn)
577
-
578
- expect(result.ok).toBe(false)
579
- expect(result.error).toBeDefined()
580
- expect(result.error).not.toContain(secretLookingHost)
581
- })
582
-
583
- it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
584
- const {getGatewayComposeStatus} = await import('./status')
585
- // A misdirected secret-shaped value passes the hostname regex and becomes the host;
586
- // OpenSSH lowercases it in the "Could not resolve hostname" stderr line.
587
- const secretHost = 'AKIAIOSFODNN7EXAMPLE'
588
-
589
- const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
590
- const encoder = new TextEncoder()
591
- return {
592
- stdout: new ReadableStream({
593
- start(c) {
594
- c.close()
595
- },
596
- }),
597
- stderr: new ReadableStream({
598
- start(c) {
599
- c.enqueue(
600
- encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
601
- )
602
- c.close()
603
- },
604
- }),
605
- exited: Promise.resolve(255),
606
- }
607
- }
608
-
609
- const result = await getGatewayComposeStatus(secretHost, mockSpawn)
610
-
611
- expect(result.ok).toBe(false)
612
- expect(result.error).not.toContain(secretHost)
613
- expect(result.error).not.toContain(secretHost.toLowerCase())
614
- })
615
- })
616
-
617
- // ─── SSH identity injection (GATEWAY_SSH_KEY) ─────────────────────────────────
618
-
619
- describe('getGatewayComposeStatus — SSH identity injection', () => {
620
- let originalEnv: Record<string, string | undefined>
621
-
622
- beforeEach(() => {
623
- originalEnv = {GATEWAY_SSH_KEY: process.env.GATEWAY_SSH_KEY}
624
- })
625
-
626
- afterEach(() => {
627
- if (originalEnv.GATEWAY_SSH_KEY === undefined) {
628
- delete process.env.GATEWAY_SSH_KEY
629
- } else {
630
- process.env.GATEWAY_SSH_KEY = originalEnv.GATEWAY_SSH_KEY
631
- }
632
- })
633
-
634
- it('includes -i <path> and IdentitiesOnly=yes in ssh argv when GATEWAY_SSH_KEY is set', async () => {
635
- const {getGatewayComposeStatus} = await import('./status')
636
- process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
637
-
638
- let capturedCmd: string[] = []
639
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
640
- capturedCmd = cmd
641
- const enc = new TextEncoder()
642
- return {
643
- stdout: new ReadableStream({
644
- start(c) {
645
- c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
646
- c.close()
647
- },
648
- }),
649
- stderr: new ReadableStream({
650
- start(c) {
651
- c.close()
652
- },
653
- }),
654
- exited: Promise.resolve(0),
655
- }
656
- }
657
-
658
- await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
659
-
660
- const iIdx = capturedCmd.indexOf('-i')
661
- expect(iIdx).toBeGreaterThan(-1)
662
- expect(capturedCmd[iIdx + 1]).toBeTruthy() // path to temp key file
663
-
664
- const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
665
- expect(identitiesOnlyIdx).toBeGreaterThan(-1)
666
-
667
- const destination = capturedCmd.find(arg => arg.includes('@'))
668
- expect(destination).toBe('root@gateway.example.com')
669
- })
670
-
671
- it('does not include -i or IdentitiesOnly=yes when GATEWAY_SSH_KEY is absent', async () => {
672
- const {getGatewayComposeStatus} = await import('./status')
673
- delete process.env.GATEWAY_SSH_KEY
674
-
675
- let capturedCmd: string[] = []
676
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
677
- capturedCmd = cmd
678
- const enc = new TextEncoder()
679
- return {
680
- stdout: new ReadableStream({
681
- start(c) {
682
- c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
683
- c.close()
684
- },
685
- }),
686
- stderr: new ReadableStream({
687
- start(c) {
688
- c.close()
689
- },
690
- }),
691
- exited: Promise.resolve(0),
692
- }
693
- }
694
-
695
- await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
696
-
697
- expect(capturedCmd.indexOf('-i')).toBe(-1)
698
- expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
699
- })
700
-
701
- it('cleans up the temp key file after the SSH command completes', async () => {
702
- const {getGatewayComposeStatus} = await import('./status')
703
- const {statSync} = await import('node:fs')
704
- process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
705
-
706
- let capturedKeyPath: string | undefined
707
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
708
- const iIdx = cmd.indexOf('-i')
709
- if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
710
- const enc = new TextEncoder()
711
- return {
712
- stdout: new ReadableStream({
713
- start(c) {
714
- c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
715
- c.close()
716
- },
717
- }),
718
- stderr: new ReadableStream({
719
- start(c) {
720
- c.close()
721
- },
722
- }),
723
- exited: Promise.resolve(0),
724
- }
725
- }
726
-
727
- await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
728
-
729
- expect(capturedKeyPath).toBeTruthy()
730
- const keyPath = capturedKeyPath
731
- if (keyPath) {
732
- expect(() => statSync(keyPath)).toThrow()
733
- }
734
- })
735
-
736
- it('cleans up the temp key dir even when spawn throws synchronously', async () => {
737
- const {getGatewayComposeStatus} = await import('./status')
738
- const {statSync} = await import('node:fs')
739
- process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
740
-
741
- let capturedKeyPath: string | undefined
742
- const throwingSpawn: SpawnFn = (cmd, _opts) => {
743
- const iIdx = cmd.indexOf('-i')
744
- if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
745
- throw new Error('spawn failed')
746
- }
747
-
748
- await expect(getGatewayComposeStatus('gateway.example.com', throwingSpawn)).rejects.toThrow('spawn failed')
749
-
750
- expect(capturedKeyPath).toBeTruthy()
751
- if (capturedKeyPath) {
752
- expect(() => statSync(capturedKeyPath as string)).toThrow()
753
- }
754
- })
755
- })