@marcusrbrown/infra 0.4.10 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/__test__/mcp-ctx-fixture.test.ts +108 -0
- package/src/__test__/mcp-ctx-fixture.ts +104 -0
- package/src/commands/cliproxy/config.test.ts +261 -91
- package/src/commands/cliproxy/config.ts +84 -41
- package/src/commands/cliproxy/keys.test.ts +326 -0
- package/src/commands/cliproxy/keys.ts +116 -60
- package/src/commands/cliproxy/status.test.ts +80 -0
- package/src/commands/cliproxy/status.ts +74 -54
- package/src/commands/gateway/backup.test.ts +66 -1
- package/src/commands/gateway/backup.ts +39 -24
- package/src/commands/gateway/status.test.ts +225 -1
- package/src/commands/gateway/status.ts +69 -41
- package/src/commands/keeweb/status.test.ts +146 -0
- package/src/commands/keeweb/status.ts +32 -32
- package/src/commands/mcp.test.ts +210 -0
- package/src/commands/mcp.ts +32 -3
- package/src/commands/status.test.ts +95 -111
- package/src/commands/status.ts +46 -28
- package/src/lib/action-ctx.ts +25 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
2
|
|
|
3
|
+
import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
|
|
3
4
|
import {
|
|
4
5
|
checkHttpReachability,
|
|
5
6
|
checkUsageStats,
|
|
6
7
|
checkVersion,
|
|
8
|
+
cliproxyStatusAction,
|
|
7
9
|
formatDurationMs,
|
|
8
10
|
formatUsageSummaryLine,
|
|
9
11
|
levelLabel,
|
|
@@ -307,3 +309,81 @@ describe('cliproxy status helpers', () => {
|
|
|
307
309
|
})
|
|
308
310
|
})
|
|
309
311
|
})
|
|
312
|
+
|
|
313
|
+
describe('cliproxyStatusAction (Tier-2 ctx capture, failure-path parity)', () => {
|
|
314
|
+
afterEach(() => {
|
|
315
|
+
globalThis.fetch = originalFetch
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('Tier-2: routes unexpected thrown error through ctx.console.error + ctx.process.exit(1)', async () => {
|
|
319
|
+
// Trigger an unexpected error by having ctx.console.log throw on first call
|
|
320
|
+
const {ctx, captured} = createCapturedCtx()
|
|
321
|
+
let callCount = 0
|
|
322
|
+
const originalLog = ctx.console.log
|
|
323
|
+
ctx.console.log = (...args: unknown[]) => {
|
|
324
|
+
callCount++
|
|
325
|
+
if (callCount === 1) {
|
|
326
|
+
throw new Error('Unexpected internal error during status output')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
originalLog(...args)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
|
|
333
|
+
|
|
334
|
+
await expect(cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
|
|
335
|
+
name: 'MockProcessExit',
|
|
336
|
+
code: 1,
|
|
337
|
+
})
|
|
338
|
+
expect(captured.stderr.join('')).toContain('Unexpected internal error')
|
|
339
|
+
expect(captured.exit).toEqual({code: 1})
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('cliproxyStatusAction (Tier-2 ctx capture)', () => {
|
|
344
|
+
afterEach(() => {
|
|
345
|
+
globalThis.fetch = originalFetch
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('Mode A: captures CLIProxyAPI status header to ctx.stdout', async () => {
|
|
349
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
|
|
350
|
+
|
|
351
|
+
const {ctx, captured} = createCapturedCtx()
|
|
352
|
+
await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
|
|
353
|
+
|
|
354
|
+
expect(expectCapturedToInclude(captured, 'CLIProxyAPI status')).toBe(true)
|
|
355
|
+
expect(expectCapturedToInclude(captured, 'HTTP reachability')).toBe(true)
|
|
356
|
+
expect(expectCapturedToInclude(captured, 'Summary:')).toBe(true)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('Mode A: calls ctx.process.exit(1) on HTTP error', async () => {
|
|
360
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('error', {status: 500}))
|
|
361
|
+
|
|
362
|
+
const {ctx, captured} = createCapturedCtx()
|
|
363
|
+
let threw: unknown
|
|
364
|
+
try {
|
|
365
|
+
await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
|
|
366
|
+
} catch (error) {
|
|
367
|
+
threw = error
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
expect(threw).toBeInstanceOf(MockProcessExit)
|
|
371
|
+
expect(captured.exit?.code).toBe(1)
|
|
372
|
+
expect(expectCapturedToInclude(captured, 'ERROR')).toBe(true)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('Mode A: shows management key warning when no key provided', async () => {
|
|
376
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
|
|
377
|
+
|
|
378
|
+
const savedKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
379
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
380
|
+
try {
|
|
381
|
+
const {ctx, captured} = createCapturedCtx()
|
|
382
|
+
await cliproxyStatusAction({url: 'https://cliproxy.example.com'}, ctx)
|
|
383
|
+
|
|
384
|
+
expect(expectCapturedToInclude(captured, 'CLIPROXY_MANAGEMENT_KEY')).toBe(true)
|
|
385
|
+
} finally {
|
|
386
|
+
if (savedKey !== undefined) process.env.CLIPROXY_MANAGEMENT_KEY = savedKey
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
})
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type {goke} from 'goke'
|
|
2
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
2
3
|
import type {StatusSummary} from '../status'
|
|
3
4
|
|
|
4
5
|
import {z} from 'zod'
|
|
5
6
|
|
|
7
|
+
/** Minimal ctx surface consumed by cliproxy status actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
8
|
+
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
9
|
+
|
|
6
10
|
declare const process: {
|
|
7
11
|
env: Record<string, string | undefined>
|
|
8
12
|
exitCode?: number
|
|
@@ -197,13 +201,13 @@ export async function checkVersion(baseUrl: string, key: string): Promise<CheckR
|
|
|
197
201
|
}
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
function printCheckResult(result: CheckResult): void {
|
|
201
|
-
console.log(`[${levelLabel(result.level)}] ${result.title}`)
|
|
202
|
-
console.log(` ${result.summary}`)
|
|
204
|
+
function printCheckResult(result: CheckResult, ctx: ActionCtx): void {
|
|
205
|
+
ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`)
|
|
206
|
+
ctx.console.log(` ${result.summary}`)
|
|
203
207
|
|
|
204
208
|
if (result.details && result.details.length > 0) {
|
|
205
209
|
for (const detail of result.details) {
|
|
206
|
-
console.log(` - ${detail}`)
|
|
210
|
+
ctx.console.log(` - ${detail}`)
|
|
207
211
|
}
|
|
208
212
|
}
|
|
209
213
|
}
|
|
@@ -247,6 +251,71 @@ export async function getCliproxyStatusSummary(baseUrl: string, key: string, ver
|
|
|
247
251
|
}
|
|
248
252
|
}
|
|
249
253
|
|
|
254
|
+
export interface StatusOptions {
|
|
255
|
+
url?: string
|
|
256
|
+
key?: string
|
|
257
|
+
verbose?: boolean
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCtx): Promise<void> {
|
|
261
|
+
let errorCount = 0
|
|
262
|
+
try {
|
|
263
|
+
const verbose = options.verbose === true
|
|
264
|
+
const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
265
|
+
const managementKey = options.key ?? process.env.CLIPROXY_MANAGEMENT_KEY
|
|
266
|
+
|
|
267
|
+
ctx.console.log('CLIProxyAPI status')
|
|
268
|
+
ctx.console.log('')
|
|
269
|
+
|
|
270
|
+
const results: CheckResult[] = [await checkHttpReachability(baseUrl, verbose)]
|
|
271
|
+
|
|
272
|
+
let capturedUsageResult: CheckResult | undefined
|
|
273
|
+
|
|
274
|
+
if (managementKey) {
|
|
275
|
+
const [usageResult, versionResult] = await Promise.all([
|
|
276
|
+
checkUsageStats(baseUrl, managementKey),
|
|
277
|
+
checkVersion(baseUrl, managementKey),
|
|
278
|
+
])
|
|
279
|
+
|
|
280
|
+
capturedUsageResult = usageResult
|
|
281
|
+
results.push(usageResult, versionResult)
|
|
282
|
+
} else {
|
|
283
|
+
results.push({
|
|
284
|
+
title: 'Management checks',
|
|
285
|
+
level: 'warning',
|
|
286
|
+
summary:
|
|
287
|
+
'CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var.',
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const result of results) {
|
|
292
|
+
printCheckResult(result, ctx)
|
|
293
|
+
ctx.console.log('')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
errorCount = results.filter(result => result.level === 'error').length
|
|
297
|
+
const warningCount = results.filter(result => result.level === 'warning').length
|
|
298
|
+
|
|
299
|
+
ctx.console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`)
|
|
300
|
+
|
|
301
|
+
if (capturedUsageResult) {
|
|
302
|
+
const usageLine = formatUsageSummaryLine(capturedUsageResult)
|
|
303
|
+
if (usageLine) {
|
|
304
|
+
ctx.console.log(usageLine)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
309
|
+
ctx.console.error(message)
|
|
310
|
+
ctx.process.exit(1)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (errorCount > 0) {
|
|
315
|
+
ctx.process.exit(1)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
250
319
|
export function registerCliproxyStatus(cli: ReturnType<typeof goke>): void {
|
|
251
320
|
cli
|
|
252
321
|
.command('cliproxy status', 'Show operational health of CLIProxyAPI and its management endpoints.')
|
|
@@ -261,54 +330,5 @@ export function registerCliproxyStatus(cli: ReturnType<typeof goke>): void {
|
|
|
261
330
|
z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
262
331
|
)
|
|
263
332
|
.option('--verbose', 'Enable verbose output for all commands')
|
|
264
|
-
.action(
|
|
265
|
-
const verbose = options.verbose === true
|
|
266
|
-
const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
267
|
-
const managementKey = options.key ?? process.env.CLIPROXY_MANAGEMENT_KEY
|
|
268
|
-
|
|
269
|
-
console.log('CLIProxyAPI status')
|
|
270
|
-
console.log('')
|
|
271
|
-
|
|
272
|
-
const results: CheckResult[] = [await checkHttpReachability(baseUrl, verbose)]
|
|
273
|
-
|
|
274
|
-
let capturedUsageResult: CheckResult | undefined
|
|
275
|
-
|
|
276
|
-
if (managementKey) {
|
|
277
|
-
const [usageResult, versionResult] = await Promise.all([
|
|
278
|
-
checkUsageStats(baseUrl, managementKey),
|
|
279
|
-
checkVersion(baseUrl, managementKey),
|
|
280
|
-
])
|
|
281
|
-
|
|
282
|
-
capturedUsageResult = usageResult
|
|
283
|
-
results.push(usageResult, versionResult)
|
|
284
|
-
} else {
|
|
285
|
-
results.push({
|
|
286
|
-
title: 'Management checks',
|
|
287
|
-
level: 'warning',
|
|
288
|
-
summary:
|
|
289
|
-
'CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var.',
|
|
290
|
-
})
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
for (const result of results) {
|
|
294
|
-
printCheckResult(result)
|
|
295
|
-
console.log('')
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const errorCount = results.filter(result => result.level === 'error').length
|
|
299
|
-
const warningCount = results.filter(result => result.level === 'warning').length
|
|
300
|
-
|
|
301
|
-
console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`)
|
|
302
|
-
|
|
303
|
-
if (capturedUsageResult) {
|
|
304
|
-
const usageLine = formatUsageSummaryLine(capturedUsageResult)
|
|
305
|
-
if (usageLine) {
|
|
306
|
-
console.log(usageLine)
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (errorCount > 0) {
|
|
311
|
-
process.exitCode = 1
|
|
312
|
-
}
|
|
313
|
-
})
|
|
333
|
+
.action(cliproxyStatusAction)
|
|
314
334
|
}
|
|
@@ -2,7 +2,8 @@ import {statSync, writeFileSync} from 'node:fs'
|
|
|
2
2
|
|
|
3
3
|
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
|
|
6
|
+
import {backupGatewayCa, gatewayBackupAction, type BackupSpawnFn} from './backup'
|
|
6
7
|
|
|
7
8
|
// ─── SpawnFn helpers ──────────────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -286,3 +287,67 @@ describe('backupGatewayCa — SEC1: tmp file created with mode 0600 atomically',
|
|
|
286
287
|
}
|
|
287
288
|
})
|
|
288
289
|
})
|
|
290
|
+
|
|
291
|
+
// ─── Tier-2: gatewayBackupAction ctx capture ─────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe('gatewayBackupAction — ctx capture (Tier-2)', () => {
|
|
294
|
+
let originalEnv: Record<string, string | undefined>
|
|
295
|
+
let tmpOutput: string
|
|
296
|
+
|
|
297
|
+
beforeEach(() => {
|
|
298
|
+
originalEnv = {GATEWAY_HOST: process.env.GATEWAY_HOST}
|
|
299
|
+
process.env.GATEWAY_HOST = 'gateway.example.com'
|
|
300
|
+
tmpOutput = `/tmp/test-action-backup-${Date.now()}.tar`
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
afterEach(async () => {
|
|
304
|
+
if (originalEnv.GATEWAY_HOST === undefined) {
|
|
305
|
+
delete process.env.GATEWAY_HOST
|
|
306
|
+
} else {
|
|
307
|
+
process.env.GATEWAY_HOST = originalEnv.GATEWAY_HOST
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
|
|
311
|
+
} catch {
|
|
312
|
+
// ignore
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('routes success message to ctx.console.log (stdout)', async () => {
|
|
317
|
+
const {ctx, captured} = createCapturedCtx()
|
|
318
|
+
|
|
319
|
+
await gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx, makeSpawnOk(new Uint8Array([1, 2, 3])))
|
|
320
|
+
|
|
321
|
+
expect(expectCapturedToInclude(captured, 'CA backup written to')).toBe(true)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('routes sensitive-content warning to ctx.process.stderr.write', async () => {
|
|
325
|
+
const {ctx, captured} = createCapturedCtx()
|
|
326
|
+
|
|
327
|
+
await gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx, makeSpawnOk(new Uint8Array([1, 2, 3])))
|
|
328
|
+
|
|
329
|
+
const stderrText = captured.stderr.join('')
|
|
330
|
+
expect(stderrText.toLowerCase().includes('sensitive')).toBe(true)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('routes error to ctx.console.error and calls ctx.process.exit(1) when GATEWAY_HOST is unset', async () => {
|
|
334
|
+
delete process.env.GATEWAY_HOST
|
|
335
|
+
const {ctx, captured} = createCapturedCtx()
|
|
336
|
+
|
|
337
|
+
await expect(gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx)).rejects.toBeInstanceOf(MockProcessExit)
|
|
338
|
+
|
|
339
|
+
expect(captured.stderr.join('').includes('GATEWAY_HOST')).toBe(true)
|
|
340
|
+
expect(captured.exit?.code).toBe(1)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('routes backup failure to ctx.console.error and calls ctx.process.exit(1)', async () => {
|
|
344
|
+
const {ctx, captured} = createCapturedCtx()
|
|
345
|
+
|
|
346
|
+
await expect(
|
|
347
|
+
gatewayBackupAction({output: tmpOutput, includeCa: true}, ctx, makeSpawnError('Connection refused')),
|
|
348
|
+
).rejects.toBeInstanceOf(MockProcessExit)
|
|
349
|
+
|
|
350
|
+
expect(captured.stderr.join('').toLowerCase().includes('backup failed')).toBe(true)
|
|
351
|
+
expect(captured.exit?.code).toBe(1)
|
|
352
|
+
})
|
|
353
|
+
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type {goke} from 'goke'
|
|
2
2
|
|
|
3
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
4
|
+
|
|
3
5
|
import {closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync} from 'node:fs'
|
|
4
6
|
|
|
5
7
|
import {z} from 'zod'
|
|
6
|
-
|
|
7
8
|
import {validateGatewayHost} from './host'
|
|
8
9
|
|
|
9
10
|
declare const process: {
|
|
@@ -12,6 +13,10 @@ declare const process: {
|
|
|
12
13
|
stderr: {write: (msg: string) => void}
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
|
|
17
|
+
|
|
18
|
+
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
19
|
+
|
|
15
20
|
const MITMPROXY_CERTS_VOLUME = 'mitmproxy-certs'
|
|
16
21
|
const CA_CERT_FILE = 'mitmproxy-ca-cert.pem'
|
|
17
22
|
const CA_KEY_FILE = 'mitmproxy-ca.pem'
|
|
@@ -134,6 +139,38 @@ export async function backupGatewayCa(
|
|
|
134
139
|
return {ok: true, output: opts.output, bytesWritten: tarBytes.byteLength}
|
|
135
140
|
}
|
|
136
141
|
|
|
142
|
+
// ─── Action (exported for direct testing) ────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export async function gatewayBackupAction(
|
|
145
|
+
options: {output?: string | undefined; includeCa?: boolean | undefined},
|
|
146
|
+
ctx: ActionCtx,
|
|
147
|
+
spawn?: BackupSpawnFn,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
const hostEnvKey = 'GATEWAY_HOST'
|
|
150
|
+
const host = process.env[hostEnvKey]
|
|
151
|
+
|
|
152
|
+
if (!host) {
|
|
153
|
+
ctx.console.error(`Gateway host not set. Export ${hostEnvKey} before running backup.`)
|
|
154
|
+
ctx.process.exit(1)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const output = typeof options.output === 'string' ? options.output : DEFAULT_OUTPUT
|
|
159
|
+
const includeCa = options.includeCa !== false
|
|
160
|
+
|
|
161
|
+
const result = await backupGatewayCa({host, output, includeCa}, spawn, (msg: string) =>
|
|
162
|
+
ctx.process.stderr.write(`${msg}\n`),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if (!result.ok) {
|
|
166
|
+
ctx.console.error(`Backup failed: ${result.error}`)
|
|
167
|
+
ctx.process.exit(1)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ctx.console.log(`CA backup written to: ${output}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
// ─── Command registration ─────────────────────────────────────────────────────
|
|
138
175
|
|
|
139
176
|
export function registerGatewayBackup(cli: ReturnType<typeof goke>): void {
|
|
@@ -162,27 +199,5 @@ export function registerGatewayBackup(cli: ReturnType<typeof goke>): void {
|
|
|
162
199
|
.example('infra gateway backup --include-ca')
|
|
163
200
|
.example('# Back up the CA to a custom path')
|
|
164
201
|
.example('infra gateway backup --output /secure/backup/mitmproxy-ca.tar')
|
|
165
|
-
.action(
|
|
166
|
-
const hostEnvKey = 'GATEWAY_HOST'
|
|
167
|
-
const host = process.env[hostEnvKey]
|
|
168
|
-
|
|
169
|
-
if (!host) {
|
|
170
|
-
console.error(`Gateway host not set. Export ${hostEnvKey} before running backup.`)
|
|
171
|
-
process.exitCode = 1
|
|
172
|
-
return
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const output = typeof options.output === 'string' ? options.output : DEFAULT_OUTPUT
|
|
176
|
-
const includeCa = options.includeCa !== false
|
|
177
|
-
|
|
178
|
-
const result = await backupGatewayCa({host, output, includeCa})
|
|
179
|
-
|
|
180
|
-
if (!result.ok) {
|
|
181
|
-
console.error(`Backup failed: ${result.error}`)
|
|
182
|
-
process.exitCode = 1
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
console.log(`CA backup written to: ${output}`)
|
|
187
|
-
})
|
|
202
|
+
.action(gatewayBackupAction)
|
|
188
203
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {createCapturedCtx, expectCapturedToInclude, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
|
|
4
|
+
import {gatewayStatusAction, parseComposePs, parseComposePsOutput, type ComposePsEntry, type ServiceRow} from './status'
|
|
4
5
|
|
|
5
6
|
// ─── parseComposePs ──────────────────────────────────────────────────────────
|
|
6
7
|
|
|
@@ -257,3 +258,226 @@ describe('getGatewayComposeStatus', () => {
|
|
|
257
258
|
expect(result.error).toContain('SSH')
|
|
258
259
|
})
|
|
259
260
|
})
|
|
261
|
+
|
|
262
|
+
// ─── parseComposePsOutput ─────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
// Realistic fixture shape matching live droplet output
|
|
265
|
+
const ndjsonFixture = [
|
|
266
|
+
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)"}`,
|
|
267
|
+
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)"}`,
|
|
268
|
+
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"}`,
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
describe('parseComposePsOutput', () => {
|
|
272
|
+
it('parses NDJSON with 3 lines into 3 entries with correct Name/State/Health', () => {
|
|
273
|
+
const raw = ndjsonFixture.join('\n')
|
|
274
|
+
const entries = parseComposePsOutput(raw)
|
|
275
|
+
|
|
276
|
+
expect(entries).toHaveLength(3)
|
|
277
|
+
expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
|
|
278
|
+
expect(entries[0]?.State).toBe('running')
|
|
279
|
+
expect(entries[0]?.Health).toBe('healthy')
|
|
280
|
+
expect(entries[1]?.Name).toBe('fro-bot-mitmproxy-1')
|
|
281
|
+
expect(entries[1]?.State).toBe('running')
|
|
282
|
+
expect(entries[1]?.Health).toBe('healthy')
|
|
283
|
+
expect(entries[2]?.Name).toBe('fro-bot-workspace-1')
|
|
284
|
+
expect(entries[2]?.State).toBe('running')
|
|
285
|
+
expect(entries[2]?.Health).toBe('')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('parses legacy single JSON array format', () => {
|
|
289
|
+
const raw = JSON.stringify([
|
|
290
|
+
{Name: 'fro-bot-gateway-1', State: 'running', Health: 'healthy'},
|
|
291
|
+
{Name: 'fro-bot-mitmproxy-1', State: 'running', Health: 'healthy'},
|
|
292
|
+
])
|
|
293
|
+
const entries = parseComposePsOutput(raw)
|
|
294
|
+
|
|
295
|
+
expect(entries).toHaveLength(2)
|
|
296
|
+
expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
|
|
297
|
+
expect(entries[1]?.Name).toBe('fro-bot-mitmproxy-1')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('returns empty array for empty string', () => {
|
|
301
|
+
expect(parseComposePsOutput('')).toEqual([])
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('returns empty array for whitespace-only input', () => {
|
|
305
|
+
expect(parseComposePsOutput(' \n \n ')).toEqual([])
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('parses a single NDJSON line', () => {
|
|
309
|
+
const raw = ndjsonFixture.at(0) ?? ''
|
|
310
|
+
const entries = parseComposePsOutput(raw)
|
|
311
|
+
|
|
312
|
+
expect(entries).toHaveLength(1)
|
|
313
|
+
expect(entries[0]?.Name).toBe('fro-bot-gateway-1')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('handles trailing newline correctly', () => {
|
|
317
|
+
const raw = `${ndjsonFixture.join('\n')}\n`
|
|
318
|
+
const entries = parseComposePsOutput(raw)
|
|
319
|
+
|
|
320
|
+
expect(entries).toHaveLength(3)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('throws on a malformed NDJSON line', () => {
|
|
324
|
+
const raw = `${ndjsonFixture[0]}\nnot-valid-json\n${ndjsonFixture[2]}`
|
|
325
|
+
|
|
326
|
+
expect(() => parseComposePsOutput(raw)).toThrow()
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// ─── getGatewayComposeStatus — NDJSON integration ────────────────────────────
|
|
331
|
+
|
|
332
|
+
describe('getGatewayComposeStatus — NDJSON stdout', () => {
|
|
333
|
+
it('parses NDJSON docker compose ps output and returns correct services', async () => {
|
|
334
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
335
|
+
|
|
336
|
+
const ndjsonOutput = ndjsonFixture.join('\n')
|
|
337
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(ndjsonOutput))
|
|
338
|
+
|
|
339
|
+
expect(result.ok).toBe(true)
|
|
340
|
+
expect(result.services).toHaveLength(3)
|
|
341
|
+
expect(result.services[0]).toEqual({service: 'fro-bot-gateway-1', state: 'running', health: 'healthy'})
|
|
342
|
+
expect(result.services[1]).toEqual({service: 'fro-bot-mitmproxy-1', state: 'running', health: 'healthy'})
|
|
343
|
+
expect(result.services[2]).toEqual({service: 'fro-bot-workspace-1', state: 'running', health: 'n-a'})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('returns ok=false with error message when NDJSON contains a malformed line', async () => {
|
|
347
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
348
|
+
|
|
349
|
+
const badOutput = `${ndjsonFixture[0]}\nnot-valid-json`
|
|
350
|
+
const result = await getGatewayComposeStatus('gateway.example.com', makeSpawnOk(badOutput))
|
|
351
|
+
|
|
352
|
+
expect(result.ok).toBe(false)
|
|
353
|
+
expect(result.error).toContain('Failed to parse docker compose ps output')
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// ─── Tier-2: gatewayStatusAction ctx capture ─────────────────────────────────
|
|
358
|
+
|
|
359
|
+
type StatusSpawnFn = (
|
|
360
|
+
cmd: string[],
|
|
361
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
362
|
+
) => {
|
|
363
|
+
stdout: ReadableStream<Uint8Array>
|
|
364
|
+
stderr: ReadableStream<Uint8Array>
|
|
365
|
+
exited: Promise<number>
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function makeStatusSpawnOk(jsonOutput: string): StatusSpawnFn {
|
|
369
|
+
return (_cmd, _opts) => {
|
|
370
|
+
const encoder = new TextEncoder()
|
|
371
|
+
return {
|
|
372
|
+
stdout: new ReadableStream({
|
|
373
|
+
start(controller) {
|
|
374
|
+
controller.enqueue(encoder.encode(jsonOutput))
|
|
375
|
+
controller.close()
|
|
376
|
+
},
|
|
377
|
+
}),
|
|
378
|
+
stderr: new ReadableStream({
|
|
379
|
+
start(controller) {
|
|
380
|
+
controller.close()
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
exited: Promise.resolve(0),
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function makeStatusSpawnError(message: string): StatusSpawnFn {
|
|
389
|
+
return (_cmd, _opts) => {
|
|
390
|
+
const encoder = new TextEncoder()
|
|
391
|
+
return {
|
|
392
|
+
stdout: new ReadableStream({
|
|
393
|
+
start(controller) {
|
|
394
|
+
controller.close()
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
stderr: new ReadableStream({
|
|
398
|
+
start(controller) {
|
|
399
|
+
controller.enqueue(encoder.encode(message))
|
|
400
|
+
controller.close()
|
|
401
|
+
},
|
|
402
|
+
}),
|
|
403
|
+
exited: Promise.resolve(1),
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
describe('gatewayStatusAction — ctx capture (Tier-2)', () => {
|
|
409
|
+
let originalEnv: Record<string, string | undefined>
|
|
410
|
+
|
|
411
|
+
beforeEach(() => {
|
|
412
|
+
originalEnv = {GATEWAY_HOST: process.env.GATEWAY_HOST}
|
|
413
|
+
process.env.GATEWAY_HOST = 'gateway.example.com'
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
afterEach(() => {
|
|
417
|
+
if (originalEnv.GATEWAY_HOST === undefined) {
|
|
418
|
+
delete process.env.GATEWAY_HOST
|
|
419
|
+
} else {
|
|
420
|
+
process.env.GATEWAY_HOST = originalEnv.GATEWAY_HOST
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('routes "Status: OK" to ctx.console.log when all services are running', async () => {
|
|
425
|
+
const {ctx, captured} = createCapturedCtx()
|
|
426
|
+
|
|
427
|
+
const psOutput = JSON.stringify([
|
|
428
|
+
{Name: 'gateway', State: 'running', Health: 'healthy'},
|
|
429
|
+
{Name: 'mitmproxy', State: 'running', Health: 'healthy'},
|
|
430
|
+
])
|
|
431
|
+
|
|
432
|
+
await gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnOk(psOutput))
|
|
433
|
+
|
|
434
|
+
expect(expectCapturedToInclude(captured, 'Status: OK')).toBe(true)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('routes "Gateway status" header to ctx.console.log', async () => {
|
|
438
|
+
const {ctx, captured} = createCapturedCtx()
|
|
439
|
+
|
|
440
|
+
const psOutput = JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])
|
|
441
|
+
|
|
442
|
+
await gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnOk(psOutput))
|
|
443
|
+
|
|
444
|
+
expect(expectCapturedToInclude(captured, 'Gateway status')).toBe(true)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('routes error to ctx.console.error and calls ctx.process.exit(1) when SSH fails', async () => {
|
|
448
|
+
const {ctx, captured} = createCapturedCtx()
|
|
449
|
+
|
|
450
|
+
await expect(
|
|
451
|
+
gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnError('Connection refused')),
|
|
452
|
+
).rejects.toBeInstanceOf(MockProcessExit)
|
|
453
|
+
|
|
454
|
+
expect(captured.stderr.join('').includes('Error')).toBe(true)
|
|
455
|
+
expect(captured.exit?.code).toBe(1)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('routes error to ctx.console.error and calls ctx.process.exit(1) when GATEWAY_HOST is unset', async () => {
|
|
459
|
+
delete process.env.GATEWAY_HOST
|
|
460
|
+
const {ctx, captured} = createCapturedCtx()
|
|
461
|
+
|
|
462
|
+
await expect(gatewayStatusAction({key: undefined}, ctx)).rejects.toBeInstanceOf(MockProcessExit)
|
|
463
|
+
|
|
464
|
+
expect(captured.stderr.join('').includes('GATEWAY_HOST')).toBe(true)
|
|
465
|
+
expect(captured.exit?.code).toBe(1)
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('routes "Status: DEGRADED" to ctx.console.log and exits 1 when a service is not running', async () => {
|
|
469
|
+
const {ctx, captured} = createCapturedCtx()
|
|
470
|
+
|
|
471
|
+
const psOutput = JSON.stringify([
|
|
472
|
+
{Name: 'gateway', State: 'exited', Health: ''},
|
|
473
|
+
{Name: 'mitmproxy', State: 'running', Health: 'healthy'},
|
|
474
|
+
])
|
|
475
|
+
|
|
476
|
+
await expect(gatewayStatusAction({key: undefined}, ctx, makeStatusSpawnOk(psOutput))).rejects.toBeInstanceOf(
|
|
477
|
+
MockProcessExit,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
expect(expectCapturedToInclude(captured, 'DEGRADED')).toBe(true)
|
|
481
|
+
expect(captured.exit?.code).toBe(1)
|
|
482
|
+
})
|
|
483
|
+
})
|