@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.
- package/dist/cli.js +11965 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /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
|
-
})
|