@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,5 +1,6 @@
1
1
  import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from 'bun:test'
2
2
 
3
+ import {createCapturedCtx, expectCapturedToInclude} from '../../__test__/mcp-ctx-fixture'
3
4
  import {
4
5
  checkContentHash,
5
6
  checkHttpReachability,
@@ -7,6 +8,7 @@ import {
7
8
  formatDate,
8
9
  formatDurationMs,
9
10
  hashSha256,
11
+ keewebStatusAction,
10
12
  } from './status'
11
13
 
12
14
  const originalFetch = globalThis.fetch
@@ -210,3 +212,147 @@ describe('keeweb status helpers', () => {
210
212
  })
211
213
  })
212
214
  })
215
+
216
+ describe('keewebStatusAction (Tier-2 ctx capture)', () => {
217
+ let fetchMock: ReturnType<typeof mock<typeof fetch>>
218
+ let fileSpy: ReturnType<typeof spyOn<typeof Bun, 'file'>> | undefined
219
+ let spawnSpy: ReturnType<typeof spyOn<typeof Bun, 'spawn'>> | undefined
220
+
221
+ beforeEach(() => {
222
+ fetchMock = mock(
223
+ createFetchImplementation(async () => {
224
+ throw new Error('Unexpected fetch call')
225
+ }),
226
+ )
227
+ globalThis.fetch = Object.assign(fetchMock, {preconnect: originalFetch.preconnect})
228
+ })
229
+
230
+ afterEach(() => {
231
+ globalThis.fetch = originalFetch
232
+ fileSpy?.mockRestore()
233
+ fileSpy = undefined
234
+ spawnSpy?.mockRestore()
235
+ spawnSpy = undefined
236
+ })
237
+
238
+ it('writes "KeeWeb status" header to ctx.console.log (not global console)', async () => {
239
+ // Mock HTTP 200
240
+ fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
241
+
242
+ // Mock gh CLI returning a successful run
243
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
244
+ mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
245
+ )
246
+
247
+ // Mock local dist file as missing (avoids filesystem dependency)
248
+ fileSpy = spyOn(Bun, 'file').mockImplementation(
249
+ () =>
250
+ ({
251
+ exists: async () => false,
252
+ }) as ReturnType<typeof Bun.file>,
253
+ )
254
+
255
+ const {ctx, captured} = createCapturedCtx()
256
+
257
+ await keewebStatusAction({verbose: false}, ctx)
258
+
259
+ // 'KeeWeb status' is the header line in the formatted output — the contract
260
+ // marker confirming the action printed to ctx.console.log (captured) rather
261
+ // than the real global console (which would not appear in `captured`).
262
+ expect(expectCapturedToInclude(captured, 'KeeWeb status')).toBe(true)
263
+ // Summary footer is always emitted; pairing it with the header proves both
264
+ // ends of the formatted output flow through ctx capture.
265
+ expect(expectCapturedToInclude(captured, 'Summary:')).toBe(true)
266
+ })
267
+
268
+ it('writes HTTP check result to captured stdout', async () => {
269
+ fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
270
+
271
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
272
+ mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
273
+ )
274
+
275
+ fileSpy = spyOn(Bun, 'file').mockImplementation(
276
+ () =>
277
+ ({
278
+ exists: async () => false,
279
+ }) as ReturnType<typeof Bun.file>,
280
+ )
281
+
282
+ const {ctx, captured} = createCapturedCtx()
283
+
284
+ await keewebStatusAction({verbose: false}, ctx)
285
+
286
+ expect(expectCapturedToInclude(captured, 'HTTP reachability')).toBe(true)
287
+ expect(expectCapturedToInclude(captured, '[OK]')).toBe(true)
288
+ })
289
+
290
+ it('writes summary line to captured stdout', async () => {
291
+ fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
292
+
293
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
294
+ mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
295
+ )
296
+
297
+ fileSpy = spyOn(Bun, 'file').mockImplementation(
298
+ () =>
299
+ ({
300
+ exists: async () => false,
301
+ }) as ReturnType<typeof Bun.file>,
302
+ )
303
+
304
+ const {ctx, captured} = createCapturedCtx()
305
+
306
+ await keewebStatusAction({verbose: false}, ctx)
307
+
308
+ expect(expectCapturedToInclude(captured, 'Summary:')).toBe(true)
309
+ expect(expectCapturedToInclude(captured, '3 checks')).toBe(true)
310
+ })
311
+
312
+ it('calls ctx.process.exit(1) when there are errors', async () => {
313
+ // HTTP 500 → error level
314
+ fetchMock.mockImplementation(createFetchImplementation(async () => new Response('error', {status: 500})))
315
+
316
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
317
+ mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
318
+ )
319
+
320
+ fileSpy = spyOn(Bun, 'file').mockImplementation(
321
+ () =>
322
+ ({
323
+ exists: async () => false,
324
+ }) as ReturnType<typeof Bun.file>,
325
+ )
326
+
327
+ const {ctx, captured} = createCapturedCtx()
328
+
329
+ // ctx.process.exit throws MockProcessExit — catch it
330
+ await expect(keewebStatusAction({verbose: false}, ctx)).rejects.toMatchObject({
331
+ name: 'MockProcessExit',
332
+ code: 1,
333
+ })
334
+
335
+ expect(captured.exit).toEqual({code: 1})
336
+ })
337
+
338
+ it('does not call ctx.process.exit when all checks pass', async () => {
339
+ fetchMock.mockImplementation(createFetchImplementation(async () => new Response('ok', {status: 200})))
340
+
341
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() =>
342
+ mockSpawnResult(0, JSON.stringify([{createdAt: '2026-01-15T10:30:00Z', url: 'https://example.com'}])),
343
+ )
344
+
345
+ fileSpy = spyOn(Bun, 'file').mockImplementation(
346
+ () =>
347
+ ({
348
+ exists: async () => false,
349
+ }) as ReturnType<typeof Bun.file>,
350
+ )
351
+
352
+ const {ctx, captured} = createCapturedCtx()
353
+
354
+ await keewebStatusAction({verbose: false}, ctx)
355
+
356
+ expect(captured.exit).toBeNull()
357
+ })
358
+ })
@@ -1,13 +1,11 @@
1
1
  import type {goke} from 'goke'
2
+
3
+ import type {ActionCtx} from '../../lib/action-ctx'
2
4
  import type {StatusSummary} from '../status'
3
5
 
4
6
  import path from 'node:path'
5
7
  import {z} from 'zod'
6
8
 
7
- declare const process: {
8
- exitCode?: number
9
- }
10
-
11
9
  const SITE_URL = 'https://kw.igg.ms/'
12
10
  const GH_REPO = 'marcusrbrown/infra'
13
11
  const HTTP_TIMEOUT_MS = 10_000
@@ -254,13 +252,13 @@ export async function checkContentHash(verbose: boolean): Promise<CheckResult> {
254
252
  }
255
253
  }
256
254
 
257
- function printCheckResult(result: CheckResult): void {
258
- console.log(`[${levelLabel(result.level)}] ${result.title}`)
259
- console.log(` ${result.summary}`)
255
+ function printCheckResult(result: CheckResult, ctx: ActionCtx): void {
256
+ ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`)
257
+ ctx.console.log(` ${result.summary}`)
260
258
 
261
259
  if (result.details && result.details.length > 0) {
262
260
  for (const detail of result.details) {
263
- console.log(` - ${detail}`)
261
+ ctx.console.log(` - ${detail}`)
264
262
  }
265
263
  }
266
264
  }
@@ -286,34 +284,36 @@ export async function getKeewebStatusSummary(verbose: boolean): Promise<StatusSu
286
284
  }
287
285
  }
288
286
 
289
- export function registerKeewebStatus(cli: ReturnType<typeof goke>): void {
290
- cli
291
- .command('keeweb status', 'Show operational health of the KeeWeb deployment')
292
- .option('--verbose', 'Enable verbose output for all commands')
293
- .action(async options => {
294
- const verbose = options.verbose === true
287
+ export async function keewebStatusAction(options: {verbose?: boolean}, ctx: ActionCtx): Promise<void> {
288
+ const verbose = options.verbose === true
289
+
290
+ ctx.console.log('KeeWeb status')
291
+ ctx.console.log('')
295
292
 
296
- console.log('KeeWeb status')
297
- console.log('')
293
+ const results = await Promise.all([
294
+ checkHttpReachability(verbose),
295
+ checkLastDeploy(verbose),
296
+ checkContentHash(verbose),
297
+ ])
298
298
 
299
- const results = await Promise.all([
300
- checkHttpReachability(verbose),
301
- checkLastDeploy(verbose),
302
- checkContentHash(verbose),
303
- ])
299
+ for (const result of results) {
300
+ printCheckResult(result, ctx)
301
+ ctx.console.log('')
302
+ }
304
303
 
305
- for (const result of results) {
306
- printCheckResult(result)
307
- console.log('')
308
- }
304
+ const errorCount = results.filter(result => result.level === 'error').length
305
+ const warningCount = results.filter(result => result.level === 'warning').length
309
306
 
310
- const errorCount = results.filter(result => result.level === 'error').length
311
- const warningCount = results.filter(result => result.level === 'warning').length
307
+ ctx.console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`)
312
308
 
313
- console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`)
309
+ if (errorCount > 0) {
310
+ ctx.process.exit(1)
311
+ }
312
+ }
314
313
 
315
- if (errorCount > 0) {
316
- process.exitCode = 1
317
- }
318
- })
314
+ export function registerKeewebStatus(cli: ReturnType<typeof goke>): void {
315
+ cli
316
+ .command('keeweb status', 'Show operational health of the KeeWeb deployment')
317
+ .option('--verbose', 'Enable verbose output for all commands')
318
+ .action(keewebStatusAction)
319
319
  }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Tier-1 in-process MCP integration test.
3
+ *
4
+ * Uses @modelcontextprotocol/sdk's InMemoryTransport to spin up the MCP server
5
+ * in-process (no subprocess) and assert the tool list + call contracts.
6
+ *
7
+ * // FALLBACK: If InMemoryTransport proves incompatible with @goke/mcp@0.0.10's
8
+ * // transport injection in a future SDK version, switch to subprocess mode:
9
+ * //
10
+ * // const proc = Bun.spawn(['bun', 'run', 'packages/cli/src/cli.ts', 'mcp'], {
11
+ * // stdin: 'pipe',
12
+ * // stdout: 'pipe',
13
+ * // stderr: 'pipe',
14
+ * // })
15
+ * // const transport = new StdioClientTransport({
16
+ * // readable: proc.stdout,
17
+ * // writable: proc.stdin,
18
+ * // })
19
+ * // const client = new Client({name: 'test-client', version: '1.0.0'}, {capabilities: {}})
20
+ * // await client.connect(transport)
21
+ * // // ... same assertions below ...
22
+ * // await client.close()
23
+ * // proc.kill()
24
+ */
25
+
26
+ import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js'
27
+
28
+ import {createMcpAction} from '@goke/mcp'
29
+ import {Client} from '@modelcontextprotocol/sdk/client'
30
+ import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'
31
+ import {CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js'
32
+ import {afterAll, beforeAll, describe, expect, test} from 'bun:test'
33
+ import {goke} from 'goke'
34
+
35
+ import {registerCliproxyCommands} from './cliproxy'
36
+ import {registerGatewayCommands} from './gateway'
37
+ import {registerKeewebCommands} from './keeweb'
38
+ import {MCP_ALLOWLIST, registerMcp} from './mcp'
39
+ import {registerStatus} from './status'
40
+
41
+ // ─── Tool name constants ──────────────────────────────────────────────────────
42
+
43
+ /** Derived from the production MCP_ALLOWLIST — spaces replaced by underscores. */
44
+ const EXPECTED_TOOLS = [...MCP_ALLOWLIST].map(name => name.replaceAll(' ', '_')).sort()
45
+
46
+ /** The 9 CLI-only commands that must NOT appear in the MCP tool list. */
47
+ const CLI_ONLY_TOOLS = [
48
+ 'cliproxy_deploy',
49
+ 'cliproxy_login',
50
+ 'cliproxy_open',
51
+ 'cliproxy_setup',
52
+ 'gateway_deploy',
53
+ 'gateway_logs',
54
+ 'gateway_restore',
55
+ 'keeweb_deploy',
56
+ 'keeweb_open',
57
+ ].sort()
58
+
59
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
60
+
61
+ function buildTestCli(): ReturnType<typeof goke> {
62
+ const cli = goke('infra')
63
+ cli.option('--verbose', 'Enable verbose output for all commands')
64
+ registerKeewebCommands(cli)
65
+ registerCliproxyCommands(cli)
66
+ registerGatewayCommands(cli)
67
+ registerStatus(cli)
68
+ registerMcp(cli)
69
+ return cli
70
+ }
71
+
72
+ // ─── Suite ────────────────────────────────────────────────────────────────────
73
+
74
+ describe('mcp integration (Tier-1, in-process)', () => {
75
+ let client: Client
76
+
77
+ beforeAll(async () => {
78
+ const cli = buildTestCli()
79
+
80
+ // Simulate goke having matched the 'mcp' command so createMcpAction
81
+ // auto-excludes it from the tool list (it reads cli.matchedCommandName).
82
+ cli.matchedCommandName = 'mcp'
83
+
84
+ const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair()
85
+
86
+ // createMcpAction supports a custom createTransport factory (Option A).
87
+ // We inject the in-memory server transport so no stdio is involved.
88
+ const mcpAction = createMcpAction({
89
+ cli,
90
+ commandFilter: (name: string) => MCP_ALLOWLIST.has(name),
91
+ createTransport: () => serverTransport,
92
+ })
93
+
94
+ // Start the server (non-blocking — server.connect() resolves once the
95
+ // transport handshake completes, then the server listens for requests).
96
+ await mcpAction()
97
+
98
+ client = new Client({name: 'test-client', version: '1.0.0'}, {capabilities: {}})
99
+ await client.connect(clientTransport)
100
+ })
101
+
102
+ afterAll(async () => {
103
+ await client?.close()
104
+ })
105
+
106
+ // ── tools/list assertions ──────────────────────────────────────────────────
107
+
108
+ test('tools/list returns exactly the 10 allowlist tool names', async () => {
109
+ const result = await client.listTools()
110
+ const names = result.tools.map((t: {name: string}) => t.name).sort()
111
+ expect(names).toEqual(EXPECTED_TOOLS)
112
+ })
113
+
114
+ test('cli-only commands are absent from the tool list', async () => {
115
+ const result = await client.listTools()
116
+ const names = new Set(result.tools.map((t: {name: string}) => t.name))
117
+ for (const excluded of CLI_ONLY_TOOLS) {
118
+ expect(names.has(excluded)).toBe(false)
119
+ }
120
+ })
121
+
122
+ // ── Mode B contract: gateway_backup ───────────────────────────────────────
123
+ //
124
+ // gateway_backup tries to SSH to a real droplet, which is unreachable in CI.
125
+ // We assert the contract: the tool returns a non-empty CallToolResult.
126
+ // In CI the result will be an error CallToolResult (isError: true) because
127
+ // SSH fails — that's fine; the contract is that the MCP layer wraps the
128
+ // error and returns content rather than throwing.
129
+
130
+ test('gateway_backup returns a non-empty CallToolResult (Mode B contract)', async () => {
131
+ const rawResult = await client.callTool(
132
+ {
133
+ name: 'gateway_backup',
134
+ arguments: {},
135
+ },
136
+ CallToolResultSchema,
137
+ )
138
+ // Parse through the schema to get a properly typed result.
139
+ // The tool must return at least one content block (even on SSH failure).
140
+ const result: CallToolResult = CallToolResultSchema.parse(rawResult)
141
+ expect(result.content.length).toBeGreaterThan(0)
142
+ })
143
+
144
+ // ── Mode C contract: cliproxy_keys_list ───────────────────────────────────
145
+ //
146
+ // Mode C: when a command returns structured data AND prints to stdout,
147
+ // the CallToolResult must contain BOTH a stdout text block AND a
148
+ // stringified return-value text block.
149
+ //
150
+ // Re-enable after Unit 4 lands (cliproxy keys list refactor to return
151
+ // structured data alongside ctx-printed text).
152
+
153
+ // Re-enable after Unit 4 lands (cliproxy keys list refactor to return
154
+ // structured data alongside ctx-printed text).
155
+ test('cliproxy_keys_list returns BOTH stdout block AND structured return block (Mode C contract)', async () => {
156
+ const originalFetch = globalThis.fetch
157
+ const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
158
+
159
+ try {
160
+ process.env.CLIPROXY_MANAGEMENT_KEY = 'test-management-key'
161
+
162
+ globalThis.fetch = (async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
163
+ return new Response(JSON.stringify(['fro-bot-test1', 'fro-bot-test2']), {
164
+ status: 200,
165
+ headers: {'content-type': 'application/json'},
166
+ })
167
+ }) as typeof fetch
168
+
169
+ const rawResult = await client.callTool(
170
+ {
171
+ name: 'cliproxy_keys_list',
172
+ arguments: {},
173
+ },
174
+ CallToolResultSchema,
175
+ )
176
+
177
+ const result: CallToolResult = CallToolResultSchema.parse(rawResult)
178
+
179
+ // Mode C contract: at least 2 content blocks (stdout text + structured return)
180
+ expect(result.content.length).toBeGreaterThanOrEqual(2)
181
+
182
+ const allText = result.content
183
+ .filter((block): block is {type: 'text'; text: string} => block.type === 'text')
184
+ .map(block => block.text)
185
+ .join('\n')
186
+
187
+ // One block must contain the formatted stdout (key names printed by the action)
188
+ expect(allText).toContain('fro-bot-test1')
189
+
190
+ // One block must contain the stringified return value (JSON array of key names)
191
+ const hasStructuredReturn = result.content.some(block => {
192
+ if (block.type !== 'text') return false
193
+ try {
194
+ const parsed = JSON.parse(block.text) as unknown
195
+ return Array.isArray(parsed) && parsed.includes('fro-bot-test1') && parsed.includes('fro-bot-test2')
196
+ } catch {
197
+ return false
198
+ }
199
+ })
200
+ expect(hasStructuredReturn).toBe(true)
201
+ } finally {
202
+ globalThis.fetch = originalFetch
203
+ if (originalKey === undefined) {
204
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
205
+ } else {
206
+ process.env.CLIPROXY_MANAGEMENT_KEY = originalKey
207
+ }
208
+ }
209
+ })
210
+ })
@@ -1,8 +1,37 @@
1
1
  import type {goke} from 'goke'
2
2
  import {createMcpAction} from '@goke/mcp'
3
3
 
4
+ /**
5
+ * Commands intentionally excluded from MCP exposure (not in MCP_ALLOWLIST):
6
+ *
7
+ * - `gateway deploy` — subprocess streaming (Bun.spawn with stdout: 'inherit'), deferred to MCP v2 (#291)
8
+ * - `cliproxy deploy` — subprocess streaming, deferred to MCP v2 (#291)
9
+ * - `keeweb deploy` — subprocess streaming, deferred to MCP v2 (#291)
10
+ * - `gateway logs` — subprocess streaming, deferred to MCP v2 (#291)
11
+ * - `cliproxy login` — interactive (OAuth callback URL paste, requires TTY)
12
+ * - `cliproxy open` — interactive (SSH TUI session, requires TTY)
13
+ * - `cliproxy setup` — interactive (@clack/prompts wizard, requires TTY)
14
+ * - `gateway restore` — destructive policy (replaces mitmproxy CA on live gateway, deferred to MCP v2 #292)
15
+ * - `keeweb open` — host-machine side effect (spawns local browser, requires user intent)
16
+ */
17
+ export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
18
+ 'gateway status',
19
+ 'gateway backup',
20
+ 'cliproxy status',
21
+ 'cliproxy keys list',
22
+ 'cliproxy keys add',
23
+ 'cliproxy keys remove',
24
+ 'cliproxy config get',
25
+ 'cliproxy config set',
26
+ 'keeweb status',
27
+ 'status',
28
+ ])
29
+
4
30
  export function registerMcp(cli: ReturnType<typeof goke>): void {
5
- cli
6
- .command('mcp', 'Start a stdio MCP server exposing all CLI commands as tools for coding agents')
7
- .action(createMcpAction({cli}))
31
+ cli.command('mcp', 'Start a stdio MCP server exposing all CLI commands as tools for coding agents').action(
32
+ createMcpAction({
33
+ cli,
34
+ commandFilter: name => MCP_ALLOWLIST.has(name),
35
+ }),
36
+ )
8
37
  }