@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.
- package/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /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
|
-
}
|
package/src/commands/mcp.test.ts
DELETED
|
@@ -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
|
-
})
|
package/src/commands/mcp.ts
DELETED
|
@@ -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
|
-
}
|