@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -125,9 +125,27 @@ Commands:
125
125
  --include-ca Restore the mitmproxy CA certificate and private key. Currently the only supported restore target. (default: true)
126
126
 
127
127
 
128
+ umami status Show operational health of the Umami analytics deployment via docker compose ps.
129
+
130
+ --key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
131
+
132
+
133
+ umami deploy Deploy Umami analytics. Default mode triggers the GitHub Deploy Umami workflow, while --local runs apps/umami deploy directly with Bun.
134
+
135
+ --local Run local deployment with Bun using apps/umami instead of triggering GitHub Actions. (default: false)
136
+ --dry-run Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow. (default: false)
137
+
138
+
139
+ umami logs [service] Stream logs from an Umami service via SSH and docker compose.
140
+
141
+ --tail [n] Number of log lines to tail from each service. (default: 100)
142
+ --allow-ci Allow log streaming in CI environments. Logs may contain sensitive credentials. (default: false)
143
+ --key [key] Environment variable name holding the SSH host. Falls back to UMAMI_DOMAIN when omitted.
144
+
145
+
128
146
  status Show status of all deployments
129
147
 
130
- --json Output machine-readable JSON with keeweb, cliproxy, and gateway summary objects.
148
+ --json Output machine-readable JSON with keeweb, cliproxy, gateway, and umami summary objects.
131
149
  --verbose Include verbose per-app health check details when building the summary rows.
132
150
 
133
151
 
package/src/cli.ts CHANGED
@@ -7,6 +7,7 @@ import {registerGatewayCommands} from './commands/gateway'
7
7
  import {registerKeewebCommands} from './commands/keeweb'
8
8
  import {registerMcp} from './commands/mcp'
9
9
  import {registerStatus} from './commands/status'
10
+ import {registerUmamiCommands} from './commands/umami'
10
11
 
11
12
  declare const process: {
12
13
  argv: string[]
@@ -20,6 +21,7 @@ cli.option('--verbose', 'Enable verbose output for all commands')
20
21
  registerKeewebCommands(cli)
21
22
  registerCliproxyCommands(cli)
22
23
  registerGatewayCommands(cli)
24
+ registerUmamiCommands(cli)
23
25
  registerStatus(cli)
24
26
  registerMcp(cli)
25
27
 
@@ -251,6 +251,73 @@ describe('verifyModelsAvailable', () => {
251
251
  await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
252
252
  })
253
253
 
254
+ it('regression: entries WITH owned_by openai still parse and are detected as OpenAI', async () => {
255
+ const fixture = {
256
+ data: [
257
+ {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
258
+ {id: 'gpt-5.4-mini', owned_by: 'openai'},
259
+ ],
260
+ object: 'list',
261
+ }
262
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
263
+
264
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
265
+ })
266
+
267
+ it('v7 compatibility: entries omitting owned_by parse without error and OpenAI is detected via id prefix', async () => {
268
+ // CLIProxyAPI v7 may return entries without owned_by — these must not fail Zod parse,
269
+ // and an entry like {id: 'openai/gpt-5.4-mini'} should be detected as an OpenAI model.
270
+ const v7Fixture = {
271
+ data: [
272
+ {id: 'openai/gpt-5.4-mini', object: 'model'},
273
+ {id: 'anthropic/claude-sonnet-4-6', object: 'model'},
274
+ ],
275
+ object: 'list',
276
+ }
277
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(v7Fixture))) as unknown as typeof fetch
278
+
279
+ // Requesting a model that doesn't exist so we can observe which check throws.
280
+ // The error must be "not found on proxy" — not a Zod parse error or "No OpenAI models on proxy".
281
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/nonexistent-model')).rejects.toThrow(
282
+ 'not found on proxy',
283
+ )
284
+ })
285
+
286
+ it('v7 compatibility: mixed entries (some with owned_by, some without) resolve correctly', async () => {
287
+ const mixedFixture = {
288
+ data: [
289
+ {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
290
+ // v7-style OpenAI entry — no owned_by, id has openai/ prefix
291
+ {id: 'openai/gpt-5.4-mini', object: 'model'},
292
+ ],
293
+ object: 'list',
294
+ }
295
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(mixedFixture))) as unknown as typeof fetch
296
+
297
+ // Should detect OpenAI via id inference and pass the OpenAI presence check.
298
+ // The error must be "not found on proxy" — not a Zod parse error or "No OpenAI models on proxy".
299
+ await expect(
300
+ verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/nonexistent-model'),
301
+ ).rejects.toThrow('not found on proxy')
302
+ })
303
+
304
+ it('v7 compatibility: entry with unrecognizable id and no owned_by does not crash (falls through as non-OpenAI)', async () => {
305
+ const unknownIdFixture = {
306
+ data: [
307
+ // No owned_by, id doesn't map to any known provider
308
+ {id: 'some-unknown-model', object: 'model'},
309
+ // A real OpenAI entry with owned_by — detection should succeed via this entry
310
+ {id: 'gpt-5.4-mini', owned_by: 'openai'},
311
+ ],
312
+ object: 'list',
313
+ }
314
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(unknownIdFixture))) as unknown as typeof fetch
315
+
316
+ // Should still pass — openai is detected via owned_by on the second entry,
317
+ // and the unknown entry does not cause a crash.
318
+ await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
319
+ })
320
+
254
321
  it('error path: dual providers, no owned_by=openai entries — throws no-openai-models message', async () => {
255
322
  const anthropicOnlyData = {
256
323
  data: [
@@ -337,6 +404,94 @@ describe('validateSetupOptions — providers/model validation', () => {
337
404
  })
338
405
  })
339
406
 
407
+ // ── FIX 1: owned_by:'' falls back to id inference ────────────────────────────
408
+
409
+ describe('verifyModelsAvailable — owned_by empty string falls back to id inference', () => {
410
+ let originalFetch: typeof globalThis.fetch
411
+ afterEach(() => {
412
+ globalThis.fetch = originalFetch
413
+ })
414
+ originalFetch = globalThis.fetch
415
+
416
+ it('entry with owned_by:"" and id "openai/gpt-5.4-mini" is detected as OpenAI (does not throw no-openai-models)', async () => {
417
+ const fixture = {
418
+ data: [{id: 'openai/gpt-5.4-mini', owned_by: ''}],
419
+ }
420
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
421
+
422
+ // Must resolve — empty owned_by should fall back to id prefix inference
423
+ await expect(
424
+ verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/gpt-5.4-mini'),
425
+ ).resolves.toBeUndefined()
426
+ })
427
+ })
428
+
429
+ // ── FIX 2: v7-prefixed model presence ────────────────────────────────────────
430
+
431
+ describe('verifyModelsAvailable — v7-prefixed model id matched without owned_by', () => {
432
+ let originalFetch: typeof globalThis.fetch
433
+ afterEach(() => {
434
+ globalThis.fetch = originalFetch
435
+ })
436
+ originalFetch = globalThis.fetch
437
+
438
+ it('entry with id "openai/gpt-5.4-mini" (no owned_by) resolves when requesting "openai/gpt-5.4-mini"', async () => {
439
+ const fixture = {
440
+ data: [{id: 'openai/gpt-5.4-mini'}],
441
+ }
442
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
443
+
444
+ await expect(
445
+ verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/gpt-5.4-mini'),
446
+ ).resolves.toBeUndefined()
447
+ })
448
+
449
+ it('model not found error lists requested model and available openai id but not anthropic ids', async () => {
450
+ const fixture = {
451
+ data: [{id: 'openai/gpt-5.4-mini'}, {id: 'anthropic/claude-sonnet-4-6'}],
452
+ }
453
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(fixture))) as unknown as typeof fetch
454
+
455
+ let errorMessage = ''
456
+ try {
457
+ await verifyModelsAvailable('https://cliproxy.fro.bot', 'sk-test-key', ['openai'], 'openai/nonexistent')
458
+ } catch (error) {
459
+ errorMessage = error instanceof Error ? error.message : String(error)
460
+ }
461
+
462
+ expect(errorMessage).toContain('nonexistent')
463
+ expect(errorMessage).toContain('openai/gpt-5.4-mini')
464
+ expect(errorMessage).not.toContain('anthropic')
465
+ expect(errorMessage).not.toContain('claude')
466
+ })
467
+ })
468
+
469
+ // ── FIX 3: exact key redacted in /v1/models error body ───────────────────────
470
+
471
+ describe('verifyModelsAvailable — exact key redacted in error body', () => {
472
+ let originalFetch: typeof globalThis.fetch
473
+ afterEach(() => {
474
+ globalThis.fetch = originalFetch
475
+ })
476
+ originalFetch = globalThis.fetch
477
+
478
+ it('500 response body containing the raw key does not expose the key in thrown message', async () => {
479
+ const rawKey = 'my-plain-key-no-bearer-prefix'
480
+ const body = `Internal error: key=${rawKey} was invalid`
481
+ globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
482
+
483
+ let errorMessage = ''
484
+ try {
485
+ await verifyModelsAvailable('https://cliproxy.fro.bot', rawKey, ['openai'], 'openai/gpt-5.4-mini')
486
+ } catch (error) {
487
+ errorMessage = error instanceof Error ? error.message : String(error)
488
+ }
489
+
490
+ expect(errorMessage).toContain('500')
491
+ expect(errorMessage).not.toContain(rawKey)
492
+ })
493
+ })
494
+
340
495
  // ── assertProxyReachable (new TDD tests) ──────────────────────────────────────
341
496
 
342
497
  describe('assertProxyReachable', () => {
@@ -366,6 +521,19 @@ describe('assertProxyReachable', () => {
366
521
 
367
522
  await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Proxy check failed/)
368
523
  })
524
+
525
+ it('probes /healthz: resolves when /healthz returns 200 and bare base returns 404', async () => {
526
+ const BASE = 'https://proxy.example'
527
+ let fetchedUrl: string | undefined
528
+ globalThis.fetch = mock(async (url: string) => {
529
+ fetchedUrl = url
530
+ if (url === `${BASE}/healthz`) return new Response('{"status":"ok"}', {status: 200})
531
+ return new Response('Not Found', {status: 404})
532
+ }) as unknown as typeof fetch
533
+
534
+ await expect(assertProxyReachable(BASE)).resolves.toBeUndefined()
535
+ expect(fetchedUrl).toBe(`${BASE}/healthz`)
536
+ })
369
537
  })
370
538
 
371
539
  // ── assertProxyKeyWorks (new TDD tests) ───────────────────────────────────────
@@ -4,11 +4,12 @@ import {z} from 'zod'
4
4
 
5
5
  import {parseProviders, type ProviderId} from './providers'
6
6
 
7
- // Permissive schema: unknown fields preserved via passthrough()
7
+ // Permissive schema: unknown fields preserved via passthrough().
8
+ // owned_by is optional — CLIProxyAPI v7 may omit it; provider is inferred from id instead.
8
9
  const modelEntrySchema = z
9
10
  .object({
10
11
  id: z.string(),
11
- owned_by: z.string(),
12
+ owned_by: z.string().optional(),
12
13
  })
13
14
  .passthrough()
14
15
 
@@ -22,6 +23,32 @@ export const MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])
22
23
 
23
24
  const HTTP_TIMEOUT_MS = 10_000
24
25
 
26
+ type ModelEntry = z.infer<typeof modelEntrySchema>
27
+
28
+ // Known bare-id patterns per provider, used only when owned_by is absent
29
+ // (e.g. CLIProxyAPI v7 omits owned_by from /v1/models entries).
30
+ const PROVIDER_ID_PATTERNS: Record<string, RegExp> = {
31
+ openai: /^(?:gpt-|o1-|o3-|codex)/i,
32
+ anthropic: /^claude-/i,
33
+ }
34
+
35
+ /**
36
+ * Decides whether a /v1/models entry belongs to a provider.
37
+ * Prefers owned_by when present; falls back to id prefix / known bare-id
38
+ * patterns when absent. Display/validation-only — never an auth or trust signal.
39
+ */
40
+ function entryMatchesProvider(entry: ModelEntry, provider: string): boolean {
41
+ if (entry.owned_by !== undefined && entry.owned_by.trim() !== '') {
42
+ return entry.owned_by === provider
43
+ }
44
+ const id = entry.id ?? ''
45
+ if (id.startsWith(`${provider}/`)) {
46
+ return true
47
+ }
48
+ const pattern = PROVIDER_ID_PATTERNS[provider]
49
+ return pattern === undefined ? false : pattern.test(id)
50
+ }
51
+
25
52
  /** Local copy — avoids a circular import with setup.ts (validation → setup → validation). */
26
53
  function extractErrorMessage(error: unknown): string {
27
54
  return error instanceof Error ? error.message : String(error)
@@ -97,8 +124,8 @@ export async function verifyModelsAvailable(
97
124
 
98
125
  if (!response.ok) {
99
126
  const rawBody = await response.text()
100
- // Redact any Authorization headers or sk-* token-shaped strings that the server might echo
101
- const redacted = rawBody
127
+ // Redact the literal key value, Authorization headers, and sk-* shaped tokens
128
+ const redacted = (key.length > 0 ? rawBody.replaceAll(key, '<redacted>') : rawBody)
102
129
  .replaceAll(/Bearer\s+[^\s"]+/g, 'Bearer <redacted>')
103
130
  .replaceAll(/sk-[\w.-]{8,}/g, 'sk-<redacted>')
104
131
  const excerpt = redacted.slice(0, 200)
@@ -120,9 +147,9 @@ export async function verifyModelsAvailable(
120
147
 
121
148
  const entries = parsed.data
122
149
 
123
- // OpenAI presence check
150
+ // OpenAI presence check.
124
151
  if (providers.includes('openai')) {
125
- const hasOpenAi = entries.some(e => e.owned_by === 'openai')
152
+ const hasOpenAi = entries.some(e => entryMatchesProvider(e, 'openai'))
126
153
  if (!hasOpenAi) {
127
154
  throw new Error('No OpenAI models on proxy — is the Codex token loaded? Try `cliproxy login codex`.')
128
155
  }
@@ -133,11 +160,11 @@ export async function verifyModelsAvailable(
133
160
  const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1)
134
161
  const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined
135
162
 
136
- const modelPresent = entries.some(e => e.id === bareId)
163
+ const modelPresent = entries.some(e => e.id === bareId || e.id === model)
137
164
  if (!modelPresent) {
138
165
  // List available ids for the matching provider only
139
166
  const matchingIds = providerPrefix
140
- ? entries.filter(e => e.owned_by === providerPrefix).map(e => e.id)
167
+ ? entries.filter(e => entryMatchesProvider(e, providerPrefix)).map(e => e.id)
141
168
  : entries.map(e => e.id)
142
169
  const available = matchingIds.length > 0 ? matchingIds.join(', ') : '(none)'
143
170
  throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? 'models'}: ${available}`)
@@ -146,7 +173,7 @@ export async function verifyModelsAvailable(
146
173
 
147
174
  export async function assertProxyReachable(baseUrl: string): Promise<void> {
148
175
  try {
149
- const response = await fetch(baseUrl, {
176
+ const response = await fetch(`${baseUrl}/healthz`, {
150
177
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
151
178
  })
152
179