@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,708 +0,0 @@
1
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
-
3
- import {createCapturedCtx, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
4
- import {
5
- dashboardStatusAction,
6
- getDashboardComposeStatus,
7
- getDashboardStatusSummary,
8
- parseComposePs,
9
- parseComposePsOutput,
10
- type ComposePsEntry,
11
- type ServiceRow,
12
- } from './status'
13
-
14
- // ─── parseComposePsOutput ─────────────────────────────────────────────────────
15
-
16
- describe('parseComposePsOutput', () => {
17
- it('parses NDJSON output (one JSON object per line)', () => {
18
- const ndjson = [
19
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
20
- JSON.stringify({Name: 'db', State: 'running', Health: ''}),
21
- ].join('\n')
22
-
23
- const entries = parseComposePsOutput(ndjson)
24
-
25
- expect(entries).toHaveLength(2)
26
- expect(entries[0]).toEqual({Name: 'dashboard', State: 'running', Health: 'healthy'})
27
- expect(entries[1]).toEqual({Name: 'db', State: 'running', Health: ''})
28
- })
29
-
30
- it('parses JSON-array output (legacy compose format)', () => {
31
- const jsonArray = JSON.stringify([
32
- {Name: 'dashboard', State: 'running', Health: 'healthy'},
33
- {Name: 'db', State: 'running', Health: ''},
34
- ])
35
-
36
- const entries = parseComposePsOutput(jsonArray)
37
-
38
- expect(entries).toHaveLength(2)
39
- expect(entries[0]).toEqual({Name: 'dashboard', State: 'running', Health: 'healthy'})
40
- })
41
-
42
- it('returns empty array for empty string', () => {
43
- expect(parseComposePsOutput('')).toEqual([])
44
- })
45
-
46
- it('returns empty array for whitespace-only string', () => {
47
- expect(parseComposePsOutput(' \n ')).toEqual([])
48
- })
49
- })
50
-
51
- // ─── parseComposePs ──────────────────────────────────────────────────────────
52
-
53
- describe('parseComposePs', () => {
54
- it('maps running services with healthy status', () => {
55
- const raw: ComposePsEntry[] = [
56
- {Name: 'dashboard', State: 'running', Health: 'healthy'},
57
- {Name: 'db', State: 'running', Health: ''},
58
- ]
59
-
60
- const rows = parseComposePs(raw)
61
-
62
- expect(rows).toHaveLength(2)
63
- expect(rows[0]).toEqual({service: 'dashboard', state: 'running', health: 'healthy'} satisfies ServiceRow)
64
- expect(rows[1]).toEqual({service: 'db', state: 'running', health: 'n-a'} satisfies ServiceRow)
65
- })
66
-
67
- it('maps exited state', () => {
68
- const raw: ComposePsEntry[] = [{Name: 'dashboard', State: 'exited', Health: ''}]
69
-
70
- const rows = parseComposePs(raw)
71
-
72
- expect(rows[0]).toEqual({service: 'dashboard', state: 'exited', health: 'n-a'} satisfies ServiceRow)
73
- })
74
-
75
- it('maps unhealthy health status', () => {
76
- const raw: ComposePsEntry[] = [{Name: 'dashboard', State: 'running', Health: 'unhealthy'}]
77
-
78
- const rows = parseComposePs(raw)
79
-
80
- expect(rows[0]?.health).toBe('unhealthy')
81
- })
82
-
83
- it('maps starting health status', () => {
84
- const raw: ComposePsEntry[] = [{Name: 'dashboard', State: 'running', Health: 'starting'}]
85
-
86
- const rows = parseComposePs(raw)
87
-
88
- expect(rows[0]?.health).toBe('starting')
89
- })
90
-
91
- it('maps unknown health values to n-a', () => {
92
- const raw: ComposePsEntry[] = [{Name: 'dashboard', State: 'running', Health: 'something-weird'}]
93
-
94
- const rows = parseComposePs(raw)
95
-
96
- expect(rows[0]?.health).toBe('n-a')
97
- })
98
-
99
- it('returns empty array for empty input', () => {
100
- expect(parseComposePs([])).toEqual([])
101
- })
102
- })
103
-
104
- // ─── getDashboardComposeStatus (SSH mocked via SpawnFn) ──────────────────────
105
-
106
- type SpawnFn = (
107
- cmd: string[],
108
- opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
109
- ) => {
110
- stdout: ReadableStream<Uint8Array>
111
- stderr: ReadableStream<Uint8Array>
112
- exited: Promise<number>
113
- }
114
-
115
- function makeSpawn(stdout: string, stderr = '', exitCode = 0): SpawnFn {
116
- return (_cmd, _opts) => {
117
- const enc = new TextEncoder()
118
- return {
119
- stdout: new ReadableStream({
120
- start(controller) {
121
- controller.enqueue(enc.encode(stdout))
122
- controller.close()
123
- },
124
- }),
125
- stderr: new ReadableStream({
126
- start(controller) {
127
- controller.enqueue(enc.encode(stderr))
128
- controller.close()
129
- },
130
- }),
131
- exited: Promise.resolve(exitCode),
132
- }
133
- }
134
- }
135
-
136
- // ─── SSH command includes ConnectTimeout ─────────────────────────────────────
137
-
138
- describe('getDashboardComposeStatus — SSH command includes ConnectTimeout', () => {
139
- it('passes -o ConnectTimeout=10 to ssh', async () => {
140
- let capturedCmd: string[] = []
141
-
142
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
143
- capturedCmd = cmd
144
- const enc = new TextEncoder()
145
- return {
146
- stdout: new ReadableStream({
147
- start(c) {
148
- c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
149
- c.close()
150
- },
151
- }),
152
- stderr: new ReadableStream({
153
- start(c) {
154
- c.close()
155
- },
156
- }),
157
- exited: Promise.resolve(0),
158
- }
159
- }
160
-
161
- await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
162
-
163
- const connectTimeoutIdx = capturedCmd.findIndex(arg => arg.startsWith('ConnectTimeout='))
164
- expect(connectTimeoutIdx).toBeGreaterThan(-1)
165
- expect(capturedCmd[connectTimeoutIdx - 1]).toBe('-o')
166
- expect(capturedCmd[connectTimeoutIdx]).toBe('ConnectTimeout=10')
167
- })
168
- })
169
-
170
- // ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
171
-
172
- describe('getDashboardComposeStatus — SSH command includes UserKnownHostsFile', () => {
173
- it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
174
- let capturedCmd: string[] = []
175
-
176
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
177
- capturedCmd = cmd
178
- const enc = new TextEncoder()
179
- return {
180
- stdout: new ReadableStream({
181
- start(c) {
182
- c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
183
- c.close()
184
- },
185
- }),
186
- stderr: new ReadableStream({
187
- start(c) {
188
- c.close()
189
- },
190
- }),
191
- exited: Promise.resolve(0),
192
- }
193
- }
194
-
195
- await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
196
-
197
- // Find the UserKnownHostsFile option in the ssh command
198
- const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
199
- expect(knownHostsIdx).toBeGreaterThan(-1)
200
- expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
201
- expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
202
- })
203
-
204
- it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
205
- let capturedCmd: string[] = []
206
-
207
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
208
- capturedCmd = cmd
209
- const enc = new TextEncoder()
210
- return {
211
- stdout: new ReadableStream({
212
- start(c) {
213
- c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
214
- c.close()
215
- },
216
- }),
217
- stderr: new ReadableStream({
218
- start(c) {
219
- c.close()
220
- },
221
- }),
222
- exited: Promise.resolve(0),
223
- }
224
- }
225
-
226
- await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
227
-
228
- const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
229
- expect(strictIdx).toBeGreaterThan(-1)
230
- expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
231
- })
232
- })
233
-
234
- describe('getDashboardComposeStatus', () => {
235
- it('returns service rows from NDJSON output', async () => {
236
- const ndjson = [
237
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
238
- JSON.stringify({Name: 'db', State: 'running', Health: ''}),
239
- ].join('\n')
240
-
241
- const result = await getDashboardComposeStatus('dashboard.fro.bot', makeSpawn(ndjson))
242
-
243
- expect(result.ok).toBe(true)
244
- expect(result.services).toHaveLength(2)
245
- expect(result.services[0]?.service).toBe('dashboard')
246
- })
247
-
248
- it('returns service rows from JSON-array output', async () => {
249
- const jsonArray = JSON.stringify([
250
- {Name: 'dashboard', State: 'running', Health: 'healthy'},
251
- {Name: 'db', State: 'running', Health: ''},
252
- ])
253
-
254
- const result = await getDashboardComposeStatus('dashboard.fro.bot', makeSpawn(jsonArray))
255
-
256
- expect(result.ok).toBe(true)
257
- expect(result.services).toHaveLength(2)
258
- })
259
-
260
- it('returns ok=false when SSH exits non-zero', async () => {
261
- const result = await getDashboardComposeStatus('dashboard.fro.bot', makeSpawn('', 'connection refused', 1))
262
-
263
- expect(result.ok).toBe(false)
264
- expect(result.error).toContain('SSH command failed')
265
- })
266
-
267
- it('rejects invalid host before spawning SSH', async () => {
268
- let spawnCalled = false
269
- const spy: SpawnFn = (cmd, opts) => {
270
- spawnCalled = true
271
- return makeSpawn('')(cmd, opts)
272
- }
273
-
274
- await expect(getDashboardComposeStatus('-oProxyCommand=evil', spy)).rejects.toThrow('Invalid DASHBOARD_DOMAIN')
275
- expect(spawnCalled).toBe(false)
276
- })
277
- })
278
-
279
- // ─── getDashboardStatusSummary ────────────────────────────────────────────────
280
-
281
- describe('getDashboardStatusSummary', () => {
282
- it('shows service rows with DEGRADED marker when services present but unhealthy', async () => {
283
- const ndjson = [
284
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'unhealthy'}),
285
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
286
- ].join('\n')
287
-
288
- const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(ndjson))
289
-
290
- expect(summary.http).toContain('DEGRADED')
291
- expect(summary.http).toContain('dashboard')
292
- expect(summary.http).not.toContain('No services reported')
293
- })
294
-
295
- it('shows ERROR with no-services message when compose ps returns empty output', async () => {
296
- const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(''))
297
-
298
- expect(summary.http).toContain('ERROR')
299
- expect(summary.http).toContain('No services')
300
- expect(summary.http).not.toContain('DEGRADED')
301
- })
302
-
303
- it('shows OK with service rows when all services are healthy', async () => {
304
- const ndjson = [
305
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
306
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
307
- ].join('\n')
308
-
309
- const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(ndjson))
310
-
311
- expect(summary.http).toContain('OK')
312
- expect(summary.http).toContain('dashboard')
313
- expect(summary.http).not.toContain('DEGRADED')
314
- expect(summary.http).not.toContain('ERROR')
315
- })
316
-
317
- it('returns a summary with app: dashboard', async () => {
318
- const ndjson = JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])
319
- const summary = await getDashboardStatusSummary('dashboard.fro.bot', makeSpawn(ndjson))
320
- expect(summary.app).toBe('dashboard')
321
- })
322
- })
323
-
324
- // ─── dashboardStatusAction ───────────────────────────────────────────────────
325
-
326
- describe('status command', () => {
327
- let originalEnv: Record<string, string | undefined>
328
-
329
- beforeEach(() => {
330
- originalEnv = {DASHBOARD_DOMAIN: process.env.DASHBOARD_DOMAIN, MY_DASHBOARD_HOST: process.env.MY_DASHBOARD_HOST}
331
- })
332
-
333
- afterEach(() => {
334
- if (originalEnv.DASHBOARD_DOMAIN === undefined) {
335
- delete process.env.DASHBOARD_DOMAIN
336
- } else {
337
- process.env.DASHBOARD_DOMAIN = originalEnv.DASHBOARD_DOMAIN
338
- }
339
-
340
- if (originalEnv.MY_DASHBOARD_HOST === undefined) {
341
- delete process.env.MY_DASHBOARD_HOST
342
- } else {
343
- process.env.MY_DASHBOARD_HOST = originalEnv.MY_DASHBOARD_HOST
344
- }
345
- })
346
-
347
- it('outputs service rows through ctx when services are running', async () => {
348
- process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
349
-
350
- const ndjson = [
351
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
352
- JSON.stringify({Name: 'db', State: 'running', Health: ''}),
353
- ].join('\n')
354
-
355
- const {ctx, captured} = createCapturedCtx()
356
-
357
- await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
358
-
359
- const output = captured.stdout.join('\n')
360
- expect(output).toContain('dashboard')
361
- expect(output).toContain('running')
362
- expect(captured.exit).toBeNull()
363
- })
364
-
365
- it('uses ctx.console NOT global console (output is captured via ctx)', async () => {
366
- process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
367
-
368
- const ndjson = JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])
369
- const {ctx, captured} = createCapturedCtx()
370
-
371
- await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
372
-
373
- // Output must appear in captured ctx, not global console
374
- expect(captured.stdout.length).toBeGreaterThan(0)
375
- expect(captured.stdout.join('')).toContain('dashboard')
376
- })
377
-
378
- it('calls ctx.console.error and ctx.process.exit(1) on SSH failure without throwing', async () => {
379
- process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
380
-
381
- const {ctx, captured} = createCapturedCtx()
382
-
383
- let threw = false
384
- try {
385
- await dashboardStatusAction({}, ctx, makeSpawn('', 'connection refused', 1))
386
- } catch (error) {
387
- if (error instanceof MockProcessExit) {
388
- // expected — MockProcessExit is thrown by ctx.process.exit
389
- } else {
390
- threw = true
391
- }
392
- }
393
-
394
- expect(threw).toBe(false)
395
- expect(captured.stderr.join('')).toContain('Error')
396
- expect(captured.exit?.code).toBe(1)
397
- })
398
-
399
- it('prints the SSH error message (not a generic no-services message) when SSH fails', async () => {
400
- process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
401
-
402
- const {ctx, captured} = createCapturedCtx()
403
-
404
- try {
405
- await dashboardStatusAction(
406
- {},
407
- ctx,
408
- makeSpawn('', 'ssh: connect to host dashboard.fro.bot port 22: Connection timed out', 255),
409
- )
410
- } catch (error) {
411
- if (!(error instanceof MockProcessExit)) throw error
412
- }
413
-
414
- const stderrText = captured.stderr.join('')
415
- // Must surface the SSH failure detail, not the generic no-services fallback
416
- expect(stderrText).toContain('SSH command failed')
417
- expect(stderrText).not.toContain('No services reported by docker compose ps')
418
- expect(captured.exit?.code).toBe(1)
419
- })
420
-
421
- it('calls ctx.console.error and exits when DASHBOARD_DOMAIN is not set', async () => {
422
- delete process.env.DASHBOARD_DOMAIN
423
-
424
- const {ctx, captured} = createCapturedCtx()
425
-
426
- try {
427
- await dashboardStatusAction({}, ctx)
428
- } catch (error) {
429
- if (!(error instanceof MockProcessExit)) throw error
430
- }
431
-
432
- expect(captured.stderr.join('')).toContain('DASHBOARD_DOMAIN')
433
- expect(captured.exit?.code).toBe(1)
434
- })
435
-
436
- it('rejects bad host via ctx.console.error without throwing unhandled', async () => {
437
- process.env.DASHBOARD_DOMAIN = '-oProxyCommand=evil'
438
-
439
- const {ctx, captured} = createCapturedCtx()
440
-
441
- try {
442
- await dashboardStatusAction({}, ctx, makeSpawn(''))
443
- } catch (error) {
444
- if (!(error instanceof MockProcessExit)) throw error
445
- }
446
-
447
- expect(captured.stderr.join('')).toContain('Invalid DASHBOARD_DOMAIN')
448
- expect(captured.exit?.code).toBe(1)
449
- })
450
-
451
- it('reads host from the env var named by --key instead of DASHBOARD_DOMAIN', async () => {
452
- delete process.env.DASHBOARD_DOMAIN
453
- process.env.MY_DASHBOARD_HOST = 'dashboard.fro.bot'
454
-
455
- const ndjson = [
456
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
457
- JSON.stringify({Name: 'db', State: 'running', Health: ''}),
458
- ].join('\n')
459
-
460
- const {ctx, captured} = createCapturedCtx()
461
-
462
- try {
463
- await dashboardStatusAction({key: 'MY_DASHBOARD_HOST'}, ctx, makeSpawn(ndjson))
464
- } catch (error) {
465
- if (!(error instanceof MockProcessExit)) throw error
466
- }
467
-
468
- const output = captured.stdout.join('\n')
469
- expect(output).toContain('dashboard')
470
- expect(captured.exit).toBeNull()
471
- })
472
-
473
- it('exits 1 with a clear message when docker compose ps returns empty output', async () => {
474
- process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
475
-
476
- const {ctx, captured} = createCapturedCtx()
477
-
478
- try {
479
- await dashboardStatusAction({}, ctx, makeSpawn(''))
480
- } catch (error) {
481
- if (!(error instanceof MockProcessExit)) throw error
482
- }
483
-
484
- expect(captured.stderr.join('')).toMatch(/no services|empty/i)
485
- expect(captured.exit?.code).toBe(1)
486
- })
487
-
488
- it('reports degraded when a service is running but health is unhealthy', async () => {
489
- process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
490
-
491
- const ndjson = [
492
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'unhealthy'}),
493
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
494
- ].join('\n')
495
-
496
- const {ctx, captured} = createCapturedCtx()
497
-
498
- try {
499
- await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
500
- } catch (error) {
501
- if (!(error instanceof MockProcessExit)) throw error
502
- }
503
-
504
- const output = captured.stdout.join('\n')
505
- expect(output).toContain('DEGRADED')
506
- expect(captured.exit?.code).toBe(1)
507
- })
508
-
509
- it('reports OK when a service is running with health n-a (e.g. caddy has no healthcheck)', async () => {
510
- process.env.DASHBOARD_DOMAIN = 'dashboard.fro.bot'
511
-
512
- const ndjson = [
513
- JSON.stringify({Name: 'dashboard', State: 'running', Health: 'healthy'}),
514
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
515
- JSON.stringify({Name: 'caddy', State: 'running', Health: ''}),
516
- ].join('\n')
517
-
518
- const {ctx, captured} = createCapturedCtx()
519
-
520
- await dashboardStatusAction({}, ctx, makeSpawn(ndjson))
521
-
522
- const output = captured.stdout.join('\n')
523
- expect(output).toContain('Status: OK')
524
- expect(captured.exit).toBeNull()
525
- })
526
- })
527
-
528
- // ─── SSH error redaction — host value must not leak in returned error strings ──
529
-
530
- describe('getDashboardComposeStatus — SSH error redaction', () => {
531
- it('does not include the resolved host value in the error when SSH fails', async () => {
532
- const {getDashboardComposeStatus} = await import('./status')
533
- const secretLookingHost = 'TOPSECRETHOSTNAME'
534
-
535
- const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
536
- const encoder = new TextEncoder()
537
- return {
538
- stdout: new ReadableStream({
539
- start(c) {
540
- c.enqueue(encoder.encode(''))
541
- c.close()
542
- },
543
- }),
544
- stderr: new ReadableStream({
545
- start(c) {
546
- c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
547
- c.close()
548
- },
549
- }),
550
- exited: Promise.resolve(255),
551
- }
552
- }
553
-
554
- const result = await getDashboardComposeStatus(secretLookingHost, mockSpawn)
555
-
556
- expect(result.ok).toBe(false)
557
- expect(result.error).toBeDefined()
558
- expect(result.error).not.toContain(secretLookingHost)
559
- })
560
-
561
- it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
562
- const {getDashboardComposeStatus} = await import('./status')
563
- const secretHost = 'AKIAIOSFODNN7EXAMPLE'
564
-
565
- const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
566
- const encoder = new TextEncoder()
567
- return {
568
- stdout: new ReadableStream({
569
- start(c) {
570
- c.close()
571
- },
572
- }),
573
- stderr: new ReadableStream({
574
- start(c) {
575
- c.enqueue(
576
- encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
577
- )
578
- c.close()
579
- },
580
- }),
581
- exited: Promise.resolve(255),
582
- }
583
- }
584
-
585
- const result = await getDashboardComposeStatus(secretHost, mockSpawn)
586
-
587
- expect(result.ok).toBe(false)
588
- expect(result.error).not.toContain(secretHost)
589
- expect(result.error).not.toContain(secretHost.toLowerCase())
590
- })
591
- })
592
-
593
- // ─── SSH identity injection (DASHBOARD_SSH_KEY) ───────────────────────────────
594
-
595
- describe('getDashboardComposeStatus — SSH identity injection', () => {
596
- let originalEnv: Record<string, string | undefined>
597
-
598
- beforeEach(() => {
599
- originalEnv = {DASHBOARD_SSH_KEY: process.env.DASHBOARD_SSH_KEY}
600
- })
601
-
602
- afterEach(() => {
603
- if (originalEnv.DASHBOARD_SSH_KEY === undefined) {
604
- delete process.env.DASHBOARD_SSH_KEY
605
- } else {
606
- process.env.DASHBOARD_SSH_KEY = originalEnv.DASHBOARD_SSH_KEY
607
- }
608
- })
609
-
610
- it('includes -i <path> and IdentitiesOnly=yes in ssh argv when DASHBOARD_SSH_KEY is set', async () => {
611
- process.env.DASHBOARD_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
612
-
613
- let capturedCmd: string[] = []
614
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
615
- capturedCmd = cmd
616
- const enc = new TextEncoder()
617
- return {
618
- stdout: new ReadableStream({
619
- start(c) {
620
- c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
621
- c.close()
622
- },
623
- }),
624
- stderr: new ReadableStream({
625
- start(c) {
626
- c.close()
627
- },
628
- }),
629
- exited: Promise.resolve(0),
630
- }
631
- }
632
-
633
- await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
634
-
635
- const iIdx = capturedCmd.indexOf('-i')
636
- expect(iIdx).toBeGreaterThan(-1)
637
- expect(capturedCmd[iIdx + 1]).toBeTruthy()
638
-
639
- const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
640
- expect(identitiesOnlyIdx).toBeGreaterThan(-1)
641
-
642
- const destination = capturedCmd.find(arg => arg.includes('@'))
643
- expect(destination).toBe('root@dashboard.fro.bot')
644
- })
645
-
646
- it('does not include -i or IdentitiesOnly=yes when DASHBOARD_SSH_KEY is absent', async () => {
647
- delete process.env.DASHBOARD_SSH_KEY
648
-
649
- let capturedCmd: string[] = []
650
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
651
- capturedCmd = cmd
652
- const enc = new TextEncoder()
653
- return {
654
- stdout: new ReadableStream({
655
- start(c) {
656
- c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
657
- c.close()
658
- },
659
- }),
660
- stderr: new ReadableStream({
661
- start(c) {
662
- c.close()
663
- },
664
- }),
665
- exited: Promise.resolve(0),
666
- }
667
- }
668
-
669
- await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
670
-
671
- expect(capturedCmd.indexOf('-i')).toBe(-1)
672
- expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
673
- })
674
-
675
- it('cleans up the temp key file after the SSH command completes', async () => {
676
- const {statSync} = await import('node:fs')
677
- process.env.DASHBOARD_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
678
-
679
- let capturedKeyPath: string | undefined
680
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
681
- const iIdx = cmd.indexOf('-i')
682
- if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
683
- const enc = new TextEncoder()
684
- return {
685
- stdout: new ReadableStream({
686
- start(c) {
687
- c.enqueue(enc.encode(JSON.stringify([{Name: 'dashboard', State: 'running', Health: 'healthy'}])))
688
- c.close()
689
- },
690
- }),
691
- stderr: new ReadableStream({
692
- start(c) {
693
- c.close()
694
- },
695
- }),
696
- exited: Promise.resolve(0),
697
- }
698
- }
699
-
700
- await getDashboardComposeStatus('dashboard.fro.bot', capturingSpawn)
701
-
702
- expect(capturedKeyPath).toBeTruthy()
703
- const keyPath = capturedKeyPath
704
- if (keyPath) {
705
- expect(() => statSync(keyPath)).toThrow()
706
- }
707
- })
708
- })