@marcusrbrown/infra 0.4.11 → 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 +130 -1
- package/src/commands/gateway/status.ts +50 -39
- 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
|
|
|
@@ -352,3 +353,131 @@ describe('getGatewayComposeStatus — NDJSON stdout', () => {
|
|
|
352
353
|
expect(result.error).toContain('Failed to parse docker compose ps output')
|
|
353
354
|
})
|
|
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
|
+
})
|
|
@@ -1,8 +1,11 @@
|
|
|
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 interface (subset of GokeExecutionContext used by this action) ─
|
|
8
|
+
|
|
6
9
|
import {validateGatewayHost} from './host'
|
|
7
10
|
|
|
8
11
|
declare const process: {
|
|
@@ -175,6 +178,52 @@ export async function getGatewayStatusSummary(host: string): Promise<StatusSumma
|
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
// ─── Action (exported for direct testing) ────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export async function gatewayStatusAction(
|
|
184
|
+
options: {key?: string | undefined},
|
|
185
|
+
ctx: ActionCtx,
|
|
186
|
+
spawn?: SpawnFn,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
const hostEnvKey = options.key ?? 'GATEWAY_HOST'
|
|
189
|
+
const host = process.env[hostEnvKey]
|
|
190
|
+
|
|
191
|
+
if (!host) {
|
|
192
|
+
ctx.console.error(`Gateway host not set. Export ${hostEnvKey} or pass --key <env-name> pointing to a set variable.`)
|
|
193
|
+
ctx.process.exit(1)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ctx.console.log('Gateway status')
|
|
198
|
+
ctx.console.log('')
|
|
199
|
+
|
|
200
|
+
const result = await getGatewayComposeStatus(host, spawn)
|
|
201
|
+
|
|
202
|
+
if (!result.ok && result.services.length === 0) {
|
|
203
|
+
ctx.console.error(`Error: ${result.error ?? 'Unknown error'}`)
|
|
204
|
+
ctx.process.exit(1)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ctx.console.log('Service State Health')
|
|
209
|
+
ctx.console.log('─────────────────────────────────────')
|
|
210
|
+
|
|
211
|
+
for (const row of result.services) {
|
|
212
|
+
const svc = row.service.padEnd(16)
|
|
213
|
+
const state = row.state.padEnd(10)
|
|
214
|
+
ctx.console.log(`${svc} ${state} ${row.health}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ctx.console.log('')
|
|
218
|
+
|
|
219
|
+
if (result.ok) {
|
|
220
|
+
ctx.console.log('Status: OK')
|
|
221
|
+
} else {
|
|
222
|
+
ctx.console.log('Status: DEGRADED (one or more services not running)')
|
|
223
|
+
ctx.process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
178
227
|
// ─── Command registration ─────────────────────────────────────────────────────
|
|
179
228
|
|
|
180
229
|
export function registerGatewayStatus(cli: ReturnType<typeof goke>): void {
|
|
@@ -184,43 +233,5 @@ export function registerGatewayStatus(cli: ReturnType<typeof goke>): void {
|
|
|
184
233
|
'--key [key]',
|
|
185
234
|
z.string().describe('Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.'),
|
|
186
235
|
)
|
|
187
|
-
.action(
|
|
188
|
-
const hostEnvKey = options.key ?? 'GATEWAY_HOST'
|
|
189
|
-
const host = process.env[hostEnvKey]
|
|
190
|
-
|
|
191
|
-
if (!host) {
|
|
192
|
-
console.error(`Gateway host not set. Export ${hostEnvKey} or pass --key <env-name> pointing to a set variable.`)
|
|
193
|
-
process.exitCode = 1
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
console.log('Gateway status')
|
|
198
|
-
console.log('')
|
|
199
|
-
|
|
200
|
-
const result = await getGatewayComposeStatus(host)
|
|
201
|
-
|
|
202
|
-
if (!result.ok && result.services.length === 0) {
|
|
203
|
-
console.error(`Error: ${result.error ?? 'Unknown error'}`)
|
|
204
|
-
process.exitCode = 1
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
console.log('Service State Health')
|
|
209
|
-
console.log('─────────────────────────────────────')
|
|
210
|
-
|
|
211
|
-
for (const row of result.services) {
|
|
212
|
-
const svc = row.service.padEnd(16)
|
|
213
|
-
const state = row.state.padEnd(10)
|
|
214
|
-
console.log(`${svc} ${state} ${row.health}`)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
console.log('')
|
|
218
|
-
|
|
219
|
-
if (result.ok) {
|
|
220
|
-
console.log('Status: OK')
|
|
221
|
-
} else {
|
|
222
|
-
console.log('Status: DEGRADED (one or more services not running)')
|
|
223
|
-
process.exitCode = 1
|
|
224
|
-
}
|
|
225
|
-
})
|
|
236
|
+
.action((options, ctx) => gatewayStatusAction(options, ctx))
|
|
226
237
|
}
|