@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,633 +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
- getUmamiComposeStatus,
6
- getUmamiStatusSummary,
7
- parseComposePs,
8
- parseComposePsOutput,
9
- umamiStatusAction,
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: 'umami', 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: 'umami', 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: 'umami', 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: 'umami', 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: 'umami', 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: 'umami', 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: 'umami', State: 'exited', Health: ''}]
69
-
70
- const rows = parseComposePs(raw)
71
-
72
- expect(rows[0]).toEqual({service: 'umami', state: 'exited', health: 'n-a'} satisfies ServiceRow)
73
- })
74
-
75
- it('maps unhealthy health status', () => {
76
- const raw: ComposePsEntry[] = [{Name: 'umami', 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: 'umami', 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: 'umami', 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
- // ─── getUmamiComposeStatus (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 repo-pinned UserKnownHostsFile ─────────────────────
137
-
138
- describe('getUmamiComposeStatus — SSH command includes UserKnownHostsFile', () => {
139
- it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', 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: 'umami', 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 getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
162
-
163
- // Find the UserKnownHostsFile option in the ssh command
164
- const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
165
- expect(knownHostsIdx).toBeGreaterThan(-1)
166
- expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
167
- expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
168
- })
169
-
170
- it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
171
- let capturedCmd: string[] = []
172
-
173
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
174
- capturedCmd = cmd
175
- const enc = new TextEncoder()
176
- return {
177
- stdout: new ReadableStream({
178
- start(c) {
179
- c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
180
- c.close()
181
- },
182
- }),
183
- stderr: new ReadableStream({
184
- start(c) {
185
- c.close()
186
- },
187
- }),
188
- exited: Promise.resolve(0),
189
- }
190
- }
191
-
192
- await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
193
-
194
- const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
195
- expect(strictIdx).toBeGreaterThan(-1)
196
- expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
197
- })
198
- })
199
-
200
- describe('getUmamiComposeStatus', () => {
201
- it('returns service rows from NDJSON output', async () => {
202
- const ndjson = [
203
- JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
204
- JSON.stringify({Name: 'db', State: 'running', Health: ''}),
205
- ].join('\n')
206
-
207
- const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn(ndjson))
208
-
209
- expect(result.ok).toBe(true)
210
- expect(result.services).toHaveLength(2)
211
- expect(result.services[0]?.service).toBe('umami')
212
- })
213
-
214
- it('returns service rows from JSON-array output', async () => {
215
- const jsonArray = JSON.stringify([
216
- {Name: 'umami', State: 'running', Health: 'healthy'},
217
- {Name: 'db', State: 'running', Health: ''},
218
- ])
219
-
220
- const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn(jsonArray))
221
-
222
- expect(result.ok).toBe(true)
223
- expect(result.services).toHaveLength(2)
224
- })
225
-
226
- it('returns ok=false when SSH exits non-zero', async () => {
227
- const result = await getUmamiComposeStatus('metrics.fro.bot', makeSpawn('', 'connection refused', 1))
228
-
229
- expect(result.ok).toBe(false)
230
- expect(result.error).toContain('SSH command failed')
231
- })
232
-
233
- it('rejects invalid host before spawning SSH', async () => {
234
- let spawnCalled = false
235
- const spy: SpawnFn = (cmd, opts) => {
236
- spawnCalled = true
237
- return makeSpawn('')(cmd, opts)
238
- }
239
-
240
- await expect(getUmamiComposeStatus('-oProxyCommand=evil', spy)).rejects.toThrow('Invalid UMAMI_DOMAIN')
241
- expect(spawnCalled).toBe(false)
242
- })
243
- })
244
-
245
- // ─── getUmamiStatusSummary ────────────────────────────────────────────────────
246
-
247
- describe('getUmamiStatusSummary', () => {
248
- it('shows service rows with DEGRADED marker when services present but unhealthy', async () => {
249
- const ndjson = [
250
- JSON.stringify({Name: 'umami', State: 'running', Health: 'unhealthy'}),
251
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
252
- ].join('\n')
253
-
254
- const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(ndjson))
255
-
256
- expect(summary.http).toContain('DEGRADED')
257
- expect(summary.http).toContain('umami')
258
- expect(summary.http).not.toContain('No services reported')
259
- })
260
-
261
- it('shows ERROR with no-services message when compose ps returns empty output', async () => {
262
- const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(''))
263
-
264
- expect(summary.http).toContain('ERROR')
265
- expect(summary.http).toContain('No services')
266
- expect(summary.http).not.toContain('DEGRADED')
267
- })
268
-
269
- it('shows OK with service rows when all services are healthy', async () => {
270
- const ndjson = [
271
- JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
272
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
273
- ].join('\n')
274
-
275
- const summary = await getUmamiStatusSummary('metrics.fro.bot', makeSpawn(ndjson))
276
-
277
- expect(summary.http).toContain('OK')
278
- expect(summary.http).toContain('umami')
279
- expect(summary.http).not.toContain('DEGRADED')
280
- expect(summary.http).not.toContain('ERROR')
281
- })
282
- })
283
-
284
- // ─── umamiStatusAction ───────────────────────────────────────────────────────
285
-
286
- describe('status command', () => {
287
- let originalEnv: Record<string, string | undefined>
288
-
289
- beforeEach(() => {
290
- originalEnv = {UMAMI_DOMAIN: process.env.UMAMI_DOMAIN, MY_UMAMI_HOST: process.env.MY_UMAMI_HOST}
291
- })
292
-
293
- afterEach(() => {
294
- if (originalEnv.UMAMI_DOMAIN === undefined) {
295
- delete process.env.UMAMI_DOMAIN
296
- } else {
297
- process.env.UMAMI_DOMAIN = originalEnv.UMAMI_DOMAIN
298
- }
299
-
300
- if (originalEnv.MY_UMAMI_HOST === undefined) {
301
- delete process.env.MY_UMAMI_HOST
302
- } else {
303
- process.env.MY_UMAMI_HOST = originalEnv.MY_UMAMI_HOST
304
- }
305
- })
306
-
307
- it('outputs service rows through ctx when services are running', async () => {
308
- process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
309
-
310
- const ndjson = [
311
- JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
312
- JSON.stringify({Name: 'db', State: 'running', Health: ''}),
313
- ].join('\n')
314
-
315
- const {ctx, captured} = createCapturedCtx()
316
-
317
- await umamiStatusAction({}, ctx, makeSpawn(ndjson))
318
-
319
- const output = captured.stdout.join('\n')
320
- expect(output).toContain('umami')
321
- expect(output).toContain('running')
322
- expect(captured.exit).toBeNull()
323
- })
324
-
325
- it('calls ctx.console.error and ctx.process.exit(1) on SSH failure without throwing', async () => {
326
- process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
327
-
328
- const {ctx, captured} = createCapturedCtx()
329
-
330
- let threw = false
331
- try {
332
- await umamiStatusAction({}, ctx, makeSpawn('', 'connection refused', 1))
333
- } catch (error) {
334
- if (error instanceof MockProcessExit) {
335
- // expected — MockProcessExit is thrown by ctx.process.exit
336
- } else {
337
- threw = true
338
- }
339
- }
340
-
341
- expect(threw).toBe(false)
342
- expect(captured.stderr.join('')).toContain('Error')
343
- expect(captured.exit?.code).toBe(1)
344
- })
345
-
346
- it('calls ctx.console.error and exits when UMAMI_DOMAIN is not set', async () => {
347
- delete process.env.UMAMI_DOMAIN
348
-
349
- const {ctx, captured} = createCapturedCtx()
350
-
351
- try {
352
- await umamiStatusAction({}, ctx)
353
- } catch (error) {
354
- if (!(error instanceof MockProcessExit)) throw error
355
- }
356
-
357
- expect(captured.stderr.join('')).toContain('UMAMI_DOMAIN')
358
- expect(captured.exit?.code).toBe(1)
359
- })
360
-
361
- it('rejects bad host via ctx.console.error without throwing unhandled', async () => {
362
- process.env.UMAMI_DOMAIN = '-oProxyCommand=evil'
363
-
364
- const {ctx, captured} = createCapturedCtx()
365
-
366
- try {
367
- await umamiStatusAction({}, ctx, makeSpawn(''))
368
- } catch (error) {
369
- if (!(error instanceof MockProcessExit)) throw error
370
- }
371
-
372
- expect(captured.stderr.join('')).toContain('Invalid UMAMI_DOMAIN')
373
- expect(captured.exit?.code).toBe(1)
374
- })
375
-
376
- it('reads host from the env var named by --key instead of UMAMI_DOMAIN', async () => {
377
- delete process.env.UMAMI_DOMAIN
378
- process.env.MY_UMAMI_HOST = 'metrics.fro.bot'
379
-
380
- const ndjson = [
381
- JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
382
- JSON.stringify({Name: 'db', State: 'running', Health: ''}),
383
- ].join('\n')
384
-
385
- const {ctx, captured} = createCapturedCtx()
386
-
387
- try {
388
- await umamiStatusAction({key: 'MY_UMAMI_HOST'}, ctx, makeSpawn(ndjson))
389
- } catch (error) {
390
- if (!(error instanceof MockProcessExit)) throw error
391
- }
392
-
393
- const output = captured.stdout.join('\n')
394
- expect(output).toContain('umami')
395
- expect(captured.exit).toBeNull()
396
- })
397
-
398
- it('exits 1 with a clear message when docker compose ps returns empty output', async () => {
399
- process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
400
-
401
- const {ctx, captured} = createCapturedCtx()
402
-
403
- try {
404
- await umamiStatusAction({}, ctx, makeSpawn(''))
405
- } catch (error) {
406
- if (!(error instanceof MockProcessExit)) throw error
407
- }
408
-
409
- expect(captured.stderr.join('')).toMatch(/no services|empty/i)
410
- expect(captured.exit?.code).toBe(1)
411
- })
412
-
413
- it('reports degraded when a service is running but health is unhealthy', async () => {
414
- process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
415
-
416
- const ndjson = [
417
- JSON.stringify({Name: 'umami', State: 'running', Health: 'unhealthy'}),
418
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
419
- ].join('\n')
420
-
421
- const {ctx, captured} = createCapturedCtx()
422
-
423
- try {
424
- await umamiStatusAction({}, ctx, makeSpawn(ndjson))
425
- } catch (error) {
426
- if (!(error instanceof MockProcessExit)) throw error
427
- }
428
-
429
- const output = captured.stdout.join('\n')
430
- expect(output).toContain('DEGRADED')
431
- expect(captured.exit?.code).toBe(1)
432
- })
433
-
434
- it('reports OK when a service is running with health n-a (e.g. caddy has no healthcheck)', async () => {
435
- process.env.UMAMI_DOMAIN = 'metrics.fro.bot'
436
-
437
- const ndjson = [
438
- JSON.stringify({Name: 'umami', State: 'running', Health: 'healthy'}),
439
- JSON.stringify({Name: 'db', State: 'running', Health: 'healthy'}),
440
- JSON.stringify({Name: 'caddy', State: 'running', Health: ''}),
441
- ].join('\n')
442
-
443
- const {ctx, captured} = createCapturedCtx()
444
-
445
- await umamiStatusAction({}, ctx, makeSpawn(ndjson))
446
-
447
- const output = captured.stdout.join('\n')
448
- expect(output).toContain('Status: OK')
449
- expect(captured.exit).toBeNull()
450
- })
451
- })
452
-
453
- // ─── SSH error redaction — host value must not leak in returned error strings ──
454
-
455
- describe('getUmamiComposeStatus — SSH error redaction', () => {
456
- it('does not include the resolved host value in the error when SSH fails', async () => {
457
- const {getUmamiComposeStatus} = await import('./status')
458
- const secretLookingHost = 'TOPSECRETHOSTNAME'
459
-
460
- const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
461
- const encoder = new TextEncoder()
462
- return {
463
- stdout: new ReadableStream({
464
- start(c) {
465
- c.enqueue(encoder.encode(''))
466
- c.close()
467
- },
468
- }),
469
- stderr: new ReadableStream({
470
- start(c) {
471
- c.enqueue(encoder.encode(`ssh: Could not resolve hostname ${secretLookingHost}: Name or service not known`))
472
- c.close()
473
- },
474
- }),
475
- exited: Promise.resolve(255),
476
- }
477
- }
478
-
479
- const result = await getUmamiComposeStatus(secretLookingHost, mockSpawn)
480
-
481
- expect(result.ok).toBe(false)
482
- expect(result.error).toBeDefined()
483
- expect(result.error).not.toContain(secretLookingHost)
484
- })
485
-
486
- it('does not leak the host even when OpenSSH lowercases it in stderr', async () => {
487
- const {getUmamiComposeStatus} = await import('./status')
488
- const secretHost = 'AKIAIOSFODNN7EXAMPLE'
489
-
490
- const mockSpawn = (_cmd: string[], _opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'}) => {
491
- const encoder = new TextEncoder()
492
- return {
493
- stdout: new ReadableStream({
494
- start(c) {
495
- c.close()
496
- },
497
- }),
498
- stderr: new ReadableStream({
499
- start(c) {
500
- c.enqueue(
501
- encoder.encode(`ssh: Could not resolve hostname ${secretHost.toLowerCase()}: Name or service not known`),
502
- )
503
- c.close()
504
- },
505
- }),
506
- exited: Promise.resolve(255),
507
- }
508
- }
509
-
510
- const result = await getUmamiComposeStatus(secretHost, mockSpawn)
511
-
512
- expect(result.ok).toBe(false)
513
- expect(result.error).not.toContain(secretHost)
514
- expect(result.error).not.toContain(secretHost.toLowerCase())
515
- })
516
- })
517
-
518
- // ─── SSH identity injection (UMAMI_SSH_KEY) ───────────────────────────────────
519
-
520
- describe('getUmamiComposeStatus — SSH identity injection', () => {
521
- let originalEnv: Record<string, string | undefined>
522
-
523
- beforeEach(() => {
524
- originalEnv = {UMAMI_SSH_KEY: process.env.UMAMI_SSH_KEY}
525
- })
526
-
527
- afterEach(() => {
528
- if (originalEnv.UMAMI_SSH_KEY === undefined) {
529
- delete process.env.UMAMI_SSH_KEY
530
- } else {
531
- process.env.UMAMI_SSH_KEY = originalEnv.UMAMI_SSH_KEY
532
- }
533
- })
534
-
535
- it('includes -i <path> and IdentitiesOnly=yes in ssh argv when UMAMI_SSH_KEY is set', async () => {
536
- process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
537
-
538
- let capturedCmd: string[] = []
539
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
540
- capturedCmd = cmd
541
- const enc = new TextEncoder()
542
- return {
543
- stdout: new ReadableStream({
544
- start(c) {
545
- c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
546
- c.close()
547
- },
548
- }),
549
- stderr: new ReadableStream({
550
- start(c) {
551
- c.close()
552
- },
553
- }),
554
- exited: Promise.resolve(0),
555
- }
556
- }
557
-
558
- await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
559
-
560
- const iIdx = capturedCmd.indexOf('-i')
561
- expect(iIdx).toBeGreaterThan(-1)
562
- expect(capturedCmd[iIdx + 1]).toBeTruthy()
563
-
564
- const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
565
- expect(identitiesOnlyIdx).toBeGreaterThan(-1)
566
-
567
- const destination = capturedCmd.find(arg => arg.includes('@'))
568
- expect(destination).toBe('root@metrics.fro.bot')
569
- })
570
-
571
- it('does not include -i or IdentitiesOnly=yes when UMAMI_SSH_KEY is absent', async () => {
572
- delete process.env.UMAMI_SSH_KEY
573
-
574
- let capturedCmd: string[] = []
575
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
576
- capturedCmd = cmd
577
- const enc = new TextEncoder()
578
- return {
579
- stdout: new ReadableStream({
580
- start(c) {
581
- c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
582
- c.close()
583
- },
584
- }),
585
- stderr: new ReadableStream({
586
- start(c) {
587
- c.close()
588
- },
589
- }),
590
- exited: Promise.resolve(0),
591
- }
592
- }
593
-
594
- await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
595
-
596
- expect(capturedCmd.indexOf('-i')).toBe(-1)
597
- expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
598
- })
599
-
600
- it('cleans up the temp key file after the SSH command completes', async () => {
601
- const {statSync} = await import('node:fs')
602
- process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
603
-
604
- let capturedKeyPath: string | undefined
605
- const capturingSpawn: SpawnFn = (cmd, _opts) => {
606
- const iIdx = cmd.indexOf('-i')
607
- if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
608
- const enc = new TextEncoder()
609
- return {
610
- stdout: new ReadableStream({
611
- start(c) {
612
- c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
613
- c.close()
614
- },
615
- }),
616
- stderr: new ReadableStream({
617
- start(c) {
618
- c.close()
619
- },
620
- }),
621
- exited: Promise.resolve(0),
622
- }
623
- }
624
-
625
- await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
626
-
627
- expect(capturedKeyPath).toBeTruthy()
628
- const keyPath = capturedKeyPath
629
- if (keyPath) {
630
- expect(() => statSync(keyPath)).toThrow()
631
- }
632
- })
633
- })