@marcusrbrown/infra 0.8.1 → 0.9.1
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 +1 -1
- package/src/__snapshots__/cli.test.ts.snap +19 -1
- package/src/cli.ts +2 -0
- package/src/commands/cliproxy/setup/validation.test.ts +168 -0
- package/src/commands/cliproxy/setup/validation.ts +36 -9
- package/src/commands/cliproxy/status.test.ts +368 -38
- package/src/commands/cliproxy/status.ts +168 -41
- package/src/commands/mcp.test.ts +5 -7
- package/src/commands/mcp.ts +3 -0
- package/src/commands/status.test.ts +36 -0
- package/src/commands/status.ts +10 -3
- package/src/commands/umami/deploy.test.ts +202 -0
- package/src/commands/umami/deploy.ts +132 -0
- package/src/commands/umami/host.test.ts +62 -0
- package/src/commands/umami/host.ts +31 -0
- package/src/commands/umami/index.ts +13 -0
- package/src/commands/umami/logs.test.ts +154 -0
- package/src/commands/umami/logs.ts +161 -0
- package/src/commands/umami/status.test.ts +387 -0
- package/src/commands/umami/status.ts +267 -0
|
@@ -94,8 +94,34 @@ export async function checkHttpReachability(url: string, verbose: boolean): Prom
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** Count records in a usage-queue array that look like errors/failures. Defensive: only counts when a recognizable field exists. */
|
|
98
|
+
function countQueueErrors(records: unknown[]): number {
|
|
99
|
+
let count = 0
|
|
100
|
+
for (const record of records) {
|
|
101
|
+
if (record === null || typeof record !== 'object') {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rec = record as Record<string, unknown>
|
|
106
|
+
|
|
107
|
+
// Status field >= 400 indicates an HTTP-level error
|
|
108
|
+
const status = toNumber(rec.status)
|
|
109
|
+
if (status !== null && status >= 400) {
|
|
110
|
+
count++
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Explicit error/failure markers
|
|
115
|
+
if (rec.error !== undefined || rec.failed === true || rec.failure === true) {
|
|
116
|
+
count++
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return count
|
|
121
|
+
}
|
|
122
|
+
|
|
97
123
|
export async function checkUsageStats(baseUrl: string, key: string): Promise<CheckResult> {
|
|
98
|
-
const endpoint = `${baseUrl}/v0/management/usage`
|
|
124
|
+
const endpoint = `${baseUrl}/v0/management/usage-queue?count=50`
|
|
99
125
|
|
|
100
126
|
try {
|
|
101
127
|
const response = await fetch(endpoint, {
|
|
@@ -115,31 +141,35 @@ export async function checkUsageStats(baseUrl: string, key: string): Promise<Che
|
|
|
115
141
|
return {
|
|
116
142
|
title: 'Usage stats',
|
|
117
143
|
level: 'error',
|
|
118
|
-
summary: `GET /v0/management/usage failed with HTTP ${response.status}`,
|
|
144
|
+
summary: `GET /v0/management/usage-queue failed with HTTP ${response.status}`,
|
|
119
145
|
}
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
const payload = await parseJsonResponse(response)
|
|
123
|
-
const top = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
|
124
|
-
const usage = top.usage && typeof top.usage === 'object' ? (top.usage as Record<string, unknown>) : top
|
|
125
|
-
const totalRequests = toNumber(usage.total_requests)
|
|
126
|
-
const failureCount = toNumber(usage.failure_count)
|
|
127
149
|
|
|
128
|
-
if (
|
|
150
|
+
if (!Array.isArray(payload)) {
|
|
129
151
|
return {
|
|
130
152
|
title: 'Usage stats',
|
|
131
153
|
level: 'warning',
|
|
132
|
-
summary: '
|
|
154
|
+
summary: 'Usage-queue response was not an array — cannot parse recent activity.',
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const total = payload.length
|
|
159
|
+
const errors = countQueueErrors(payload)
|
|
160
|
+
|
|
161
|
+
if (total === 0) {
|
|
162
|
+
return {
|
|
163
|
+
title: 'Usage stats',
|
|
164
|
+
level: 'ok',
|
|
165
|
+
summary: 'recent: 0 (idle)',
|
|
133
166
|
}
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
return {
|
|
137
170
|
title: 'Usage stats',
|
|
138
|
-
level:
|
|
139
|
-
summary:
|
|
140
|
-
failureCount > 0
|
|
141
|
-
? `total_requests=${totalRequests}, failure_count=${failureCount} (token refresh likely needed)`
|
|
142
|
-
: `total_requests=${totalRequests}, failure_count=${failureCount}`,
|
|
171
|
+
level: errors > 0 ? 'warning' : 'ok',
|
|
172
|
+
summary: errors > 0 ? `recent: ${total}, errors: ${errors}` : `recent: ${total}`,
|
|
143
173
|
}
|
|
144
174
|
} catch (error) {
|
|
145
175
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -196,6 +226,95 @@ export async function checkVersion(baseUrl: string, key: string): Promise<CheckR
|
|
|
196
226
|
}
|
|
197
227
|
}
|
|
198
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Probe the management API with a cheap auth check before issuing parallel calls.
|
|
231
|
+
* Returns null on success, or a CheckResult describing the auth failure.
|
|
232
|
+
* Never throws.
|
|
233
|
+
*/
|
|
234
|
+
async function probeManagementAuth(baseUrl: string, key: string): Promise<CheckResult | null> {
|
|
235
|
+
const endpoint = `${baseUrl}/v0/management/config`
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const response = await fetch(endpoint, {
|
|
239
|
+
headers: managementHeaders(key),
|
|
240
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
if (response.ok) {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (response.status === 403) {
|
|
248
|
+
// Could be an IP ban — inspect body for ban-ish markers
|
|
249
|
+
const payload = await parseJsonResponse(response)
|
|
250
|
+
const isBanBody =
|
|
251
|
+
payload !== null &&
|
|
252
|
+
typeof payload === 'object' &&
|
|
253
|
+
Object.values(payload as Record<string, unknown>).some(
|
|
254
|
+
v => typeof v === 'string' && /\b(?:ip[- ]?)?bann?ed\b/i.test(v),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if (isBanBody) {
|
|
258
|
+
return {
|
|
259
|
+
title: 'Management access',
|
|
260
|
+
level: 'error',
|
|
261
|
+
summary: 'IP banned — stop retrying for ~30 min. Management checks skipped.',
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
title: 'Management access',
|
|
267
|
+
level: 'error',
|
|
268
|
+
summary: 'Management API returned HTTP 403. Management checks skipped.',
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (response.status === 401) {
|
|
273
|
+
return {
|
|
274
|
+
title: 'Management access',
|
|
275
|
+
level: 'error',
|
|
276
|
+
summary: 'Management API returned HTTP 401 (invalid key). Management checks skipped.',
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
title: 'Management access',
|
|
282
|
+
level: 'warning',
|
|
283
|
+
summary: `Management auth probe returned HTTP ${response.status}. Management checks skipped.`,
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
287
|
+
return {
|
|
288
|
+
title: 'Management access',
|
|
289
|
+
level: 'error',
|
|
290
|
+
summary: `Management auth probe failed: ${message}`,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type ManagementChecks =
|
|
296
|
+
| {kind: 'no-key'}
|
|
297
|
+
| {kind: 'auth-failure'; result: CheckResult}
|
|
298
|
+
| {kind: 'checks'; usage: CheckResult; version: CheckResult}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Run management checks with single probe + parallel data fetches.
|
|
302
|
+
* Returns a discriminated union so callers handle no-key, auth-failure, and success distinctly.
|
|
303
|
+
*/
|
|
304
|
+
async function runManagementChecks(baseUrl: string, key: string | undefined): Promise<ManagementChecks> {
|
|
305
|
+
if (!key) {
|
|
306
|
+
return {kind: 'no-key'}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const authFailure = await probeManagementAuth(baseUrl, key)
|
|
310
|
+
if (authFailure !== null) {
|
|
311
|
+
return {kind: 'auth-failure', result: authFailure}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const [usage, version] = await Promise.all([checkUsageStats(baseUrl, key), checkVersion(baseUrl, key)])
|
|
315
|
+
return {kind: 'checks', usage, version}
|
|
316
|
+
}
|
|
317
|
+
|
|
199
318
|
function printCheckResult(result: CheckResult, ctx: ActionCtx): void {
|
|
200
319
|
ctx.console.log(`[${levelLabel(result.level)}] ${result.title}`)
|
|
201
320
|
ctx.console.log(` ${result.summary}`)
|
|
@@ -212,37 +331,46 @@ function formatCheckSummary(result: CheckResult): string {
|
|
|
212
331
|
}
|
|
213
332
|
|
|
214
333
|
export function formatUsageSummaryLine(result: CheckResult): string | null {
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
if (!totalMatch || !failureMatch) {
|
|
334
|
+
// v7 recent-window format: "recent: N" or "recent: N, errors: M"
|
|
335
|
+
const recentMatch = /recent:\s*(\d+)/.exec(result.summary)
|
|
336
|
+
if (!recentMatch) {
|
|
219
337
|
return null
|
|
220
338
|
}
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return `Requests: ${total} total, ${failed} failed (${failureRate.toFixed(1)}% failure rate)`
|
|
339
|
+
const total = Number(recentMatch[1])
|
|
340
|
+
const errorMatch = /errors:\s*(\d+)/.exec(result.summary)
|
|
341
|
+
const errors = errorMatch ? Number(errorMatch[1]) : 0
|
|
342
|
+
return `Recent requests: ${total}${errors > 0 ? `, ${errors} errors` : ''}`
|
|
227
343
|
}
|
|
228
344
|
|
|
229
345
|
export async function getCliproxyStatusSummary(baseUrl: string, key: string, verbose: boolean): Promise<StatusSummary> {
|
|
230
346
|
const normalizedBaseUrl = stripTrailingSlash(baseUrl)
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
347
|
+
const [httpResult, mgmt] = await Promise.all([
|
|
348
|
+
checkHttpReachability(`${normalizedBaseUrl}/healthz`, verbose),
|
|
349
|
+
runManagementChecks(normalizedBaseUrl, key || undefined),
|
|
350
|
+
])
|
|
351
|
+
|
|
352
|
+
let version: string
|
|
353
|
+
let usageStats: string
|
|
354
|
+
|
|
355
|
+
if (mgmt.kind === 'no-key') {
|
|
356
|
+
version = '— (no key)'
|
|
357
|
+
usageStats = '— (no key)'
|
|
358
|
+
} else if (mgmt.kind === 'auth-failure') {
|
|
359
|
+
const authSummary = formatCheckSummary(mgmt.result)
|
|
360
|
+
version = authSummary
|
|
361
|
+
usageStats = authSummary
|
|
362
|
+
} else {
|
|
363
|
+
version = formatCheckSummary(mgmt.version)
|
|
364
|
+
usageStats = formatCheckSummary(mgmt.usage)
|
|
365
|
+
}
|
|
238
366
|
|
|
239
367
|
return {
|
|
240
368
|
app: 'cliproxy',
|
|
241
369
|
http: formatCheckSummary(httpResult),
|
|
242
370
|
lastDeploy: '—',
|
|
243
|
-
version
|
|
371
|
+
version,
|
|
244
372
|
contentHash: '—',
|
|
245
|
-
usageStats
|
|
373
|
+
usageStats,
|
|
246
374
|
}
|
|
247
375
|
}
|
|
248
376
|
|
|
@@ -262,25 +390,24 @@ export async function cliproxyStatusAction(options: StatusOptions, ctx: ActionCt
|
|
|
262
390
|
ctx.console.log('CLIProxyAPI status')
|
|
263
391
|
ctx.console.log('')
|
|
264
392
|
|
|
265
|
-
const results: CheckResult[] = [await checkHttpReachability(baseUrl
|
|
393
|
+
const results: CheckResult[] = [await checkHttpReachability(`${baseUrl}/healthz`, verbose)]
|
|
266
394
|
|
|
267
395
|
let capturedUsageResult: CheckResult | undefined
|
|
268
396
|
|
|
269
|
-
|
|
270
|
-
const [usageResult, versionResult] = await Promise.all([
|
|
271
|
-
checkUsageStats(baseUrl, managementKey),
|
|
272
|
-
checkVersion(baseUrl, managementKey),
|
|
273
|
-
])
|
|
397
|
+
const mgmt = await runManagementChecks(baseUrl, managementKey)
|
|
274
398
|
|
|
275
|
-
|
|
276
|
-
results.push(usageResult, versionResult)
|
|
277
|
-
} else {
|
|
399
|
+
if (mgmt.kind === 'no-key') {
|
|
278
400
|
results.push({
|
|
279
401
|
title: 'Management checks',
|
|
280
402
|
level: 'warning',
|
|
281
403
|
summary:
|
|
282
404
|
'CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var.',
|
|
283
405
|
})
|
|
406
|
+
} else if (mgmt.kind === 'auth-failure') {
|
|
407
|
+
results.push(mgmt.result)
|
|
408
|
+
} else {
|
|
409
|
+
capturedUsageResult = mgmt.usage
|
|
410
|
+
results.push(mgmt.usage, mgmt.version)
|
|
284
411
|
}
|
|
285
412
|
|
|
286
413
|
for (const result of results) {
|
package/src/commands/mcp.test.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {registerGatewayCommands} from './gateway'
|
|
|
37
37
|
import {registerKeewebCommands} from './keeweb'
|
|
38
38
|
import {MCP_ALLOWLIST, registerMcp} from './mcp'
|
|
39
39
|
import {registerStatus} from './status'
|
|
40
|
+
import {registerUmamiCommands} from './umami'
|
|
40
41
|
|
|
41
42
|
// ─── Tool name constants ──────────────────────────────────────────────────────
|
|
42
43
|
|
|
@@ -54,6 +55,8 @@ const CLI_ONLY_TOOLS = [
|
|
|
54
55
|
'gateway_restore',
|
|
55
56
|
'keeweb_deploy',
|
|
56
57
|
'keeweb_open',
|
|
58
|
+
'umami_deploy',
|
|
59
|
+
'umami_logs',
|
|
57
60
|
].sort()
|
|
58
61
|
|
|
59
62
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -64,6 +67,7 @@ function buildTestCli(): ReturnType<typeof goke> {
|
|
|
64
67
|
registerKeewebCommands(cli)
|
|
65
68
|
registerCliproxyCommands(cli)
|
|
66
69
|
registerGatewayCommands(cli)
|
|
70
|
+
registerUmamiCommands(cli)
|
|
67
71
|
registerStatus(cli)
|
|
68
72
|
registerMcp(cli)
|
|
69
73
|
return cli
|
|
@@ -105,7 +109,7 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
105
109
|
|
|
106
110
|
// ── tools/list assertions ──────────────────────────────────────────────────
|
|
107
111
|
|
|
108
|
-
test('tools/list returns exactly the
|
|
112
|
+
test('tools/list returns exactly the allowlist tool names', async () => {
|
|
109
113
|
const result = await client.listTools()
|
|
110
114
|
const names = result.tools.map((t: {name: string}) => t.name).sort()
|
|
111
115
|
expect(names).toEqual(EXPECTED_TOOLS)
|
|
@@ -146,12 +150,6 @@ describe('mcp integration (Tier-1, in-process)', () => {
|
|
|
146
150
|
// Mode C: when a command returns structured data AND prints to stdout,
|
|
147
151
|
// the CallToolResult must contain BOTH a stdout text block AND a
|
|
148
152
|
// 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
153
|
test('cliproxy_keys_list returns BOTH stdout block AND structured return block (Mode C contract)', async () => {
|
|
156
154
|
const originalFetch = globalThis.fetch
|
|
157
155
|
const originalKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
package/src/commands/mcp.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {createMcpAction} from '@goke/mcp'
|
|
|
13
13
|
* - `cliproxy setup` — interactive (@clack/prompts wizard, requires TTY)
|
|
14
14
|
* - `gateway restore` — destructive policy (replaces mitmproxy CA on live gateway, deferred to MCP v2 #292)
|
|
15
15
|
* - `keeweb open` — host-machine side effect (spawns local browser, requires user intent)
|
|
16
|
+
* - `umami deploy` — intentionally CLI-only: mutates live deployment and requires environment approval
|
|
17
|
+
* - `umami logs` — intentionally CLI-only: streams logs that may emit sensitive data (DB passwords, app secrets)
|
|
16
18
|
*/
|
|
17
19
|
export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
18
20
|
'gateway status',
|
|
@@ -24,6 +26,7 @@ export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
|
24
26
|
'cliproxy config get',
|
|
25
27
|
'cliproxy config set',
|
|
26
28
|
'keeweb status',
|
|
29
|
+
'umami status',
|
|
27
30
|
'status',
|
|
28
31
|
])
|
|
29
32
|
|
|
@@ -31,11 +31,21 @@ const healthyGateway: StatusSummary = {
|
|
|
31
31
|
usageStats: '—',
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const healthyUmami: StatusSummary = {
|
|
35
|
+
app: 'umami',
|
|
36
|
+
http: 'OK: umami:running/healthy',
|
|
37
|
+
lastDeploy: '—',
|
|
38
|
+
version: '—',
|
|
39
|
+
contentHash: '—',
|
|
40
|
+
usageStats: '—',
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
function makeDeps(overrides?: Partial<Parameters<typeof registerStatus>[1]>): Parameters<typeof registerStatus>[1] {
|
|
35
44
|
return {
|
|
36
45
|
getKeewebStatusSummary: async () => healthyKeeweb,
|
|
37
46
|
getCliproxyStatusSummary: async () => healthyCliproxy,
|
|
38
47
|
getGatewayStatusSummary: async () => healthyGateway,
|
|
48
|
+
getUmamiStatusSummary: async () => healthyUmami,
|
|
39
49
|
...overrides,
|
|
40
50
|
}
|
|
41
51
|
}
|
|
@@ -52,6 +62,30 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
|
|
|
52
62
|
expect(expectCapturedToInclude(captured, '| keeweb | OK | 2026-04-12 10:00 | — | match | — |')).toBe(true)
|
|
53
63
|
expect(expectCapturedToInclude(captured, '| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')).toBe(true)
|
|
54
64
|
expect(expectCapturedToInclude(captured, '| gateway | OK: gateway:running/healthy | — | — | — | — |')).toBe(true)
|
|
65
|
+
expect(expectCapturedToInclude(captured, '| umami | OK: umami:running/healthy | — | — | — | — |')).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('shows an error row when umami is unreachable and keeps the other results', async () => {
|
|
69
|
+
const {ctx, captured} = createCapturedCtx()
|
|
70
|
+
|
|
71
|
+
await unifiedStatusAction(
|
|
72
|
+
{},
|
|
73
|
+
ctx,
|
|
74
|
+
makeDeps({
|
|
75
|
+
getUmamiStatusSummary: async () => {
|
|
76
|
+
throw new Error('UMAMI_DOMAIN not set')
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
expectCapturedToInclude(
|
|
83
|
+
captured,
|
|
84
|
+
'| umami | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set | ❌ UMAMI_DOMAIN not set |',
|
|
85
|
+
),
|
|
86
|
+
).toBe(true)
|
|
87
|
+
expect(expectCapturedToInclude(captured, '| gateway | OK: gateway:running/healthy | — | — | — | — |')).toBe(true)
|
|
88
|
+
expect(captured.stderr.join('')).toContain('umami status check failed: UMAMI_DOMAIN not set')
|
|
55
89
|
})
|
|
56
90
|
|
|
57
91
|
it('shows an error row when one app fails and keeps the other results', async () => {
|
|
@@ -92,11 +126,13 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
|
|
|
92
126
|
keeweb: {http: string}
|
|
93
127
|
cliproxy: {version: string}
|
|
94
128
|
gateway: {http: string}
|
|
129
|
+
umami: {http: string}
|
|
95
130
|
}
|
|
96
131
|
|
|
97
132
|
expect(parsed.keeweb.http).toBe('OK')
|
|
98
133
|
expect(parsed.cliproxy.version).toBe('v1.2.3')
|
|
99
134
|
expect(parsed.gateway.http).toBe('OK: gateway:running/healthy')
|
|
135
|
+
expect(parsed.umami.http).toBe('OK: umami:running/healthy')
|
|
100
136
|
})
|
|
101
137
|
|
|
102
138
|
it('does not write to global console (output is captured via ctx)', async () => {
|
package/src/commands/status.ts
CHANGED
|
@@ -7,13 +7,14 @@ import {z} from 'zod'
|
|
|
7
7
|
import {getCliproxyStatusSummary} from './cliproxy/status'
|
|
8
8
|
import {getGatewayStatusSummary} from './gateway'
|
|
9
9
|
import {getKeewebStatusSummary} from './keeweb/status'
|
|
10
|
+
import {getUmamiStatusSummary} from './umami'
|
|
10
11
|
|
|
11
12
|
declare const process: {
|
|
12
13
|
env: Record<string, string | undefined>
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface StatusSummary {
|
|
16
|
-
app: 'keeweb' | 'cliproxy' | 'gateway'
|
|
17
|
+
app: 'keeweb' | 'cliproxy' | 'gateway' | 'umami'
|
|
17
18
|
http: string
|
|
18
19
|
lastDeploy: string
|
|
19
20
|
version: string
|
|
@@ -27,6 +28,7 @@ interface StatusDependencies {
|
|
|
27
28
|
getKeewebStatusSummary: (verbose: boolean) => Promise<StatusSummary>
|
|
28
29
|
getCliproxyStatusSummary: (baseUrl: string, key: string, verbose: boolean) => Promise<StatusSummary>
|
|
29
30
|
getGatewayStatusSummary: (host: string) => Promise<StatusSummary>
|
|
31
|
+
getUmamiStatusSummary: (host: string) => Promise<StatusSummary>
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
@@ -63,6 +65,7 @@ function toJsonPayload(rows: StatusSummary[]): Record<AppName, StatusSummary> {
|
|
|
63
65
|
keeweb: rows.find(row => row.app === 'keeweb') ?? errorSummary('keeweb', 'missing result'),
|
|
64
66
|
cliproxy: rows.find(row => row.app === 'cliproxy') ?? errorSummary('cliproxy', 'missing result'),
|
|
65
67
|
gateway: rows.find(row => row.app === 'gateway') ?? errorSummary('gateway', 'missing result'),
|
|
68
|
+
umami: rows.find(row => row.app === 'umami') ?? errorSummary('umami', 'missing result'),
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
|
|
@@ -78,20 +81,23 @@ export async function unifiedStatusAction(
|
|
|
78
81
|
getKeewebStatusSummary,
|
|
79
82
|
getCliproxyStatusSummary,
|
|
80
83
|
getGatewayStatusSummary,
|
|
84
|
+
getUmamiStatusSummary,
|
|
81
85
|
},
|
|
82
86
|
): Promise<void> {
|
|
83
87
|
const verbose = options.verbose === true
|
|
84
88
|
const cliproxyBaseUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
85
89
|
const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
|
|
86
90
|
const gatewayHost = process.env.GATEWAY_HOST ?? ''
|
|
91
|
+
const umamiHost = process.env.UMAMI_DOMAIN ?? ''
|
|
87
92
|
|
|
88
93
|
const results = await Promise.allSettled([
|
|
89
94
|
dependencies.getKeewebStatusSummary(verbose),
|
|
90
95
|
dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
|
|
91
96
|
dependencies.getGatewayStatusSummary(gatewayHost),
|
|
97
|
+
dependencies.getUmamiStatusSummary(umamiHost),
|
|
92
98
|
])
|
|
93
99
|
|
|
94
|
-
const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway']
|
|
100
|
+
const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway', 'umami']
|
|
95
101
|
const rows: StatusSummary[] = results.map((result, index) => {
|
|
96
102
|
const app = appNames[index] ?? 'keeweb'
|
|
97
103
|
if (result.status === 'fulfilled') {
|
|
@@ -120,13 +126,14 @@ export function registerStatus(
|
|
|
120
126
|
getKeewebStatusSummary,
|
|
121
127
|
getCliproxyStatusSummary,
|
|
122
128
|
getGatewayStatusSummary,
|
|
129
|
+
getUmamiStatusSummary,
|
|
123
130
|
},
|
|
124
131
|
): void {
|
|
125
132
|
cli
|
|
126
133
|
.command('status', 'Show status of all deployments')
|
|
127
134
|
.option(
|
|
128
135
|
'--json',
|
|
129
|
-
z.boolean().describe('Output machine-readable JSON with keeweb, cliproxy, and
|
|
136
|
+
z.boolean().describe('Output machine-readable JSON with keeweb, cliproxy, gateway, and umami summary objects.'),
|
|
130
137
|
)
|
|
131
138
|
.option(
|
|
132
139
|
'--verbose',
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {resolve} from 'node:path'
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
3
|
+
|
|
4
|
+
import {getUmamiDeployEnv, validateUmamiRemotePreconditions} from './deploy'
|
|
5
|
+
|
|
6
|
+
const repoRoot = resolve(import.meta.dir, '../../../../..')
|
|
7
|
+
|
|
8
|
+
const envKeys = [
|
|
9
|
+
'HOME',
|
|
10
|
+
'PATH',
|
|
11
|
+
'SSH_AUTH_SOCK',
|
|
12
|
+
'UMAMI_DOMAIN',
|
|
13
|
+
'UMAMI_APP_SECRET',
|
|
14
|
+
'UMAMI_DB_PASSWORD',
|
|
15
|
+
'UMAMI_ADMIN_PASSWORD',
|
|
16
|
+
'UMAMI_SSH_KEY',
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
type ManagedEnvKey = (typeof envKeys)[number]
|
|
20
|
+
|
|
21
|
+
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
22
|
+
|
|
23
|
+
function restoreManagedEnv(): void {
|
|
24
|
+
for (const key of envKeys) {
|
|
25
|
+
const value = originalEnv[key]
|
|
26
|
+
if (value === undefined) {
|
|
27
|
+
delete process.env[key]
|
|
28
|
+
} else {
|
|
29
|
+
process.env[key] = value
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
|
|
35
|
+
restoreManagedEnv()
|
|
36
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
delete process.env[key as ManagedEnvKey]
|
|
39
|
+
} else {
|
|
40
|
+
process.env[key as ManagedEnvKey] = value
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
originalEnv = {}
|
|
47
|
+
for (const key of envKeys) {
|
|
48
|
+
originalEnv[key] = process.env[key]
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
restoreManagedEnv()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ─── getUmamiDeployEnv ────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('getUmamiDeployEnv', () => {
|
|
59
|
+
it('returns env object with required keys when all are set', () => {
|
|
60
|
+
setManagedEnv({
|
|
61
|
+
PATH: '/usr/bin:/bin',
|
|
62
|
+
HOME: '/home/user',
|
|
63
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
64
|
+
UMAMI_DOMAIN: 'metrics.fro.bot',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const env = getUmamiDeployEnv()
|
|
68
|
+
|
|
69
|
+
expect(env.PATH).toBe('/usr/bin:/bin')
|
|
70
|
+
expect(env.HOME).toBe('/home/user')
|
|
71
|
+
expect(env.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock')
|
|
72
|
+
expect(env.UMAMI_DOMAIN).toBe('metrics.fro.bot')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('throws when PATH is missing', () => {
|
|
76
|
+
setManagedEnv({PATH: undefined, HOME: '/home/user', SSH_AUTH_SOCK: '/tmp/ssh-agent.sock'})
|
|
77
|
+
|
|
78
|
+
expect(() => getUmamiDeployEnv()).toThrow('PATH is required')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('throws when HOME is missing', () => {
|
|
82
|
+
setManagedEnv({PATH: '/usr/bin:/bin', HOME: undefined, SSH_AUTH_SOCK: '/tmp/ssh-agent.sock'})
|
|
83
|
+
|
|
84
|
+
expect(() => getUmamiDeployEnv()).toThrow('HOME is required')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('throws when SSH_AUTH_SOCK is missing and no UMAMI_SSH_KEY either', () => {
|
|
88
|
+
setManagedEnv({
|
|
89
|
+
PATH: '/usr/bin:/bin',
|
|
90
|
+
HOME: '/home/user',
|
|
91
|
+
SSH_AUTH_SOCK: undefined,
|
|
92
|
+
UMAMI_SSH_KEY: undefined,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(() => getUmamiDeployEnv()).toThrow('Local deploy needs an SSH context')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('succeeds with only SSH_AUTH_SOCK set (no UMAMI_SSH_KEY)', () => {
|
|
99
|
+
setManagedEnv({
|
|
100
|
+
PATH: '/usr/bin:/bin',
|
|
101
|
+
HOME: '/home/user',
|
|
102
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
103
|
+
UMAMI_SSH_KEY: undefined,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const env = getUmamiDeployEnv()
|
|
107
|
+
|
|
108
|
+
expect(env.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock')
|
|
109
|
+
expect('UMAMI_SSH_KEY' in env).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('succeeds with only UMAMI_SSH_KEY set (no SSH_AUTH_SOCK) and includes the key in env', () => {
|
|
113
|
+
setManagedEnv({
|
|
114
|
+
PATH: '/usr/bin:/bin',
|
|
115
|
+
HOME: '/home/user',
|
|
116
|
+
SSH_AUTH_SOCK: undefined,
|
|
117
|
+
UMAMI_SSH_KEY: 'ssh-ed25519 AAAA...',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const env = getUmamiDeployEnv()
|
|
121
|
+
|
|
122
|
+
expect(env.UMAMI_SSH_KEY).toBe('ssh-ed25519 AAAA...')
|
|
123
|
+
expect('SSH_AUTH_SOCK' in env).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('includes optional umami env vars when set', () => {
|
|
127
|
+
setManagedEnv({
|
|
128
|
+
PATH: '/usr/bin:/bin',
|
|
129
|
+
HOME: '/home/user',
|
|
130
|
+
SSH_AUTH_SOCK: '/tmp/ssh-agent.sock',
|
|
131
|
+
UMAMI_APP_SECRET: 'secret123',
|
|
132
|
+
UMAMI_DB_PASSWORD: 'dbpass',
|
|
133
|
+
UMAMI_ADMIN_PASSWORD: 'adminpass',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const env = getUmamiDeployEnv()
|
|
137
|
+
|
|
138
|
+
expect(env.UMAMI_APP_SECRET).toBe('secret123')
|
|
139
|
+
expect(env.UMAMI_DB_PASSWORD).toBe('dbpass')
|
|
140
|
+
expect(env.UMAMI_ADMIN_PASSWORD).toBe('adminpass')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ─── validateUmamiRemotePreconditions ─────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('validateUmamiRemotePreconditions', () => {
|
|
147
|
+
it('throws a clear error when gh is not available', () => {
|
|
148
|
+
// We cannot reliably mock Bun.which, so we test the function contract:
|
|
149
|
+
// if gh is not installed, it should throw with a helpful message.
|
|
150
|
+
// This test verifies the error message shape by calling with a known-missing binary.
|
|
151
|
+
// In CI where gh IS installed, we skip this test.
|
|
152
|
+
if (Bun.which('gh')) {
|
|
153
|
+
// gh is available — just verify the function does not throw
|
|
154
|
+
expect(() => validateUmamiRemotePreconditions()).not.toThrow()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
expect(() => validateUmamiRemotePreconditions()).toThrow('gh CLI is required')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ─── deploy command (subprocess integration via CLI) ─────────────────────────
|
|
163
|
+
|
|
164
|
+
describe('deploy command', () => {
|
|
165
|
+
it('dry-run remote mode prints planned gh workflow run command without executing', async () => {
|
|
166
|
+
const proc = Bun.spawn(['bun', 'run', 'packages/cli/src/cli.ts', 'umami', 'deploy', '--dry-run'], {
|
|
167
|
+
cwd: repoRoot,
|
|
168
|
+
env: {...process.env, NO_COLOR: '1'},
|
|
169
|
+
stdout: 'pipe',
|
|
170
|
+
stderr: 'pipe',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const [stdout, _stderr, exitCode] = await Promise.all([
|
|
174
|
+
new Response(proc.stdout).text(),
|
|
175
|
+
new Response(proc.stderr).text(),
|
|
176
|
+
proc.exited,
|
|
177
|
+
])
|
|
178
|
+
|
|
179
|
+
expect(exitCode).toBe(0)
|
|
180
|
+
expect(stdout).toContain('Dry run')
|
|
181
|
+
expect(stdout).toContain('Deploy Umami')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('dry-run local mode prints planned bun command without executing', async () => {
|
|
185
|
+
const proc = Bun.spawn(['bun', 'run', 'packages/cli/src/cli.ts', 'umami', 'deploy', '--local', '--dry-run'], {
|
|
186
|
+
cwd: repoRoot,
|
|
187
|
+
env: {...process.env, NO_COLOR: '1'},
|
|
188
|
+
stdout: 'pipe',
|
|
189
|
+
stderr: 'pipe',
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const [stdout, _stderr, exitCode] = await Promise.all([
|
|
193
|
+
new Response(proc.stdout).text(),
|
|
194
|
+
new Response(proc.stderr).text(),
|
|
195
|
+
proc.exited,
|
|
196
|
+
])
|
|
197
|
+
|
|
198
|
+
expect(exitCode).toBe(0)
|
|
199
|
+
expect(stdout).toContain('Dry run')
|
|
200
|
+
expect(stdout).toContain('apps/umami')
|
|
201
|
+
})
|
|
202
|
+
})
|