@marcusrbrown/infra 0.13.0 → 0.13.2

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 +4977 -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,592 +0,0 @@
1
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
- import {goke} from 'goke'
3
-
4
- import {createCapturedCtx, MockProcessExit} from '../../__test__/mcp-ctx-fixture'
5
- import {cliproxyModelsAction, registerCliproxyModels} from './models'
6
-
7
- const originalFetch = globalThis.fetch
8
-
9
- type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
10
-
11
- function createFetchImplementation(handler: FetchReplacement): typeof fetch {
12
- return Object.assign(
13
- (input: string | URL | Request, init?: RequestInit) => {
14
- if (typeof input !== 'string') {
15
- throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
16
- }
17
-
18
- return handler(input, init)
19
- },
20
- {preconnect: originalFetch.preconnect},
21
- )
22
- }
23
-
24
- // Fixture: mixed providers with known created timestamps for ordering assertions.
25
- // anthropic group: claude-3-5-sonnet (created=1700000000) < claude-opus-4 (created=1710000000)
26
- // openai group: gpt-4o (created=1705000000) < gpt-4o-mini (created=1706000000)
27
- // Provider groups sorted ascending: anthropic < openai
28
- // Expected plain order: anthropic/claude-3-5-sonnet-20241022, anthropic/claude-opus-4, openai/gpt-4o, openai/gpt-4o-mini
29
- const MIXED_MODELS_RESPONSE = {
30
- object: 'list',
31
- data: [
32
- {id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic', created: 1_700_000_000},
33
- {id: 'claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000},
34
- {id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
35
- {id: 'gpt-4o-mini', object: 'model', owned_by: 'openai', created: 1_706_000_000},
36
- ],
37
- }
38
-
39
- const OPENAI_ONLY_RESPONSE = {
40
- object: 'list',
41
- data: [
42
- {id: 'gpt-4o', object: 'model', owned_by: 'openai', created: 1_705_000_000},
43
- {id: 'gpt-4o-mini', object: 'model', owned_by: 'openai', created: 1_706_000_000},
44
- ],
45
- }
46
-
47
- // Models without owned_by — provider inferred from id prefix
48
- const NO_OWNED_BY_RESPONSE = {
49
- object: 'list',
50
- data: [
51
- {id: 'claude-3-5-sonnet-20241022', object: 'model', created: 1_700_000_000},
52
- {id: 'gpt-4o', object: 'model', created: 1_705_000_000},
53
- ],
54
- }
55
-
56
- // Model with no owned_by and no matching pattern → unknown provider
57
- const UNKNOWN_PROVIDER_RESPONSE = {
58
- object: 'list',
59
- data: [
60
- {id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic', created: 1_700_000_000},
61
- {id: 'some-mystery-model-v1', object: 'model', created: 1_715_000_000},
62
- ],
63
- }
64
-
65
- // Fixture with deliberately reversed order to test date-asc sorting within a group
66
- const REVERSED_ORDER_RESPONSE = {
67
- object: 'list',
68
- data: [
69
- {id: 'claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000},
70
- {id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic', created: 1_700_000_000},
71
- ],
72
- }
73
-
74
- describe('cliproxyModelsAction — plain output', () => {
75
- beforeEach(() => {
76
- globalThis.fetch = createFetchImplementation(async () => {
77
- throw new Error('Unexpected fetch call')
78
- })
79
- })
80
-
81
- afterEach(() => {
82
- globalThis.fetch = originalFetch
83
- })
84
-
85
- it('emits provider/id format (not bare id) for each model', async () => {
86
- globalThis.fetch = createFetchImplementation(
87
- async () =>
88
- new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
89
- status: 200,
90
- headers: {'content-type': 'application/json'},
91
- }),
92
- )
93
-
94
- const {ctx, captured} = createCapturedCtx()
95
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
96
-
97
- const output = captured.stdout.join('\n')
98
- expect(output).toContain('anthropic/claude-3-5-sonnet-20241022')
99
- expect(output).toContain('anthropic/claude-opus-4')
100
- expect(output).toContain('openai/gpt-4o')
101
- expect(output).toContain('openai/gpt-4o-mini')
102
- // Must NOT emit bare ids without prefix
103
- expect(output).not.toMatch(/^claude-/m)
104
- expect(output).not.toMatch(/^gpt-/m)
105
- })
106
-
107
- it('groups by provider (anthropic before openai) and sorts date-asc within each group', async () => {
108
- globalThis.fetch = createFetchImplementation(
109
- async () =>
110
- new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
111
- status: 200,
112
- headers: {'content-type': 'application/json'},
113
- }),
114
- )
115
-
116
- const {ctx, captured} = createCapturedCtx()
117
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
118
-
119
- const lines = captured.stdout.join('\n').split('\n').filter(Boolean)
120
- expect(lines).toEqual([
121
- 'anthropic/claude-3-5-sonnet-20241022',
122
- 'anthropic/claude-opus-4',
123
- 'openai/gpt-4o',
124
- 'openai/gpt-4o-mini',
125
- ])
126
- })
127
-
128
- it('sorts date-asc within a provider group even when API returns them in reverse order', async () => {
129
- globalThis.fetch = createFetchImplementation(
130
- async () =>
131
- new Response(JSON.stringify(REVERSED_ORDER_RESPONSE), {
132
- status: 200,
133
- headers: {'content-type': 'application/json'},
134
- }),
135
- )
136
-
137
- const {ctx, captured} = createCapturedCtx()
138
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
139
-
140
- const lines = captured.stdout.join('\n').split('\n').filter(Boolean)
141
- // claude-3-5-sonnet (created=1700000000) must come before claude-opus-4 (created=1710000000)
142
- expect(lines).toEqual(['anthropic/claude-3-5-sonnet-20241022', 'anthropic/claude-opus-4'])
143
- })
144
-
145
- it('filters to openai models only when provider=openai, still emits provider/id', async () => {
146
- globalThis.fetch = createFetchImplementation(
147
- async () =>
148
- new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
149
- status: 200,
150
- headers: {'content-type': 'application/json'},
151
- }),
152
- )
153
-
154
- const {ctx, captured} = createCapturedCtx()
155
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'openai'}, ctx)
156
-
157
- const output = captured.stdout.join('\n')
158
- expect(output).toContain('openai/gpt-4o')
159
- expect(output).toContain('openai/gpt-4o-mini')
160
- expect(output).not.toContain('claude')
161
- })
162
-
163
- it('infers anthropic provider via id pattern when owned_by is absent', async () => {
164
- globalThis.fetch = createFetchImplementation(
165
- async () =>
166
- new Response(JSON.stringify(NO_OWNED_BY_RESPONSE), {
167
- status: 200,
168
- headers: {'content-type': 'application/json'},
169
- }),
170
- )
171
-
172
- const {ctx, captured} = createCapturedCtx()
173
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'anthropic'}, ctx)
174
-
175
- const output = captured.stdout.join('\n')
176
- expect(output).toContain('anthropic/claude-3-5-sonnet-20241022')
177
- expect(output).not.toContain('gpt-4o')
178
- })
179
-
180
- it('infers openai provider via id pattern when owned_by is absent', async () => {
181
- globalThis.fetch = createFetchImplementation(
182
- async () =>
183
- new Response(JSON.stringify(NO_OWNED_BY_RESPONSE), {
184
- status: 200,
185
- headers: {'content-type': 'application/json'},
186
- }),
187
- )
188
-
189
- const {ctx, captured} = createCapturedCtx()
190
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'openai'}, ctx)
191
-
192
- const output = captured.stdout.join('\n')
193
- expect(output).toContain('openai/gpt-4o')
194
- expect(output).not.toContain('claude')
195
- })
196
-
197
- it('prefixes unknown/<id> for entries with no owned_by and no matching pattern, sorted last', async () => {
198
- globalThis.fetch = createFetchImplementation(
199
- async () =>
200
- new Response(JSON.stringify(UNKNOWN_PROVIDER_RESPONSE), {
201
- status: 200,
202
- headers: {'content-type': 'application/json'},
203
- }),
204
- )
205
-
206
- const {ctx, captured} = createCapturedCtx()
207
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
208
-
209
- const lines = captured.stdout.join('\n').split('\n').filter(Boolean)
210
- // anthropic group first, unknown group last
211
- expect(lines[0]).toBe('anthropic/claude-3-5-sonnet-20241022')
212
- expect(lines.at(-1)).toBe('unknown/some-mystery-model-v1')
213
- })
214
-
215
- it('does not double-prefix an id that already has a provider/ prefix', async () => {
216
- const alreadyPrefixedResponse = {
217
- object: 'list',
218
- data: [{id: 'anthropic/claude-opus-4', object: 'model', owned_by: 'anthropic', created: 1_710_000_000}],
219
- }
220
-
221
- globalThis.fetch = createFetchImplementation(
222
- async () =>
223
- new Response(JSON.stringify(alreadyPrefixedResponse), {
224
- status: 200,
225
- headers: {'content-type': 'application/json'},
226
- }),
227
- )
228
-
229
- const {ctx, captured} = createCapturedCtx()
230
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
231
-
232
- const output = captured.stdout.join('\n')
233
- expect(output).toContain('anthropic/claude-opus-4')
234
- expect(output).not.toContain('anthropic/anthropic/')
235
- })
236
- })
237
-
238
- describe('cliproxyModelsAction — verbose output', () => {
239
- afterEach(() => {
240
- globalThis.fetch = originalFetch
241
- })
242
-
243
- it('emits a single valid JSON array parseable by JSON.parse', async () => {
244
- globalThis.fetch = createFetchImplementation(
245
- async () =>
246
- new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
247
- status: 200,
248
- headers: {'content-type': 'application/json'},
249
- }),
250
- )
251
-
252
- const {ctx, captured} = createCapturedCtx()
253
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
254
-
255
- const raw = captured.stdout.join('')
256
- const parsed = JSON.parse(raw)
257
- expect(Array.isArray(parsed)).toBe(true)
258
- })
259
-
260
- it('verbose entries have {id, provider, raw_id, created} shape with prefixed id', async () => {
261
- globalThis.fetch = createFetchImplementation(
262
- async () =>
263
- new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
264
- status: 200,
265
- headers: {'content-type': 'application/json'},
266
- }),
267
- )
268
-
269
- const {ctx, captured} = createCapturedCtx()
270
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
271
-
272
- const parsed: {id: string; provider: string; raw_id: string; created: string | null}[] = JSON.parse(
273
- captured.stdout.join(''),
274
- )
275
- expect(parsed.length).toBe(2)
276
-
277
- // parsed.length === 2 asserted above; non-null access is safe
278
- const first = parsed[0]!
279
- expect(first).toHaveProperty('id')
280
- expect(first).toHaveProperty('provider')
281
- expect(first).toHaveProperty('raw_id')
282
- expect(first).toHaveProperty('created')
283
- // id must be prefixed
284
- expect(first.id).toBe('openai/gpt-4o')
285
- expect(first.provider).toBe('openai')
286
- expect(first.raw_id).toBe('gpt-4o')
287
- // created is ISO 8601 string
288
- expect(typeof first.created).toBe('string')
289
- expect(first.created).toMatch(/^\d{4}-\d{2}-\d{2}T/)
290
- })
291
-
292
- it('verbose output is date-asc sorted and provider-grouped (anthropic before openai)', async () => {
293
- globalThis.fetch = createFetchImplementation(
294
- async () =>
295
- new Response(JSON.stringify(MIXED_MODELS_RESPONSE), {
296
- status: 200,
297
- headers: {'content-type': 'application/json'},
298
- }),
299
- )
300
-
301
- const {ctx, captured} = createCapturedCtx()
302
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
303
-
304
- const parsed: {id: string; provider: string; raw_id: string; created: string | null}[] = JSON.parse(
305
- captured.stdout.join(''),
306
- )
307
- expect(parsed.map(e => e.id)).toEqual([
308
- 'anthropic/claude-3-5-sonnet-20241022',
309
- 'anthropic/claude-opus-4',
310
- 'openai/gpt-4o',
311
- 'openai/gpt-4o-mini',
312
- ])
313
- })
314
-
315
- it('verbose with empty data emits [] (not a text message)', async () => {
316
- globalThis.fetch = createFetchImplementation(
317
- async () =>
318
- new Response(JSON.stringify({object: 'list', data: []}), {
319
- status: 200,
320
- headers: {'content-type': 'application/json'},
321
- }),
322
- )
323
-
324
- const {ctx, captured} = createCapturedCtx()
325
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
326
-
327
- const raw = captured.stdout.join('')
328
- const parsed = JSON.parse(raw)
329
- expect(parsed).toEqual([])
330
- })
331
-
332
- it('verbose with zero-match provider filter emits [] (not a text message)', async () => {
333
- globalThis.fetch = createFetchImplementation(
334
- async () =>
335
- new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
336
- status: 200,
337
- headers: {'content-type': 'application/json'},
338
- }),
339
- )
340
-
341
- const {ctx, captured} = createCapturedCtx()
342
- await cliproxyModelsAction(
343
- {url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true, provider: 'anthropic'},
344
- ctx,
345
- )
346
-
347
- const raw = captured.stdout.join('')
348
- const parsed = JSON.parse(raw)
349
- expect(parsed).toEqual([])
350
- })
351
-
352
- it('verbose: created is null when entry has no created timestamp', async () => {
353
- const noCreatedResponse = {
354
- object: 'list',
355
- data: [{id: 'gpt-4o', object: 'model', owned_by: 'openai'}],
356
- }
357
-
358
- globalThis.fetch = createFetchImplementation(
359
- async () =>
360
- new Response(JSON.stringify(noCreatedResponse), {
361
- status: 200,
362
- headers: {'content-type': 'application/json'},
363
- }),
364
- )
365
-
366
- const {ctx, captured} = createCapturedCtx()
367
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', verbose: true}, ctx)
368
-
369
- const parsed: {id: string; provider: string; raw_id: string; created: string | null}[] = JSON.parse(
370
- captured.stdout.join(''),
371
- )
372
- expect(parsed.length).toBe(1)
373
- // parsed.length === 1 asserted above; non-null access is safe
374
- expect(parsed[0]!.created).toBeNull()
375
- })
376
- })
377
-
378
- describe('cliproxyModelsAction — empty results', () => {
379
- afterEach(() => {
380
- globalThis.fetch = originalFetch
381
- })
382
-
383
- it('prints "No models." when data array is empty (non-verbose)', async () => {
384
- globalThis.fetch = createFetchImplementation(
385
- async () =>
386
- new Response(JSON.stringify({object: 'list', data: []}), {
387
- status: 200,
388
- headers: {'content-type': 'application/json'},
389
- }),
390
- )
391
-
392
- const {ctx, captured} = createCapturedCtx()
393
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx)
394
-
395
- const output = captured.stdout.join('\n')
396
- expect(output).toContain('No models')
397
- expect(captured.exit).toBeNull()
398
- })
399
-
400
- it('prints provider-specific "No models" message when filter yields zero matches (non-verbose)', async () => {
401
- globalThis.fetch = createFetchImplementation(
402
- async () =>
403
- new Response(JSON.stringify(OPENAI_ONLY_RESPONSE), {
404
- status: 200,
405
- headers: {'content-type': 'application/json'},
406
- }),
407
- )
408
-
409
- const {ctx, captured} = createCapturedCtx()
410
- await cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'anthropic'}, ctx)
411
-
412
- const output = captured.stdout.join('\n')
413
- expect(output).toContain('for provider anthropic')
414
- expect(captured.exit).toBeNull()
415
- })
416
- })
417
-
418
- describe('cliproxyModelsAction — validation errors', () => {
419
- afterEach(() => {
420
- globalThis.fetch = originalFetch
421
- })
422
-
423
- it('exits with error for unknown provider without making a fetch call', async () => {
424
- let fetchCalled = false
425
- globalThis.fetch = createFetchImplementation(async () => {
426
- fetchCalled = true
427
- return new Response('{}', {status: 200})
428
- })
429
-
430
- const {ctx, captured} = createCapturedCtx()
431
- await expect(
432
- cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key', provider: 'gemini'}, ctx),
433
- ).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
434
-
435
- expect(fetchCalled).toBe(false)
436
- expect(captured.stderr.join('')).toMatch(/gemini|provider/i)
437
- expect(captured.exit).toEqual({code: 1})
438
- })
439
-
440
- it('exits with error when no API key is provided', async () => {
441
- const savedKey = process.env.CLIPROXY_API_KEY
442
- delete process.env.CLIPROXY_API_KEY
443
-
444
- try {
445
- const {ctx, captured} = createCapturedCtx()
446
- await expect(cliproxyModelsAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
447
- name: 'MockProcessExit',
448
- code: 1,
449
- })
450
-
451
- expect(captured.stderr.join('')).toMatch(/key|CLIPROXY_API_KEY/i)
452
- expect(captured.exit).toEqual({code: 1})
453
- } finally {
454
- if (savedKey !== undefined) process.env.CLIPROXY_API_KEY = savedKey
455
- }
456
- })
457
- })
458
-
459
- describe('cliproxyModelsAction — HTTP errors', () => {
460
- afterEach(() => {
461
- globalThis.fetch = originalFetch
462
- })
463
-
464
- it('exits with error on 401 and does not include the bearer key in output', async () => {
465
- const secretKey = 'super-secret-api-key-12345'
466
-
467
- globalThis.fetch = createFetchImplementation(async () => new Response('Unauthorized', {status: 401}))
468
-
469
- const {ctx, captured} = createCapturedCtx()
470
- await expect(
471
- cliproxyModelsAction({url: 'https://cliproxy.example.com', key: secretKey}, ctx),
472
- ).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
473
-
474
- const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
475
- expect(allOutput).not.toContain(secretKey)
476
- expect(captured.exit).toEqual({code: 1})
477
- })
478
-
479
- it('exits with error on malformed JSON response', async () => {
480
- globalThis.fetch = createFetchImplementation(
481
- async () =>
482
- new Response('not valid json {{{', {
483
- status: 200,
484
- headers: {'content-type': 'application/json'},
485
- }),
486
- )
487
-
488
- const {ctx, captured} = createCapturedCtx()
489
- await expect(
490
- cliproxyModelsAction({url: 'https://cliproxy.example.com', key: 'test-api-key'}, ctx),
491
- ).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
492
-
493
- expect(captured.stderr.join('')).toMatch(/json|malformed|parse/i)
494
- expect(captured.exit).toEqual({code: 1})
495
- })
496
- })
497
-
498
- describe('cliproxyModelsAction — ambient key not forwarded to untrusted URL', () => {
499
- let originalEnv: Record<string, string | undefined>
500
-
501
- beforeEach(() => {
502
- originalEnv = {
503
- CLIPROXY_URL: process.env.CLIPROXY_URL,
504
- CLIPROXY_API_KEY: process.env.CLIPROXY_API_KEY,
505
- }
506
- process.env.CLIPROXY_API_KEY = 'ambient-secret-key'
507
- delete process.env.CLIPROXY_URL
508
- })
509
-
510
- afterEach(() => {
511
- for (const [k, v] of Object.entries(originalEnv)) {
512
- if (v === undefined) {
513
- delete process.env[k]
514
- } else {
515
- process.env[k] = v
516
- }
517
- }
518
- globalThis.fetch = originalFetch
519
- })
520
-
521
- it('does not forward ambient CLIPROXY_API_KEY to a non-trusted --url', async () => {
522
- let capturedAuthHeader: string | null | undefined
523
- let fetchCalled = false
524
-
525
- globalThis.fetch = createFetchImplementation(async (_url, init) => {
526
- fetchCalled = true
527
- const hdrs = init?.headers
528
- if (hdrs instanceof Headers) {
529
- capturedAuthHeader = hdrs.get('Authorization')
530
- } else if (hdrs !== null && hdrs !== undefined && typeof hdrs === 'object') {
531
- const h = hdrs as Record<string, string>
532
- capturedAuthHeader = h.Authorization ?? h.authorization ?? null
533
- } else {
534
- capturedAuthHeader = null
535
- }
536
-
537
- return new Response(JSON.stringify({object: 'list', data: []}), {
538
- status: 200,
539
- headers: {'content-type': 'application/json'},
540
- })
541
- })
542
-
543
- const {ctx, captured} = createCapturedCtx()
544
- // Pass an explicit untrusted URL with no --key.
545
- // The ambient CLIPROXY_API_KEY must NOT be forwarded to the untrusted host.
546
- // Two valid outcomes: (a) no fetch is made at all (key withheld → "No API key" error),
547
- // or (b) fetch is made but without the ambient key in the Authorization header.
548
- try {
549
- await cliproxyModelsAction({url: 'https://evil.example.com'}, ctx)
550
- } catch (error) {
551
- if (!(error instanceof MockProcessExit)) throw error
552
- }
553
-
554
- if (fetchCalled) {
555
- // If a fetch was made, the ambient key must not appear in the Authorization header
556
- if (capturedAuthHeader !== null && capturedAuthHeader !== undefined) {
557
- expect(capturedAuthHeader).not.toContain('ambient-secret-key')
558
- }
559
- } else {
560
- // No fetch made — key was withheld entirely (correct behavior)
561
- // Verify the error output doesn't contain the ambient key either
562
- const allOutput = [...captured.stdout, ...captured.stderr].join('\n')
563
- expect(allOutput).not.toContain('ambient-secret-key')
564
- }
565
- })
566
- })
567
-
568
- describe('registerCliproxyModels — help output and command discovery', () => {
569
- it('shows [provider] positional, --url, --key, and --verbose in help text', () => {
570
- const cli = goke('infra')
571
- registerCliproxyModels(cli)
572
- cli.help()
573
-
574
- const helpText = cli.helpText()
575
-
576
- expect(helpText).toContain('cliproxy models')
577
- expect(helpText).toContain('[provider]')
578
- expect(helpText).toContain('--url [url]')
579
- expect(helpText).toContain('--key [key]')
580
- expect(helpText).toContain('--verbose')
581
- })
582
-
583
- it('command is discoverable in the registered cliproxy group', () => {
584
- const cli = goke('infra')
585
- registerCliproxyModels(cli)
586
- cli.help()
587
-
588
- const helpText = cli.helpText()
589
-
590
- expect(helpText).toContain('List the models CLIProxyAPI serves.')
591
- })
592
- })