@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.
@@ -1,139 +1,123 @@
1
- import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
1
+ import type {StatusSummary} from './status'
2
+ import {describe, expect, it} from 'bun:test'
2
3
  import {goke} from 'goke'
4
+ import {createCapturedCtx, expectCapturedToInclude} from '../__test__/mcp-ctx-fixture'
5
+ import {registerStatus, unifiedStatusAction} from './status'
6
+
7
+ const healthyKeeweb: StatusSummary = {
8
+ app: 'keeweb',
9
+ http: 'OK',
10
+ lastDeploy: '2026-04-12 10:00',
11
+ version: '—',
12
+ contentHash: 'match',
13
+ usageStats: '—',
14
+ }
15
+
16
+ const healthyCliproxy: StatusSummary = {
17
+ app: 'cliproxy',
18
+ http: 'OK',
19
+ lastDeploy: '—',
20
+ version: 'v1.2.3',
21
+ contentHash: '—',
22
+ usageStats: '12 req / 0 fail',
23
+ }
3
24
 
4
- import {registerStatus} from './status'
25
+ const healthyGateway: StatusSummary = {
26
+ app: 'gateway',
27
+ http: 'OK: gateway:running/healthy',
28
+ lastDeploy: '—',
29
+ version: '—',
30
+ contentHash: '—',
31
+ usageStats: '—',
32
+ }
5
33
 
6
- declare const process: {
7
- exitCode?: number
34
+ function makeDeps(overrides?: Partial<Parameters<typeof registerStatus>[1]>): Parameters<typeof registerStatus>[1] {
35
+ return {
36
+ getKeewebStatusSummary: async () => healthyKeeweb,
37
+ getCliproxyStatusSummary: async () => healthyCliproxy,
38
+ getGatewayStatusSummary: async () => healthyGateway,
39
+ ...overrides,
40
+ }
8
41
  }
9
42
 
10
- describe('top-level status command', () => {
11
- let logSpy: {mockRestore: () => void; mock: {calls: unknown[][]}}
43
+ describe('top-level status command (Tier-2 ctx capture)', () => {
44
+ it('prints a unified table when all apps are healthy', async () => {
45
+ const {ctx, captured} = createCapturedCtx()
12
46
 
13
- beforeEach(() => {
14
- logSpy = spyOn(console, 'log').mockImplementation(() => undefined)
15
- process.exitCode = 0
16
- })
47
+ await unifiedStatusAction({}, ctx, makeDeps())
17
48
 
18
- afterEach(() => {
19
- logSpy.mockRestore()
20
- process.exitCode = 0
49
+ expect(
50
+ expectCapturedToInclude(captured, '| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |'),
51
+ ).toBe(true)
52
+ expect(expectCapturedToInclude(captured, '| keeweb | OK | 2026-04-12 10:00 | — | match | — |')).toBe(true)
53
+ expect(expectCapturedToInclude(captured, '| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')).toBe(true)
54
+ expect(expectCapturedToInclude(captured, '| gateway | OK: gateway:running/healthy | — | — | — | — |')).toBe(true)
21
55
  })
22
56
 
23
- async function runStatusCommand(
24
- args: string[],
25
- dependencies: Parameters<typeof registerStatus>[1],
26
- ): Promise<string[]> {
27
- const cli = goke('test')
28
- registerStatus(cli, dependencies)
29
- cli.parse(['bun', 'test', 'status', ...args], {run: false})
30
- await cli.runMatchedCommand()
57
+ it('shows an error row when one app fails and keeps the other results', async () => {
58
+ const {ctx, captured} = createCapturedCtx()
31
59
 
32
- return logSpy.mock.calls.map((call: unknown[]) => call.map((value: unknown) => String(value)).join(' '))
33
- }
34
-
35
- it('prints a unified table when both apps are healthy', async () => {
36
- const lines = await runStatusCommand([], {
37
- getKeewebStatusSummary: async () => ({
38
- app: 'keeweb',
39
- http: 'OK',
40
- lastDeploy: '2026-04-12 10:00',
41
- version: '—',
42
- contentHash: 'match',
43
- usageStats: '—',
44
- }),
45
- getCliproxyStatusSummary: async () => ({
46
- app: 'cliproxy',
47
- http: 'OK',
48
- lastDeploy: '—',
49
- version: 'v1.2.3',
50
- contentHash: '—',
51
- usageStats: '12 req / 0 fail',
60
+ await unifiedStatusAction(
61
+ {},
62
+ ctx,
63
+ makeDeps({
64
+ getKeewebStatusSummary: async () => {
65
+ throw new Error('Keeweb exploded')
66
+ },
52
67
  }),
53
- getGatewayStatusSummary: async () => ({
54
- app: 'gateway',
55
- http: 'OK: gateway:running/healthy',
56
- lastDeploy: '—',
57
- version: '—',
58
- contentHash: '—',
59
- usageStats: '—',
60
- }),
61
- })
68
+ )
62
69
 
63
- expect(lines).toContain('| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |')
64
- expect(lines).toContain('| keeweb | OK | 2026-04-12 10:00 | — | match | — |')
65
- expect(lines).toContain('| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')
66
- expect(lines).toContain('| gateway | OK: gateway:running/healthy | | | | |')
70
+ expect(
71
+ expectCapturedToInclude(
72
+ captured,
73
+ '| keeweb | Keeweb exploded | Keeweb exploded | Keeweb exploded | Keeweb exploded | Keeweb exploded |',
74
+ ),
75
+ ).toBe(true)
76
+ expect(expectCapturedToInclude(captured, '| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')).toBe(true)
77
+ // Rejection reason must also appear in stderr so MCP consumers can see it
78
+ expect(captured.stderr.join('')).toContain('keeweb status check failed: Keeweb exploded')
67
79
  })
68
80
 
69
- it('shows an error row when one app fails and keeps the other result', async () => {
70
- const lines = await runStatusCommand([], {
71
- getKeewebStatusSummary: async () => {
72
- throw new Error('Keeweb exploded')
73
- },
74
- getCliproxyStatusSummary: async () => ({
75
- app: 'cliproxy',
76
- http: 'OK',
77
- lastDeploy: '—',
78
- version: 'v1.2.3',
79
- contentHash: '—',
80
- usageStats: '12 req / 0 fail',
81
- }),
82
- getGatewayStatusSummary: async () => ({
83
- app: 'gateway',
84
- http: 'OK: gateway:running/healthy',
85
- lastDeploy: '—',
86
- version: '—',
87
- contentHash: '—',
88
- usageStats: '—',
89
- }),
90
- })
91
-
92
- expect(lines).toContain(
93
- '| keeweb | ❌ Keeweb exploded | ❌ Keeweb exploded | ❌ Keeweb exploded | ❌ Keeweb exploded | ❌ Keeweb exploded |',
94
- )
95
- expect(lines).toContain('| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')
96
- })
81
+ it('prints valid json with all app keys when --json is passed', async () => {
82
+ const {ctx, captured} = createCapturedCtx()
97
83
 
98
- it('prints valid json with both app keys when --json is passed', async () => {
99
- const lines = await runStatusCommand(['--json'], {
100
- getKeewebStatusSummary: async () => ({
101
- app: 'keeweb',
102
- http: 'OK',
103
- lastDeploy: '2026-04-12 10:00',
104
- version: '—',
105
- contentHash: 'match',
106
- usageStats: '—',
107
- }),
108
- getCliproxyStatusSummary: async () => ({
109
- app: 'cliproxy',
110
- http: 'OK',
111
- lastDeploy: '—',
112
- version: 'v1.2.3',
113
- contentHash: '—',
114
- usageStats: '12 req / 0 fail',
115
- }),
116
- getGatewayStatusSummary: async () => ({
117
- app: 'gateway',
118
- http: 'OK: gateway:running/healthy',
119
- lastDeploy: '—',
120
- version: '—',
121
- contentHash: '—',
122
- usageStats: '—',
123
- }),
124
- })
84
+ await unifiedStatusAction({json: true}, ctx, makeDeps())
125
85
 
126
- expect(lines).toHaveLength(1)
86
+ expect(captured.stdout).toHaveLength(1)
127
87
 
128
- const [jsonOutput] = lines
88
+ const [jsonOutput] = captured.stdout
129
89
  expect(jsonOutput).toBeDefined()
130
90
 
131
91
  const parsed = JSON.parse(jsonOutput ?? '{}') as {
132
92
  keeweb: {http: string}
133
93
  cliproxy: {version: string}
94
+ gateway: {http: string}
134
95
  }
135
96
 
136
97
  expect(parsed.keeweb.http).toBe('OK')
137
98
  expect(parsed.cliproxy.version).toBe('v1.2.3')
99
+ expect(parsed.gateway.http).toBe('OK: gateway:running/healthy')
100
+ })
101
+
102
+ it('does not write to global console (output is captured via ctx)', async () => {
103
+ const {ctx, captured} = createCapturedCtx()
104
+
105
+ await unifiedStatusAction({}, ctx, makeDeps())
106
+
107
+ // Verify output went to ctx capture, not global console
108
+ expect(captured.stdout.length).toBeGreaterThan(0)
109
+ expect(expectCapturedToInclude(captured, '| App |')).toBe(true)
110
+ })
111
+ })
112
+
113
+ describe('top-level status command (registerStatus wiring)', () => {
114
+ it('wires through goke without throwing', async () => {
115
+ const cli = goke('test')
116
+
117
+ registerStatus(cli, makeDeps())
118
+ cli.parse(['bun', 'test', 'status'], {run: false})
119
+
120
+ // Verify the command runs without throwing (goke uses its own ctx for output)
121
+ await expect(cli.runMatchedCommand()).resolves.toBeUndefined()
138
122
  })
139
123
  })
@@ -1,5 +1,7 @@
1
1
  import type {goke} from 'goke'
2
2
 
3
+ import type {ActionCtx} from '../lib/action-ctx'
4
+
3
5
  import {z} from 'zod'
4
6
 
5
7
  import {getCliproxyStatusSummary} from './cliproxy/status'
@@ -69,6 +71,49 @@ function formatRow(row: StatusSummary): string {
69
71
  return `| ${values.join(' | ')} |`
70
72
  }
71
73
 
74
+ export async function unifiedStatusAction(
75
+ options: {json?: boolean; verbose?: boolean},
76
+ ctx: ActionCtx,
77
+ dependencies: StatusDependencies = {
78
+ getKeewebStatusSummary,
79
+ getCliproxyStatusSummary,
80
+ getGatewayStatusSummary,
81
+ },
82
+ ): Promise<void> {
83
+ const verbose = options.verbose === true
84
+ const cliproxyBaseUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
85
+ const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
86
+ const gatewayHost = process.env.GATEWAY_HOST ?? ''
87
+
88
+ const results = await Promise.allSettled([
89
+ dependencies.getKeewebStatusSummary(verbose),
90
+ dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
91
+ dependencies.getGatewayStatusSummary(gatewayHost),
92
+ ])
93
+
94
+ const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway']
95
+ const rows: StatusSummary[] = results.map((result, index) => {
96
+ const app = appNames[index] ?? 'keeweb'
97
+ if (result.status === 'fulfilled') {
98
+ return result.value
99
+ }
100
+ const reason = result.reason
101
+ const message = reason instanceof Error ? reason.message : String(reason)
102
+ ctx.console.error(`${app} status check failed: ${message}`)
103
+ return errorSummary(app, reason)
104
+ })
105
+
106
+ if (options.json === true) {
107
+ ctx.console.log(JSON.stringify(toJsonPayload(rows)))
108
+ return
109
+ }
110
+
111
+ ctx.console.log('| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |')
112
+ for (const row of rows) {
113
+ ctx.console.log(formatRow(row))
114
+ }
115
+ }
116
+
72
117
  export function registerStatus(
73
118
  cli: ReturnType<typeof goke>,
74
119
  dependencies: StatusDependencies = {
@@ -87,32 +132,5 @@ export function registerStatus(
87
132
  '--verbose',
88
133
  z.boolean().describe('Include verbose per-app health check details when building the summary rows.'),
89
134
  )
90
- .action(async options => {
91
- const verbose = options.verbose === true
92
- const cliproxyBaseUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
93
- const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
94
- const gatewayHost = process.env.GATEWAY_HOST ?? ''
95
-
96
- const results = await Promise.allSettled([
97
- dependencies.getKeewebStatusSummary(verbose),
98
- dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
99
- dependencies.getGatewayStatusSummary(gatewayHost),
100
- ])
101
-
102
- const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway']
103
- const rows: StatusSummary[] = results.map((result, index) => {
104
- const app = appNames[index] ?? 'keeweb'
105
- return result.status === 'fulfilled' ? result.value : errorSummary(app, result.reason)
106
- })
107
-
108
- if (options.json === true) {
109
- console.log(JSON.stringify(toJsonPayload(rows)))
110
- return
111
- }
112
-
113
- console.log('| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |')
114
- for (const row of rows) {
115
- console.log(formatRow(row))
116
- }
117
- })
135
+ .action((options, ctx) => unifiedStatusAction(options, ctx, dependencies))
118
136
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Minimal `ctx` interface every MCP-capturable action accepts.
3
+ *
4
+ * Structural subtype of goke's real `GokeExecutionContext` — actions
5
+ * consume only `console.{log,error}`, `process.{stdout,stderr}.write`,
6
+ * and `process.exit`, never the `fs` surface. Source files import this
7
+ * shape from this module so the action contract is defined in one place.
8
+ *
9
+ * The `write` parameter is `string` (narrower than goke's real
10
+ * `string | Uint8Array`) because every action passes string values;
11
+ * `CapturedCtx.write(string | Uint8Array)` is still assignable to
12
+ * `ActionCtx.write(string)` via contravariance, so tests using
13
+ * `CapturedCtx` satisfy this interface.
14
+ */
15
+ export interface ActionCtx {
16
+ console: {
17
+ log: (...args: unknown[]) => void
18
+ error: (...args: unknown[]) => void
19
+ }
20
+ process: {
21
+ stdout: {write: (chunk: string) => void}
22
+ stderr: {write: (chunk: string) => void}
23
+ exit: (code: number) => never | void
24
+ }
25
+ }