@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
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|