@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.
@@ -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 (totalRequests === null || failureCount === null) {
150
+ if (!Array.isArray(payload)) {
129
151
  return {
130
152
  title: 'Usage stats',
131
153
  level: 'warning',
132
- summary: 'Management usage payload is missing expected numeric fields.',
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: failureCount > 0 ? 'warning' : 'ok',
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
- const totalMatch = /total_requests=(\d+)/.exec(result.summary)
216
- const failureMatch = /failure_count=(\d+)/.exec(result.summary)
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 total = Number(totalMatch[1])
223
- const failed = Number(failureMatch[1])
224
- const failureRate = total === 0 ? 0 : (failed / total) * 100
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 httpPromise = checkHttpReachability(normalizedBaseUrl, verbose)
232
- const managementResultsPromise = key
233
- ? Promise.all([checkUsageStats(normalizedBaseUrl, key), checkVersion(normalizedBaseUrl, key)])
234
- : Promise.resolve(null)
235
-
236
- const [httpResult, managementResults] = await Promise.all([httpPromise, managementResultsPromise])
237
- const [usageResult, versionResult] = managementResults ?? [null, null]
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: versionResult ? formatCheckSummary(versionResult) : '— (no key)',
371
+ version,
244
372
  contentHash: '—',
245
- usageStats: usageResult ? formatCheckSummary(usageResult) : '— (no key)',
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, verbose)]
393
+ const results: CheckResult[] = [await checkHttpReachability(`${baseUrl}/healthz`, verbose)]
266
394
 
267
395
  let capturedUsageResult: CheckResult | undefined
268
396
 
269
- if (managementKey) {
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
- capturedUsageResult = usageResult
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) {
@@ -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 10 allowlist tool names', async () => {
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
@@ -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 () => {
@@ -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 gateway summary objects.'),
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
+ })