@marcusrbrown/infra 0.4.11 → 0.6.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/__snapshots__/cli.test.ts.snap +1 -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/host.test.ts +73 -0
- package/src/commands/cliproxy/host.ts +31 -0
- package/src/commands/cliproxy/keys.test.ts +326 -0
- package/src/commands/cliproxy/keys.ts +116 -60
- package/src/commands/cliproxy/login.test.ts +203 -49
- package/src/commands/cliproxy/login.ts +83 -56
- package/src/commands/cliproxy/open.test.ts +20 -0
- package/src/commands/cliproxy/open.ts +4 -1
- 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,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
|
}
|
|
@@ -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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
297
|
-
|
|
293
|
+
const results = await Promise.all([
|
|
294
|
+
checkHttpReachability(verbose),
|
|
295
|
+
checkLastDeploy(verbose),
|
|
296
|
+
checkContentHash(verbose),
|
|
297
|
+
])
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
])
|
|
299
|
+
for (const result of results) {
|
|
300
|
+
printCheckResult(result, ctx)
|
|
301
|
+
ctx.console.log('')
|
|
302
|
+
}
|
|
304
303
|
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
+
if (errorCount > 0) {
|
|
310
|
+
ctx.process.exit(1)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
314
313
|
|
|
315
|
-
|
|
316
|
-
|
|
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
|
+
})
|
package/src/commands/mcp.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
}
|