@marcusrbrown/infra 0.13.0 → 0.13.2

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.
Files changed (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,359 +0,0 @@
1
- import type {goke} from 'goke'
2
-
3
- import type {ActionCtx} from '../../lib/action-ctx'
4
- import type {StatusSummary} from '../status'
5
-
6
- import path from 'node:path'
7
- import {z} from 'zod'
8
-
9
- const SITE_URL = 'https://kw.igg.ms/'
10
- const GH_REPO = 'marcusrbrown/infra'
11
- const HTTP_TIMEOUT_MS = 10_000
12
-
13
- type CheckLevel = 'ok' | 'info' | 'warning' | 'error'
14
-
15
- interface CheckResult {
16
- title: string
17
- level: CheckLevel
18
- summary: string
19
- details?: string[]
20
- }
21
-
22
- const ghRunSchema = z.array(
23
- z.object({
24
- createdAt: z.string().min(1),
25
- url: z.url(),
26
- }),
27
- )
28
-
29
- function levelLabel(level: CheckLevel): string {
30
- if (level === 'ok') {
31
- return 'OK'
32
- }
33
-
34
- if (level === 'info') {
35
- return 'INFO'
36
- }
37
-
38
- if (level === 'warning') {
39
- return 'WARN'
40
- }
41
-
42
- return 'ERROR'
43
- }
44
-
45
- export function formatDurationMs(durationMs: number): string {
46
- return `${Math.max(0, Math.round(durationMs))}ms`
47
- }
48
-
49
- export function formatDate(value: string): string {
50
- const date = new Date(value)
51
- if (Number.isNaN(date.getTime())) {
52
- return value
53
- }
54
-
55
- return date.toLocaleString()
56
- }
57
-
58
- export function hashSha256(value: string): string {
59
- const hasher = new Bun.CryptoHasher('sha256')
60
- hasher.update(value)
61
- return hasher.digest('hex')
62
- }
63
-
64
- async function streamToText(stream?: ReadableStream<Uint8Array> | null): Promise<string> {
65
- if (!stream) {
66
- return ''
67
- }
68
-
69
- return await new Response(stream).text()
70
- }
71
-
72
- export async function checkHttpReachability(verbose: boolean): Promise<CheckResult> {
73
- const startedAt = performance.now()
74
-
75
- try {
76
- const response = await fetch(SITE_URL, {
77
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
78
- })
79
- const elapsedMs = performance.now() - startedAt
80
- const details: string[] = []
81
-
82
- if (verbose) {
83
- details.push(`URL: ${SITE_URL}`)
84
- details.push(`Status text: ${response.statusText || '(none)'}`)
85
-
86
- if (response.headers.get('content-type')) {
87
- details.push(`Content-Type: ${response.headers.get('content-type')}`)
88
- }
89
-
90
- if (response.headers.get('server')) {
91
- details.push(`Server: ${response.headers.get('server')}`)
92
- }
93
- }
94
-
95
- return {
96
- title: 'HTTP reachability',
97
- level: response.ok ? 'ok' : 'error',
98
- summary: `GET ${SITE_URL} → ${response.status} (${formatDurationMs(elapsedMs)})`,
99
- details,
100
- }
101
- } catch (error) {
102
- const elapsedMs = performance.now() - startedAt
103
- const message = error instanceof Error ? error.message : String(error)
104
-
105
- return {
106
- title: 'HTTP reachability',
107
- level: 'error',
108
- summary: `Request failed after ${formatDurationMs(elapsedMs)}: ${message}`,
109
- details: verbose ? [`URL: ${SITE_URL}`, `Timeout: ${HTTP_TIMEOUT_MS}ms`] : undefined,
110
- }
111
- }
112
- }
113
-
114
- export async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
115
- try {
116
- const proc = Bun.spawn(
117
- [
118
- 'gh',
119
- 'run',
120
- 'list',
121
- '--workflow=Deploy KeeWeb',
122
- '--status=success',
123
- '--limit=1',
124
- '--json',
125
- 'createdAt,url',
126
- '--repo',
127
- GH_REPO,
128
- ],
129
- {
130
- stderr: 'pipe',
131
- stdout: 'pipe',
132
- },
133
- )
134
-
135
- const [exitCode, stdout, stderr] = await Promise.all([
136
- proc.exited,
137
- streamToText(proc.stdout),
138
- streamToText(proc.stderr),
139
- ])
140
-
141
- if (exitCode !== 0) {
142
- const baseDetails = stderr.trim() ? [stderr.trim()] : []
143
- const verboseDetails = verbose && stdout.trim() ? [...baseDetails, `Raw stdout: ${stdout.trim()}`] : baseDetails
144
-
145
- return {
146
- title: 'Last successful deploy',
147
- level: 'warning',
148
- summary: `Unable to query GitHub Actions (gh exited ${exitCode})`,
149
- details: verboseDetails,
150
- }
151
- }
152
-
153
- const parsed = ghRunSchema.safeParse(JSON.parse(stdout))
154
- if (!parsed.success) {
155
- return {
156
- title: 'Last successful deploy',
157
- level: 'warning',
158
- summary: 'GitHub CLI output did not match expected schema',
159
- details: verbose ? [parsed.error.message, `Raw stdout: ${stdout.trim()}`] : undefined,
160
- }
161
- }
162
-
163
- const [latestRun] = parsed.data
164
-
165
- if (!latestRun) {
166
- return {
167
- title: 'Last successful deploy',
168
- level: 'warning',
169
- summary: 'No successful Deploy KeeWeb workflow runs found',
170
- }
171
- }
172
-
173
- const details = [`Run URL: ${latestRun.url}`]
174
-
175
- if (verbose) {
176
- details.push(`Raw createdAt: ${latestRun.createdAt}`)
177
- }
178
-
179
- return {
180
- title: 'Last successful deploy',
181
- level: 'ok',
182
- summary: `${formatDate(latestRun.createdAt)}`,
183
- details,
184
- }
185
- } catch (error) {
186
- const message = error instanceof Error ? error.message : String(error)
187
-
188
- return {
189
- title: 'Last successful deploy',
190
- level: 'warning',
191
- summary: `Unable to query GitHub Actions: ${message}`,
192
- details: verbose
193
- ? ['Requires GitHub CLI authenticated with access to marcusrbrown/infra', 'Install: https://cli.github.com/']
194
- : undefined,
195
- }
196
- }
197
- }
198
-
199
- /**
200
- * Detect whether moduleDir belongs to a source checkout of this monorepo.
201
- *
202
- * A source checkout has the repo root at 5 levels above the keeweb command
203
- * directory (packages/cli/src/commands/keeweb → repo root). We confirm the
204
- * layout by checking for a source-only marker file that is never shipped in
205
- * the CLI package. This is more robust than checking for `node_modules` in
206
- * the path, which misses unpacked tarballs and global-style installs.
207
- */
208
- async function isSourceCheckout(resolvedModuleDir: string): Promise<boolean> {
209
- // Candidate repo root is 5 levels up from packages/cli/src/commands/keeweb
210
- const candidateRoot = path.resolve(resolvedModuleDir, '../../../../../')
211
- // apps/keeweb/src/build.ts is a source-only file never included in the CLI package
212
- const sourceMarker = path.join(candidateRoot, 'apps', 'keeweb', 'src', 'build.ts')
213
- return Bun.file(sourceMarker).exists()
214
- }
215
-
216
- export async function checkContentHash(verbose: boolean, moduleDir?: string): Promise<CheckResult> {
217
- const resolvedModuleDir = moduleDir ?? import.meta.dir
218
- const distIndexPath = path.resolve(resolvedModuleDir, '../../../../../apps/keeweb/dist/index.html')
219
-
220
- // When running from a packaged install (bunx, npm global, unpacked tarball, etc.),
221
- // the resolved path has no repo apps/keeweb source tree — the KeeWeb dist is not
222
- // shipped with the CLI package. Emit a clean, non-path-noisy message instead of
223
- // a scary temp path. We detect source checkout by looking for a source-only marker
224
- // file rather than relying on `node_modules` in the path (which misses tarballs).
225
- const sourceCheckout = await isSourceCheckout(resolvedModuleDir)
226
- if (!sourceCheckout) {
227
- return {
228
- title: 'Content hash',
229
- level: 'info',
230
- summary: 'Content hash not available: local KeeWeb dist is only present in a source checkout',
231
- details: verbose
232
- ? ['Clone the repo and run: bun run --cwd apps/keeweb build', 'Then re-run status from the source checkout']
233
- : undefined,
234
- }
235
- }
236
-
237
- const localFile = Bun.file(distIndexPath)
238
- const localExists = await localFile.exists()
239
-
240
- if (!localExists) {
241
- return {
242
- title: 'Content hash',
243
- level: 'warning',
244
- summary: `Local dist file not found: ${distIndexPath}`,
245
- details: ['Run: bun run --cwd apps/keeweb build'],
246
- }
247
- }
248
-
249
- try {
250
- const response = await fetch(SITE_URL, {
251
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
252
- })
253
-
254
- if (!response.ok) {
255
- return {
256
- title: 'Content hash',
257
- level: 'warning',
258
- summary: `Could not fetch remote index.html (HTTP ${response.status})`,
259
- details: verbose ? [`URL: ${SITE_URL}`] : undefined,
260
- }
261
- }
262
-
263
- const [remoteBody, localBody] = await Promise.all([response.text(), localFile.text()])
264
- const remoteHash = hashSha256(remoteBody)
265
- const localHash = hashSha256(localBody)
266
-
267
- const details = verbose ? [`Remote SHA-256: ${remoteHash}`, `Local SHA-256: ${localHash}`] : undefined
268
-
269
- if (remoteHash === localHash) {
270
- return {
271
- title: 'Content hash',
272
- level: 'ok',
273
- summary: 'Remote index.html matches local dist/index.html',
274
- details,
275
- }
276
- }
277
-
278
- return {
279
- title: 'Content hash',
280
- level: 'warning',
281
- summary: 'Remote index.html differs from local dist/index.html',
282
- details,
283
- }
284
- } catch (error) {
285
- const message = error instanceof Error ? error.message : String(error)
286
- return {
287
- title: 'Content hash',
288
- level: 'warning',
289
- summary: `Could not compare hashes: ${message}`,
290
- details: verbose ? [`URL: ${SITE_URL}`, `Local path: ${distIndexPath}`] : undefined,
291
- }
292
- }
293
- }
294
-
295
- function printCheckResult(result: CheckResult, ctx: ActionCtx): void {
296
- ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`)
297
- ctx.console.log(` ${result.summary}`)
298
-
299
- if (result.details && result.details.length > 0) {
300
- for (const detail of result.details) {
301
- ctx.console.log(` - ${detail}`)
302
- }
303
- }
304
- }
305
-
306
- function formatCheckSummary(result: CheckResult): string {
307
- return `${levelLabel(result.level)}: ${result.summary}`
308
- }
309
-
310
- export async function getKeewebStatusSummary(verbose: boolean): Promise<StatusSummary> {
311
- const [httpResult, lastDeployResult, contentHashResult] = await Promise.all([
312
- checkHttpReachability(verbose),
313
- checkLastDeploy(verbose),
314
- checkContentHash(verbose),
315
- ])
316
-
317
- return {
318
- app: 'keeweb',
319
- http: formatCheckSummary(httpResult),
320
- lastDeploy: formatCheckSummary(lastDeployResult),
321
- version: '—',
322
- contentHash: formatCheckSummary(contentHashResult),
323
- usageStats: '—',
324
- }
325
- }
326
-
327
- export async function keewebStatusAction(options: {verbose?: boolean}, ctx: ActionCtx): Promise<void> {
328
- const verbose = options.verbose === true
329
-
330
- ctx.console.log('KeeWeb status')
331
- ctx.console.log('')
332
-
333
- const results = await Promise.all([
334
- checkHttpReachability(verbose),
335
- checkLastDeploy(verbose),
336
- checkContentHash(verbose),
337
- ])
338
-
339
- for (const result of results) {
340
- printCheckResult(result, ctx)
341
- ctx.console.log('')
342
- }
343
-
344
- const errorCount = results.filter(result => result.level === 'error').length
345
- const warningCount = results.filter(result => result.level === 'warning').length
346
-
347
- ctx.console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`)
348
-
349
- if (errorCount > 0) {
350
- ctx.process.exit(1)
351
- }
352
- }
353
-
354
- export function registerKeewebStatus(cli: ReturnType<typeof goke>): void {
355
- cli
356
- .command('keeweb status', 'Show operational health of the KeeWeb deployment')
357
- .option('--verbose', 'Enable verbose output for all commands')
358
- .action(keewebStatusAction)
359
- }
@@ -1,260 +0,0 @@
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 {createMcpAction} from '@goke/mcp'
27
- import {Client} from '@modelcontextprotocol/sdk/client'
28
- import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js'
29
- import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'bun:test'
30
- import {goke} from 'goke'
31
-
32
- import {registerCliproxyCommands} from './cliproxy'
33
- import {registerDashboardCommands} from './dashboard'
34
- import {registerGatewayCommands} from './gateway'
35
- import {registerKeewebCommands} from './keeweb'
36
- import {MCP_ALLOWLIST, registerMcp} from './mcp'
37
- import {registerStatus} from './status'
38
- import {registerUmamiCommands} from './umami'
39
- import {registerVpnCommands} from './vpn'
40
-
41
- // ─── Tool name constants ──────────────────────────────────────────────────────
42
-
43
- /**
44
- * The MCP tool surface, pinned as an INDEPENDENT literal (deliberately NOT
45
- * derived from MCP_ALLOWLIST) so allowlist drift surfaces as a test failure —
46
- * per the Tier-1 test bar in packages/cli/AGENTS.md. Seven read-only tools.
47
- */
48
- const EXPECTED_TOOLS = [
49
- 'cliproxy_models',
50
- 'cliproxy_status',
51
- 'dashboard_status',
52
- 'gateway_status',
53
- 'keeweb_status',
54
- 'status',
55
- 'umami_status',
56
- 'vpn_status',
57
- ].sort()
58
-
59
- /**
60
- * CLI-only commands that must NOT appear in the MCP tool list: the
61
- * non-interactive/streaming-only commands plus the source-gated sensitive
62
- * commands (mutating or secret-disclosing).
63
- */
64
- const CLI_ONLY_TOOLS = [
65
- // Non-interactive / streaming — excluded since before the source-gate change
66
- 'cliproxy_deploy',
67
- 'cliproxy_login',
68
- 'cliproxy_open',
69
- 'cliproxy_setup',
70
- 'dashboard_deploy',
71
- 'dashboard_logs',
72
- 'gateway_deploy',
73
- 'gateway_logs',
74
- 'gateway_restore',
75
- 'keeweb_deploy',
76
- 'keeweb_open',
77
- 'umami_deploy',
78
- 'umami_logs',
79
- // Source-gated sensitive commands (mutating or secret-disclosing)
80
- 'gateway_backup',
81
- 'cliproxy_keys_list',
82
- 'cliproxy_keys_add',
83
- 'cliproxy_keys_remove',
84
- 'cliproxy_config_get',
85
- 'cliproxy_config_set',
86
- // VPN: mutating / sensitive / log-streaming — CLI-only
87
- 'vpn_deploy',
88
- 'vpn_logs',
89
- 'vpn_client_add',
90
- 'vpn_client_list',
91
- 'vpn_client_remove',
92
- ].sort()
93
-
94
- // ─── Helpers ──────────────────────────────────────────────────────────────────
95
-
96
- function buildTestCli(): ReturnType<typeof goke> {
97
- const cli = goke('infra')
98
- cli.option('--verbose', 'Enable verbose output for all commands')
99
- registerKeewebCommands(cli)
100
- registerCliproxyCommands(cli)
101
- registerGatewayCommands(cli)
102
- registerUmamiCommands(cli)
103
- registerDashboardCommands(cli)
104
- registerVpnCommands(cli)
105
- registerStatus(cli)
106
- registerMcp(cli)
107
- return cli
108
- }
109
-
110
- // ─── Suite ────────────────────────────────────────────────────────────────────
111
-
112
- describe('mcp integration (Tier-1, in-process)', () => {
113
- let client: Client
114
-
115
- beforeAll(async () => {
116
- const cli = buildTestCli()
117
-
118
- // Simulate goke having matched the 'mcp' command so createMcpAction
119
- // auto-excludes it from the tool list (it reads cli.matchedCommandName).
120
- cli.matchedCommandName = 'mcp'
121
-
122
- const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair()
123
-
124
- // createMcpAction supports a custom createTransport factory (Option A).
125
- // We inject the in-memory server transport so no stdio is involved.
126
- const mcpAction = createMcpAction({
127
- cli,
128
- commandFilter: (name: string) => MCP_ALLOWLIST.has(name),
129
- createTransport: () => serverTransport,
130
- })
131
-
132
- // Start the server (non-blocking — server.connect() resolves once the
133
- // transport handshake completes, then the server listens for requests).
134
- await mcpAction()
135
-
136
- client = new Client({name: 'test-client', version: '1.0.0'}, {capabilities: {}})
137
- await client.connect(clientTransport)
138
- })
139
-
140
- afterAll(async () => {
141
- await client?.close()
142
- })
143
-
144
- // ── tools/list assertions ──────────────────────────────────────────────────
145
-
146
- test('tools/list returns exactly the allowlist tool names', async () => {
147
- const result = await client.listTools()
148
- const names = result.tools.map((t: {name: string}) => t.name).sort()
149
- expect(names).toEqual(EXPECTED_TOOLS)
150
- })
151
-
152
- test('source-gated sensitive commands are absent from the MCP tool list', async () => {
153
- const result = await client.listTools()
154
- const names = new Set(result.tools.map((t: {name: string}) => t.name))
155
- for (const excluded of CLI_ONLY_TOOLS) {
156
- expect(names.has(excluded)).toBe(false)
157
- }
158
- })
159
- })
160
-
161
- // ─── cliproxy_models end-to-end call (Tier-1, in-process) ────────────────────
162
-
163
- const MODELS_RESPONSE = {
164
- object: 'list',
165
- data: [
166
- {id: 'claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000},
167
- {id: 'claude-sonnet-4-5', object: 'model', owned_by: 'anthropic', created: 1_715_000_000},
168
- {id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
169
- ],
170
- }
171
-
172
- type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
173
-
174
- function createFetchImplementation(handler: FetchReplacement): typeof fetch {
175
- const originalFetch = globalThis.fetch
176
- return Object.assign(
177
- (input: string | URL | Request, init?: RequestInit) => {
178
- if (typeof input !== 'string') {
179
- throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
180
- }
181
-
182
- return handler(input, init)
183
- },
184
- {preconnect: originalFetch.preconnect},
185
- )
186
- }
187
-
188
- describe('cliproxy_models MCP tool (Tier-1, in-process call)', () => {
189
- let callClient: Client
190
- const originalFetch = globalThis.fetch
191
- let savedApiKey: string | undefined
192
-
193
- beforeAll(async () => {
194
- const cli = buildTestCli()
195
- cli.matchedCommandName = 'mcp'
196
-
197
- const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair()
198
-
199
- const mcpAction = createMcpAction({
200
- cli,
201
- commandFilter: (name: string) => MCP_ALLOWLIST.has(name),
202
- createTransport: () => serverTransport,
203
- })
204
-
205
- await mcpAction()
206
-
207
- callClient = new Client({name: 'test-client-models', version: '1.0.0'}, {capabilities: {}})
208
- await callClient.connect(clientTransport)
209
- })
210
-
211
- afterAll(async () => {
212
- await callClient?.close()
213
- })
214
-
215
- beforeEach(() => {
216
- savedApiKey = process.env.CLIPROXY_API_KEY
217
- process.env.CLIPROXY_API_KEY = 'test-mcp-api-key'
218
- })
219
-
220
- afterEach(() => {
221
- globalThis.fetch = originalFetch
222
- if (savedApiKey === undefined) {
223
- delete process.env.CLIPROXY_API_KEY
224
- } else {
225
- process.env.CLIPROXY_API_KEY = savedApiKey
226
- }
227
- })
228
-
229
- test('listTools() includes cliproxy_models', async () => {
230
- const result = await callClient.listTools()
231
- const names = result.tools.map((t: {name: string}) => t.name)
232
- expect(names).toContain('cliproxy_models')
233
- })
234
-
235
- test('calling cliproxy_models returns captured model-list text with expected model ids', async () => {
236
- globalThis.fetch = createFetchImplementation(async url => {
237
- if (url.includes('/v1/models')) {
238
- return new Response(JSON.stringify(MODELS_RESPONSE), {
239
- status: 200,
240
- headers: {'content-type': 'application/json'},
241
- })
242
- }
243
-
244
- throw new Error(`Unexpected fetch: ${url}`)
245
- })
246
-
247
- const result = await callClient.callTool({name: 'cliproxy_models', arguments: {}})
248
-
249
- // MCP tool result is an array of content blocks; join text content for assertion
250
- const content = result.content as {type: string; text?: string}[]
251
- const text = content
252
- .filter(c => c.type === 'text')
253
- .map(c => c.text ?? '')
254
- .join('\n')
255
-
256
- expect(text).toContain('claude-opus-4')
257
- expect(text).toContain('claude-sonnet-4-5')
258
- expect(text).toContain('gpt-4o')
259
- })
260
- })
@@ -1,58 +0,0 @@
1
- import type {goke} from 'goke'
2
- import {createMcpAction} from '@goke/mcp'
3
-
4
- /**
5
- * Commands intentionally excluded from MCP exposure (not in MCP_ALLOWLIST):
6
- *
7
- * Infrastructure / streaming (deferred or not MCP-suitable):
8
- * - `gateway deploy` — subprocess streaming (Bun.spawn with stdout: 'inherit'), deferred to MCP v2 (#291)
9
- * - `cliproxy deploy` — subprocess streaming, deferred to MCP v2 (#291)
10
- * - `keeweb deploy` — subprocess streaming, deferred to MCP v2 (#291)
11
- * - `gateway logs` — subprocess streaming, deferred to MCP v2 (#291)
12
- * - `cliproxy login` — interactive (OAuth callback URL paste, requires TTY)
13
- * - `cliproxy open` — interactive (SSH TUI session, requires TTY)
14
- * - `cliproxy setup` — interactive (@clack/prompts wizard, requires TTY)
15
- * - `gateway restore` — destructive policy (replaces mitmproxy CA on live gateway, deferred to MCP v2 #292)
16
- * - `keeweb open` — host-machine side effect (spawns local browser, requires user intent)
17
- * - `umami deploy` — intentionally CLI-only: mutates live deployment and requires environment approval
18
- * - `umami logs` — intentionally CLI-only: streams logs that may emit sensitive data (DB passwords, app secrets)
19
- * - `dashboard deploy` — intentionally CLI-only: mutates live deployment and requires environment approval
20
- * - `dashboard logs` — intentionally CLI-only: streams logs that may emit sensitive data (DB passwords, app secrets)
21
- *
22
- * Source-gated sensitive commands (security — CLI-only, never MCP-exposed):
23
- * - `gateway backup` — secret-bearing: writes CA private key material to a tarball; CLI-only
24
- * - `cliproxy keys list` — secret-disclosing: prints live bearer tokens in plaintext; CLI-only
25
- * - `cliproxy keys add` — mutating: creates live bearer tokens on the proxy; CLI-only
26
- * - `cliproxy keys remove` — mutating: revokes live bearer tokens on the proxy; CLI-only
27
- * - `cliproxy config get` — secret-disclosing: dumps management config incl. management key; CLI-only
28
- * - `cliproxy config set` — mutating: overwrites CLIProxyAPI runtime config; CLI-only
29
- * - `vpn deploy` — mutating: deploys WireGuard config to live VPN box; CLI-only
30
- * - `vpn logs` — sensitive: streams journalctl logs that may reveal peer IPs/traffic; CLI-only
31
- * - `vpn client add` — mutating: generates keypair + appends peer + triggers redeploy; CLI-only
32
- * - `vpn client list` — sensitive: lists peer public keys and tunnel IPs; CLI-only
33
- * - `vpn client remove` — mutating: removes peer + triggers redeploy; CLI-only
34
- *
35
- * A defense-in-depth backstop also denies these in opencode.jsonc `permission`
36
- * so that even if MCP_ALLOWLIST were mistakenly re-expanded, opencode's native
37
- * tool permission check would block execution. Both layers are enforced by
38
- * the `gates every sensitive infra MCP tool` test in conventions.test.ts.
39
- */
40
- export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
41
- 'gateway status',
42
- 'cliproxy status',
43
- 'cliproxy models',
44
- 'keeweb status',
45
- 'umami status',
46
- 'dashboard status',
47
- 'vpn status',
48
- 'status',
49
- ])
50
-
51
- export function registerMcp(cli: ReturnType<typeof goke>): void {
52
- cli.command('mcp', 'Start a stdio MCP server exposing all CLI commands as tools for coding agents').action(
53
- createMcpAction({
54
- cli,
55
- commandFilter: name => MCP_ALLOWLIST.has(name),
56
- }),
57
- )
58
- }