@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.
@@ -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(async options => {
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 {backupGatewayCa, type BackupSpawnFn} from './backup'
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(async options => {
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 {parseComposePs, parseComposePsOutput, type ComposePsEntry, type ServiceRow} from './status'
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(async options => {
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
  }