@marcusrbrown/infra 0.13.1 → 0.13.3

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.
Files changed (108) hide show
  1. package/dist/cli.js +11965 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,567 +0,0 @@
1
- /// <reference types="bun" />
2
-
3
- import {afterEach, describe, expect, it, mock} from 'bun:test'
4
-
5
- import {
6
- assertProxyKeyWorks,
7
- assertProxyReachable,
8
- MODEL_ID_RE,
9
- validateSetupOptions,
10
- verifyModelsAvailable,
11
- } from './validation'
12
-
13
- // ── validateSetupOptions ──────────────────────────────────────────────────────
14
-
15
- describe('validateSetupOptions', () => {
16
- it('requires --key in non-interactive mode', () => {
17
- expect(() => validateSetupOptions({repo: 'owner/repo', harness: 'opencode'}, false)).toThrow(
18
- '--key is required when stdin is not a TTY',
19
- )
20
- })
21
-
22
- it('requires --repo in non-interactive mode', () => {
23
- expect(() => validateSetupOptions({key: 'sk-test', harness: 'opencode'}, false)).toThrow(
24
- '--repo is required when stdin is not a TTY',
25
- )
26
- })
27
-
28
- it('requires --harness in non-interactive mode', () => {
29
- expect(() => validateSetupOptions({key: 'sk-test', repo: 'owner/repo'}, false)).toThrow(
30
- '--harness is required when stdin is not a TTY',
31
- )
32
- })
33
- })
34
-
35
- // ── model flag validation (MODEL_ID_RE) ───────────────────────────────────────
36
-
37
- describe('model flag validation', () => {
38
- it('accepts "openai/gpt-5.4-mini"', () => {
39
- expect(MODEL_ID_RE.test('openai/gpt-5.4-mini')).toBe(true)
40
- })
41
-
42
- it('rejects "gpt-5.4-mini" (no provider prefix)', () => {
43
- expect(MODEL_ID_RE.test('gpt-5.4-mini')).toBe(false)
44
- })
45
-
46
- it('rejects "openai/GPT-5.4-mini" (uppercase)', () => {
47
- expect(MODEL_ID_RE.test('openai/GPT-5.4-mini')).toBe(false)
48
- })
49
-
50
- it('rejects "openai/gpt-5.4-mini; rm -rf /" (injection attempt)', () => {
51
- expect(MODEL_ID_RE.test('openai/gpt-5.4-mini; rm -rf /')).toBe(false)
52
- })
53
-
54
- it('rejects "openai/gpt-4o." (trailing dot)', () => {
55
- expect(MODEL_ID_RE.test('openai/gpt-4o.')).toBe(false)
56
- })
57
-
58
- it('rejects "openai/gpt-4o-" (trailing hyphen)', () => {
59
- expect(MODEL_ID_RE.test('openai/gpt-4o-')).toBe(false)
60
- })
61
-
62
- it('accepts "openai/gpt-4o" (regression — still works)', () => {
63
- expect(MODEL_ID_RE.test('openai/gpt-4o')).toBe(true)
64
- })
65
-
66
- it('accepts "anthropic/claude-sonnet-4-6" (regression)', () => {
67
- expect(MODEL_ID_RE.test('anthropic/claude-sonnet-4-6')).toBe(true)
68
- })
69
-
70
- it('accepts "openai/a" (single-char tail)', () => {
71
- expect(MODEL_ID_RE.test('openai/a')).toBe(true)
72
- })
73
-
74
- it('rejects "openai/" (empty tail)', () => {
75
- expect(MODEL_ID_RE.test('openai/')).toBe(false)
76
- })
77
- })
78
-
79
- // ── verifyModelsAvailable ─────────────────────────────────────────────────────
80
-
81
- describe('verifyModelsAvailable', () => {
82
- // Realistic fixture matching the plan spec
83
- const MODELS_FIXTURE = {
84
- data: [
85
- {id: 'claude-3-7-sonnet-20250219', owned_by: 'anthropic'},
86
- {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
87
- {id: 'gpt-5.4-mini', owned_by: 'openai'},
88
- {id: 'gpt-5.5', owned_by: 'openai'},
89
- ],
90
- }
91
-
92
- const BASE_URL = 'https://cliproxy.fro.bot'
93
- const KEY = 'sk-test-key'
94
-
95
- // Save and restore globalThis.fetch around each test
96
- let originalFetch: typeof globalThis.fetch
97
- afterEach(() => {
98
- globalThis.fetch = originalFetch
99
- })
100
- // Capture original before any test runs
101
- originalFetch = globalThis.fetch
102
-
103
- it('anthropic-only short-circuit: returns immediately without calling fetch', async () => {
104
- const fetchSpy = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
105
- globalThis.fetch = fetchSpy as unknown as typeof fetch
106
-
107
- await verifyModelsAvailable(BASE_URL, KEY, ['anthropic'], 'anthropic/claude-sonnet-4-6')
108
-
109
- expect(fetchSpy.mock.calls.length).toBe(0)
110
- })
111
-
112
- it('happy path: openai-only, model present, owned_by openai — passes without throw', async () => {
113
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
114
-
115
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
116
- })
117
-
118
- it('happy path: dual providers, anthropic model present, openai entries exist — passes', async () => {
119
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
120
-
121
- await expect(
122
- verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'anthropic/claude-sonnet-4-6'),
123
- ).resolves.toBeUndefined()
124
- })
125
-
126
- it('error path: 401 throws "Proxy key rejected" message', async () => {
127
- globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
128
-
129
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
130
- 'Proxy key rejected',
131
- )
132
- })
133
-
134
- it('error path: 401 error message does NOT contain the Authorization header value', async () => {
135
- globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
136
-
137
- let errorMessage = ''
138
- try {
139
- await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
140
- } catch (error) {
141
- errorMessage = error instanceof Error ? error.message : String(error)
142
- }
143
-
144
- expect(errorMessage).not.toContain(KEY)
145
- expect(errorMessage).not.toContain('Bearer')
146
- })
147
-
148
- it('error path: 403 throws "Proxy key rejected" message', async () => {
149
- globalThis.fetch = mock(async () => new Response('Forbidden', {status: 403})) as unknown as typeof fetch
150
-
151
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
152
- 'Proxy key rejected',
153
- )
154
- })
155
-
156
- it('error path: 500 throws with status and truncated body; no Authorization header in message', async () => {
157
- const body = 'Internal Server Error — something went wrong on the proxy'
158
- globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
159
-
160
- let errorMessage = ''
161
- try {
162
- await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
163
- } catch (error) {
164
- errorMessage = error instanceof Error ? error.message : String(error)
165
- }
166
-
167
- expect(errorMessage).toContain('500')
168
- expect(errorMessage).not.toContain(KEY)
169
- expect(errorMessage).not.toContain('Bearer')
170
- })
171
-
172
- it('error path: 200 with data:[] and openai in providers throws no-openai-models message', async () => {
173
- globalThis.fetch = mock(async () => new Response(JSON.stringify({data: []}))) as unknown as typeof fetch
174
-
175
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
176
- 'No OpenAI models on proxy',
177
- )
178
- })
179
-
180
- it('error path: model not present in data — throws and lists available openai ids', async () => {
181
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
182
-
183
- let errorMessage = ''
184
- try {
185
- await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-99-unknown')
186
- } catch (error) {
187
- errorMessage = error instanceof Error ? error.message : String(error)
188
- }
189
-
190
- expect(errorMessage).toContain('gpt-99-unknown')
191
- // Should list available openai models
192
- expect(errorMessage).toContain('gpt-5.4-mini')
193
- expect(errorMessage).toContain('gpt-5.5')
194
- // Should NOT list anthropic models
195
- expect(errorMessage).not.toContain('claude')
196
- })
197
-
198
- it('error path: model not present and provider is anthropic — lists available anthropic ids', async () => {
199
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
200
-
201
- let errorMessage = ''
202
- try {
203
- await verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'anthropic/claude-unknown-model')
204
- } catch (error) {
205
- errorMessage = error instanceof Error ? error.message : String(error)
206
- }
207
-
208
- expect(errorMessage).toContain('claude-unknown-model')
209
- // Should list available anthropic models
210
- expect(errorMessage).toContain('claude-3-7-sonnet-20250219')
211
- expect(errorMessage).toContain('claude-sonnet-4-6')
212
- // Should NOT list openai models
213
- expect(errorMessage).not.toContain('gpt-')
214
- })
215
-
216
- it('error path: data is a string (not array) — throws Zod-derived error mentioning "data" and array/Expected', async () => {
217
- globalThis.fetch = mock(async () => new Response(JSON.stringify({data: 'not-an-array'}))) as unknown as typeof fetch
218
-
219
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
220
- /data.*Expected|Expected.*data|data.*array/i,
221
- )
222
- })
223
-
224
- it('error path: data is missing (response is {}) — throws Zod-derived error indicating data is required', async () => {
225
- globalThis.fetch = mock(async () => new Response(JSON.stringify({}))) as unknown as typeof fetch
226
-
227
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(/data/i)
228
- })
229
-
230
- it('happy path (passthrough): extra top-level field ignored — passes', async () => {
231
- const fixtureWithExtra = {
232
- data: [
233
- {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
234
- {id: 'gpt-5.4-mini', owned_by: 'openai'},
235
- ],
236
- extraField: 'ignored',
237
- }
238
- globalThis.fetch = mock(async () => new Response(JSON.stringify(fixtureWithExtra))) as unknown as typeof fetch
239
-
240
- await expect(
241
- verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/gpt-5.4-mini'),
242
- ).resolves.toBeUndefined()
243
- })
244
-
245
- it('happy path (passthrough on entries): extra entry field ignored — passes', async () => {
246
- const fixtureWithEntryExtra = {
247
- data: [{id: 'gpt-5.4-mini', owned_by: 'openai', extraEntryField: 'ignored'}],
248
- }
249
- globalThis.fetch = mock(async () => new Response(JSON.stringify(fixtureWithEntryExtra))) as unknown as typeof fetch
250
-
251
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')).resolves.toBeUndefined()
252
- })
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
-
321
- it('error path: dual providers, no owned_by=openai entries — throws no-openai-models message', async () => {
322
- const anthropicOnlyData = {
323
- data: [
324
- {id: 'claude-3-7-sonnet-20250219', owned_by: 'anthropic'},
325
- {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
326
- ],
327
- }
328
- globalThis.fetch = mock(async () => new Response(JSON.stringify(anthropicOnlyData))) as unknown as typeof fetch
329
-
330
- await expect(verifyModelsAvailable(BASE_URL, KEY, ['anthropic', 'openai'], 'openai/gpt-5.4-mini')).rejects.toThrow(
331
- 'No OpenAI models on proxy',
332
- )
333
- })
334
- })
335
-
336
- // ── validateSetupOptions — providers/model validation ─────────────────────────
337
-
338
- describe('validateSetupOptions — providers/model validation', () => {
339
- it('regression: no providers/model passes unchanged (anthropic-only default)', () => {
340
- expect(() => validateSetupOptions({key: 'sk-test', repo: 'owner/repo', harness: 'opencode'}, false)).not.toThrow()
341
- })
342
-
343
- it('happy path: single provider anthropic, no model — passes', () => {
344
- expect(() =>
345
- validateSetupOptions({key: 'sk-test', repo: 'owner/repo', harness: 'opencode', providers: 'anthropic'}, false),
346
- ).not.toThrow()
347
- })
348
-
349
- it('happy path: openai + model with openai prefix — passes', () => {
350
- expect(() =>
351
- validateSetupOptions(
352
- {key: 'sk-test', repo: 'owner/repo', harness: 'opencode', providers: 'openai', model: 'openai/gpt-5.4-mini'},
353
- false,
354
- ),
355
- ).not.toThrow()
356
- })
357
-
358
- it('happy path: anthropic,openai + model with openai prefix — passes', () => {
359
- expect(() =>
360
- validateSetupOptions(
361
- {
362
- key: 'sk-test',
363
- repo: 'owner/repo',
364
- harness: 'opencode',
365
- providers: 'anthropic,openai',
366
- model: 'openai/gpt-5.4-mini',
367
- },
368
- false,
369
- ),
370
- ).not.toThrow()
371
- })
372
-
373
- it('error: multiple providers without --model throws "Pass --model" error', () => {
374
- expect(() => validateSetupOptions({harness: 'opencode', providers: 'anthropic,openai'}, false)).toThrow(
375
- 'Pass --model <provider/model-id> when selecting multiple providers.',
376
- )
377
- })
378
-
379
- it('error: model prefix does not match single provider (anthropic provider, openai model)', () => {
380
- expect(() =>
381
- validateSetupOptions({harness: 'opencode', providers: 'anthropic', model: 'openai/gpt-5.4-mini'}, false),
382
- ).toThrow(/Model prefix openai does not match selected providers/)
383
- })
384
-
385
- it('error: model prefix does not match single provider (openai provider, anthropic model)', () => {
386
- expect(() =>
387
- validateSetupOptions({harness: 'opencode', providers: 'openai', model: 'anthropic/claude-sonnet-4-6'}, false),
388
- ).toThrow(/Model prefix anthropic does not match selected providers/)
389
- })
390
-
391
- it('error: duplicate providers throws from parseProviders', () => {
392
- expect(() => validateSetupOptions({harness: 'opencode', providers: 'anthropic,anthropic'}, false)).toThrow(
393
- /duplicate/,
394
- )
395
- })
396
-
397
- it('error: unknown provider throws from parseProviders', () => {
398
- expect(() => validateSetupOptions({harness: 'opencode', providers: 'claude'}, false)).toThrow(/Unknown provider/)
399
- })
400
-
401
- it('interactive mode: providers/model checks are skipped even with invalid combo', () => {
402
- // Multiple providers without model — would fail in non-interactive, but interactive skips all checks
403
- expect(() => validateSetupOptions({providers: 'anthropic,openai'}, true)).not.toThrow()
404
- })
405
- })
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
-
495
- // ── assertProxyReachable (new TDD tests) ──────────────────────────────────────
496
-
497
- describe('assertProxyReachable', () => {
498
- let originalFetch: typeof globalThis.fetch
499
- afterEach(() => {
500
- globalThis.fetch = originalFetch
501
- })
502
- originalFetch = globalThis.fetch
503
-
504
- it('happy path: fetch returns HTTP 200 — resolves without throw', async () => {
505
- globalThis.fetch = mock(async () => new Response('OK', {status: 200})) as unknown as typeof fetch
506
-
507
- await expect(assertProxyReachable('https://good.example')).resolves.toBeUndefined()
508
- })
509
-
510
- it('error path: fetch throws AbortError — throws with "Unable to reach proxy" prefix', async () => {
511
- const abortError = new DOMException('The operation was aborted.', 'AbortError')
512
- globalThis.fetch = mock(async () => {
513
- throw abortError
514
- }) as unknown as typeof fetch
515
-
516
- await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Unable to reach proxy/)
517
- })
518
-
519
- it('error path: fetch returns non-ok status — throws with "Proxy check failed" prefix', async () => {
520
- globalThis.fetch = mock(async () => new Response('Bad Gateway', {status: 502})) as unknown as typeof fetch
521
-
522
- await expect(assertProxyReachable('https://bad.example')).rejects.toThrow(/Proxy check failed/)
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
- })
537
- })
538
-
539
- // ── assertProxyKeyWorks (new TDD tests) ───────────────────────────────────────
540
-
541
- describe('assertProxyKeyWorks', () => {
542
- let originalFetch: typeof globalThis.fetch
543
- afterEach(() => {
544
- globalThis.fetch = originalFetch
545
- })
546
- originalFetch = globalThis.fetch
547
-
548
- it('happy path: fetch returns HTTP 200 — resolves without throw', async () => {
549
- globalThis.fetch = mock(async () => new Response('OK', {status: 200})) as unknown as typeof fetch
550
-
551
- await expect(assertProxyKeyWorks('https://good.example', 'sk-good')).resolves.toBeUndefined()
552
- })
553
-
554
- it('error path: fetch returns HTTP 401 — throws with "Unable to verify proxy key" prefix', async () => {
555
- globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
556
-
557
- await expect(assertProxyKeyWorks('https://good.example', 'sk-bad')).rejects.toThrow(/Proxy key verification failed/)
558
- })
559
-
560
- it('error path: fetch throws network error — throws with "Unable to verify proxy key" prefix', async () => {
561
- globalThis.fetch = mock(async () => {
562
- throw new Error('network failure')
563
- }) as unknown as typeof fetch
564
-
565
- await expect(assertProxyKeyWorks('https://good.example', 'sk-good')).rejects.toThrow(/Unable to verify proxy key/)
566
- })
567
- })