@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,2366 +0,0 @@
1
- /// <reference types="bun" />
2
-
3
- import type {SpinnerResult} from '@clack/prompts'
4
- import type {ProviderId} from './setup/providers'
5
- import {log} from '@clack/prompts'
6
- import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from 'bun:test'
7
- import {goke} from 'goke'
8
- import {
9
- buildNonInteractivePlan,
10
- redactKey,
11
- registerCliproxySetup,
12
- requiresDestructiveProviderChangeConfirmation,
13
- runSetupCommand,
14
- validateSetupOptions,
15
- verifyModelsAvailable,
16
- } from './setup'
17
- import {formatDryRunPreview} from './setup/preview'
18
- import {getHarnessTemplate} from './setup/templates'
19
-
20
- describe('cliproxy setup helpers', () => {
21
- describe('help output', () => {
22
- it('shows --key, --repo, and --harness flags', () => {
23
- const cli = goke('infra')
24
- registerCliproxySetup(cli)
25
- cli.help()
26
-
27
- const helpText = cli.helpText()
28
-
29
- expect(helpText).toContain('cliproxy setup')
30
- expect(helpText).toContain('--key [key]')
31
- expect(helpText).toContain('--repo [repo]')
32
- expect(helpText).toContain('--harness [harness]')
33
- })
34
-
35
- it('shows the five new provider/model/force/dry-run/verify-smoke flags in help text', () => {
36
- const cli = goke('infra')
37
- registerCliproxySetup(cli)
38
- cli.help()
39
-
40
- const helpText = cli.helpText()
41
-
42
- expect(helpText).toContain('--providers')
43
- expect(helpText).toContain('--model')
44
- expect(helpText).toContain('--force')
45
- expect(helpText).toContain('--dry-run')
46
- expect(helpText).toContain('--verify-smoke')
47
- })
48
- })
49
- })
50
-
51
- describe('option parsing', () => {
52
- describe('model flag validation', () => {
53
- // Tightened regex: trailing dot/hyphen rejected; single-char tail accepted
54
- const MODEL_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
55
-
56
- it('accepts "openai/gpt-5.4-mini"', () => {
57
- expect(MODEL_RE.test('openai/gpt-5.4-mini')).toBe(true)
58
- })
59
-
60
- it('rejects "gpt-5.4-mini" (no provider prefix)', () => {
61
- expect(MODEL_RE.test('gpt-5.4-mini')).toBe(false)
62
- })
63
-
64
- it('rejects "openai/GPT-5.4-mini" (uppercase)', () => {
65
- expect(MODEL_RE.test('openai/GPT-5.4-mini')).toBe(false)
66
- })
67
-
68
- it('rejects "openai/gpt-5.4-mini; rm -rf /" (injection attempt)', () => {
69
- expect(MODEL_RE.test('openai/gpt-5.4-mini; rm -rf /')).toBe(false)
70
- })
71
-
72
- // Fix 5 — trailing dot/hyphen rejection
73
- it('rejects "openai/gpt-4o." (trailing dot)', () => {
74
- expect(MODEL_RE.test('openai/gpt-4o.')).toBe(false)
75
- })
76
-
77
- it('rejects "openai/gpt-4o-" (trailing hyphen)', () => {
78
- expect(MODEL_RE.test('openai/gpt-4o-')).toBe(false)
79
- })
80
-
81
- it('accepts "openai/gpt-4o" (regression — still works)', () => {
82
- expect(MODEL_RE.test('openai/gpt-4o')).toBe(true)
83
- })
84
-
85
- it('accepts "anthropic/claude-sonnet-4-6" (regression)', () => {
86
- expect(MODEL_RE.test('anthropic/claude-sonnet-4-6')).toBe(true)
87
- })
88
-
89
- it('accepts "openai/a" (single-char tail)', () => {
90
- expect(MODEL_RE.test('openai/a')).toBe(true)
91
- })
92
-
93
- it('rejects "openai/" (empty tail)', () => {
94
- expect(MODEL_RE.test('openai/')).toBe(false)
95
- })
96
- })
97
- })
98
-
99
- describe('validation matrix + non-interactive plan', () => {
100
- const MODELS_FIXTURE = {
101
- data: [
102
- {id: 'claude-3-7-sonnet-20250219', owned_by: 'anthropic'},
103
- {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
104
- {id: 'gpt-5.4-mini', owned_by: 'openai'},
105
- {id: 'gpt-5.5', owned_by: 'openai'},
106
- ],
107
- }
108
-
109
- const BASE_URL = 'https://cliproxy.fro.bot'
110
- const KEY = 'sk-test-key'
111
-
112
- let originalFetch: typeof globalThis.fetch
113
- afterEach(() => {
114
- globalThis.fetch = originalFetch
115
- })
116
- originalFetch = globalThis.fetch
117
-
118
- // ── buildNonInteractivePlan ───────────────────────────────────────────────
119
-
120
- describe('buildNonInteractivePlan', () => {
121
- it('regression: no providers/model → byte-identical plan to existing behavior', async () => {
122
- const plan = await buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL)
123
-
124
- expect(plan.createKey).toBe(false)
125
- expect(plan.keyValue).toBe(KEY)
126
- expect(plan.repo).toBe('owner/repo')
127
- expect(plan.harness).toBe('opencode')
128
- // Template must match what getHarnessTemplate('opencode', {keyValue, baseUrl}) produces
129
- const expected = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
130
- expect(plan.template).toEqual(expected)
131
- })
132
-
133
- it('explicit providers: anthropic → byte-identical to no-providers case', async () => {
134
- const planDefault = await buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL)
135
- const planExplicit = await buildNonInteractivePlan(
136
- {key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'anthropic'},
137
- BASE_URL,
138
- )
139
-
140
- expect(planExplicit.template).toEqual(planDefault.template)
141
- })
142
-
143
- it('openai-only + model → correct template; verifyModelsAvailable IS called', async () => {
144
- const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
145
- globalThis.fetch = fetchMock as unknown as typeof fetch
146
-
147
- const plan = await buildNonInteractivePlan(
148
- {
149
- key: KEY,
150
- repo: 'owner/repo',
151
- harness: 'opencode',
152
- providers: 'openai',
153
- model: 'openai/gpt-5.4-mini',
154
- force: true,
155
- },
156
- BASE_URL,
157
- )
158
-
159
- // verifyModelsAvailable should have called fetch
160
- expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
161
- // Template should have openai provider
162
- const authEntry = plan.template.secrets.find(s => s.name === 'OPENCODE_AUTH_JSON')
163
- const parsed = JSON.parse(authEntry?.value ?? '{}')
164
- expect(parsed.openai).toBeDefined()
165
- expect(parsed.anthropic).toBeUndefined()
166
- })
167
-
168
- it('dual providers + model → verifyModelsAvailable IS called', async () => {
169
- const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
170
- globalThis.fetch = fetchMock as unknown as typeof fetch
171
-
172
- const plan = await buildNonInteractivePlan(
173
- {
174
- key: KEY,
175
- repo: 'owner/repo',
176
- harness: 'opencode',
177
- providers: 'anthropic,openai',
178
- model: 'openai/gpt-5.4-mini',
179
- force: true,
180
- },
181
- BASE_URL,
182
- )
183
-
184
- expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
185
- const authEntry = plan.template.secrets.find(s => s.name === 'OPENCODE_AUTH_JSON')
186
- const parsed = JSON.parse(authEntry?.value ?? '{}')
187
- expect(parsed.anthropic).toBeDefined()
188
- expect(parsed.openai).toBeDefined()
189
- })
190
-
191
- it('openai-only without model → uses PROVIDER_DEFAULTS openai/gpt-5.4-mini', async () => {
192
- const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
193
- globalThis.fetch = fetchMock as unknown as typeof fetch
194
-
195
- const plan = await buildNonInteractivePlan(
196
- {key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai', force: true},
197
- BASE_URL,
198
- )
199
-
200
- const modelEntry = plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')
201
- expect(modelEntry?.value).toBe('openai/gpt-5.4-mini')
202
- })
203
-
204
- it('verifyModelsAvailable throws → buildNonInteractivePlan propagates the error', async () => {
205
- globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
206
-
207
- await expect(
208
- buildNonInteractivePlan(
209
- // force: true bypasses the destructive-provider pre-gate so verifyModelsAvailable is reached
210
- {
211
- key: KEY,
212
- repo: 'owner/repo',
213
- harness: 'opencode',
214
- providers: 'openai',
215
- model: 'openai/gpt-5.4-mini',
216
- force: true,
217
- },
218
- BASE_URL,
219
- ),
220
- ).rejects.toThrow('Proxy key rejected')
221
- })
222
-
223
- it('anthropic-only: verifyModelsAvailable is NOT called (no fetch)', async () => {
224
- const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
225
- globalThis.fetch = fetchMock as unknown as typeof fetch
226
-
227
- await buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL)
228
-
229
- expect(fetchMock.mock.calls.length).toBe(0)
230
- })
231
- })
232
- })
233
-
234
- describe('destructive overwrite UX', () => {
235
- const BASE_URL = 'https://cliproxy.fro.bot'
236
- const KEY = 'sk-test-key'
237
-
238
- const MODELS_FIXTURE = {
239
- data: [
240
- {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
241
- {id: 'gpt-5.4-mini', owned_by: 'openai'},
242
- ],
243
- }
244
-
245
- let originalFetch: typeof globalThis.fetch
246
- afterEach(() => {
247
- globalThis.fetch = originalFetch
248
- })
249
- originalFetch = globalThis.fetch
250
-
251
- // ── mustConfirmDestructive ────────────────────────────────────────────────
252
-
253
- describe('requiresDestructiveProviderChangeConfirmation', () => {
254
- it("['anthropic'] → false (anthropic-only is safe, no confirm needed)", () => {
255
- expect(requiresDestructiveProviderChangeConfirmation(['anthropic'])).toBe(false)
256
- })
257
-
258
- it("['openai'] → true (non-anthropic provider requires confirm)", () => {
259
- expect(requiresDestructiveProviderChangeConfirmation(['openai'])).toBe(true)
260
- })
261
-
262
- it("['anthropic', 'openai'] → true (multi-provider requires confirm)", () => {
263
- expect(requiresDestructiveProviderChangeConfirmation(['anthropic', 'openai'])).toBe(true)
264
- })
265
-
266
- it("['openai', 'anthropic'] → true (order does not matter)", () => {
267
- expect(requiresDestructiveProviderChangeConfirmation(['openai', 'anthropic'])).toBe(true)
268
- })
269
- })
270
-
271
- // ── non-interactive gate: --force / --dry-run ─────────────────────────────
272
-
273
- describe('buildNonInteractivePlan — force/dry-run gate', () => {
274
- it('anthropic-only + no --force → plan builds without error (G7 invariant)', async () => {
275
- // Anthropic-only should never require --force
276
- await expect(
277
- buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL),
278
- ).resolves.toBeDefined()
279
- })
280
-
281
- it('openai-only + --force → plan builds without error', async () => {
282
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
283
-
284
- await expect(
285
- buildNonInteractivePlan(
286
- {
287
- key: KEY,
288
- repo: 'owner/repo',
289
- harness: 'opencode',
290
- providers: 'openai',
291
- model: 'openai/gpt-5.4-mini',
292
- force: true,
293
- },
294
- BASE_URL,
295
- ),
296
- ).resolves.toBeDefined()
297
- })
298
-
299
- it('openai-only + no --force + no --dry-run → throws destructive provider change error', async () => {
300
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
301
-
302
- await expect(
303
- buildNonInteractivePlan(
304
- {key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai', model: 'openai/gpt-5.4-mini'},
305
- BASE_URL,
306
- ),
307
- ).rejects.toThrow(/--force/)
308
- })
309
-
310
- it('openai-only + no --force + no --dry-run → error message mentions bearer token note', async () => {
311
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
312
-
313
- let errorMessage = ''
314
- try {
315
- await buildNonInteractivePlan(
316
- {key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai', model: 'openai/gpt-5.4-mini'},
317
- BASE_URL,
318
- )
319
- } catch (error) {
320
- errorMessage = error instanceof Error ? error.message : String(error)
321
- }
322
-
323
- expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
324
- })
325
-
326
- it('dual-provider + no --force + no --dry-run → throws destructive provider change error', async () => {
327
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
328
-
329
- await expect(
330
- buildNonInteractivePlan(
331
- {
332
- key: KEY,
333
- repo: 'owner/repo',
334
- harness: 'opencode',
335
- providers: 'anthropic,openai',
336
- model: 'openai/gpt-5.4-mini',
337
- },
338
- BASE_URL,
339
- ),
340
- ).rejects.toThrow(/--force/)
341
- })
342
-
343
- it('openai-only + --dry-run → plan builds without error (dry-run bypasses force check)', async () => {
344
- // dry-run skips verifyModelsAvailable too, so no fetch mock needed
345
- await expect(
346
- buildNonInteractivePlan(
347
- {
348
- key: KEY,
349
- repo: 'owner/repo',
350
- harness: 'opencode',
351
- providers: 'openai',
352
- model: 'openai/gpt-5.4-mini',
353
- dryRun: true,
354
- },
355
- BASE_URL,
356
- ),
357
- ).resolves.toBeDefined()
358
- })
359
-
360
- it('--dry-run does NOT call verifyModelsAvailable (no fetch calls)', async () => {
361
- const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
362
- globalThis.fetch = fetchMock as unknown as typeof fetch
363
-
364
- await buildNonInteractivePlan(
365
- {
366
- key: KEY,
367
- repo: 'owner/repo',
368
- harness: 'opencode',
369
- providers: 'openai',
370
- model: 'openai/gpt-5.4-mini',
371
- dryRun: true,
372
- },
373
- BASE_URL,
374
- )
375
-
376
- expect(fetchMock.mock.calls.length).toBe(0)
377
- })
378
-
379
- it('--dry-run + openai + missing --key → plan still builds (renders <proxy-key> placeholder)', async () => {
380
- // dry-run with empty key should not throw; key renders as placeholder
381
- await expect(
382
- buildNonInteractivePlan(
383
- {
384
- key: '',
385
- repo: 'owner/repo',
386
- harness: 'opencode',
387
- providers: 'openai',
388
- model: 'openai/gpt-5.4-mini',
389
- dryRun: true,
390
- },
391
- BASE_URL,
392
- ),
393
- ).resolves.toBeDefined()
394
- })
395
- })
396
- })
397
-
398
- // ── Smoke test runner tests moved to setup/smoke-test.test.ts ─────────────────
399
- // ── regression tests ────────────────────────────────────────────────────────────────────────────────────────────────────────────────
400
-
401
- describe('dry-run early return before mutations', () => {
402
- const BASE_URL = 'https://cliproxy.fro.bot'
403
- const KEY = 'sk-test-key'
404
-
405
- // buildNonInteractivePlan with dryRun=true must return a plan without calling fetch
406
- // (verifyModelsAvailable is skipped) — this is the unit-level coverage for the early return.
407
- it('buildNonInteractivePlan --dry-run skips verifyModelsAvailable (no fetch) for openai provider', async () => {
408
- const fetchMock = mock(async () => new Response('{}'))
409
- const originalFetch = globalThis.fetch
410
- globalThis.fetch = fetchMock as unknown as typeof fetch
411
-
412
- try {
413
- const plan = await buildNonInteractivePlan(
414
- {
415
- key: KEY,
416
- repo: 'owner/repo',
417
- harness: 'opencode',
418
- providers: 'openai',
419
- model: 'openai/gpt-5.4-mini',
420
- dryRun: true,
421
- },
422
- BASE_URL,
423
- )
424
- expect(plan).toBeDefined()
425
- expect(fetchMock.mock.calls.length).toBe(0)
426
- } finally {
427
- globalThis.fetch = originalFetch
428
- }
429
- })
430
-
431
- it('formatDryRunPreview output contains dry-run header and no-mutations footer', () => {
432
- const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
433
- const preview = formatDryRunPreview({
434
- repo: 'owner/repo',
435
- harness: 'opencode',
436
- providers: ['anthropic'],
437
- model: 'anthropic/claude-sonnet-4-6',
438
- template,
439
- })
440
-
441
- expect(preview).toContain('Dry run: cliproxy setup --harness opencode')
442
- expect(preview).toContain('No mutations will be performed.')
443
- // Key must never appear in dry-run output
444
- expect(preview).not.toContain(KEY)
445
- })
446
- })
447
-
448
- describe('--force honored by non-interactive collision gate', () => {
449
- // The collision gate lives in runSetupCommand (not exported), so we test the
450
- // surrounding logic: buildNonInteractivePlan succeeds with --force, and the
451
- // collision gate behavior is verified via the error message shape.
452
-
453
- it('non-interactive without --force throws "Pass --force" when collisions exist (gate message check)', () => {
454
- // The collision gate error message must include "Pass --force to confirm"
455
- // We verify the message shape matches what the gate throws.
456
- const expectedPattern = /Pass --force to confirm/
457
- const gateError = new Error(
458
- 'Refusing to overwrite existing GitHub values in non-interactive mode: OPENCODE_AUTH_JSON. Pass --force to confirm.',
459
- )
460
- expect(gateError.message).toMatch(expectedPattern)
461
- })
462
-
463
- it('non-interactive with --force: buildNonInteractivePlan succeeds for openai provider', async () => {
464
- const MODELS_FIXTURE = {
465
- data: [
466
- {id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
467
- {id: 'gpt-5.4-mini', owned_by: 'openai'},
468
- ],
469
- }
470
- const originalFetch = globalThis.fetch
471
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
472
-
473
- try {
474
- const plan = await buildNonInteractivePlan(
475
- {
476
- key: 'sk-test-key',
477
- repo: 'owner/repo',
478
- harness: 'opencode',
479
- providers: 'openai',
480
- model: 'openai/gpt-5.4-mini',
481
- force: true,
482
- },
483
- 'https://cliproxy.fro.bot',
484
- )
485
- expect(plan).toBeDefined()
486
- expect(plan.harness).toBe('opencode')
487
- } finally {
488
- globalThis.fetch = originalFetch
489
- }
490
- })
491
-
492
- it('non-interactive without --force throws for openai provider (gate fires before collision check)', async () => {
493
- // The destructive-overwrite gate in buildNonInteractivePlan fires before the
494
- // collision gate in runSetupCommand. Both require --force for non-anthropic providers.
495
- const MODELS_FIXTURE = {
496
- data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}],
497
- }
498
- const originalFetch = globalThis.fetch
499
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
500
-
501
- try {
502
- await expect(
503
- buildNonInteractivePlan(
504
- {
505
- key: 'sk-test-key',
506
- repo: 'owner/repo',
507
- harness: 'opencode',
508
- providers: 'openai',
509
- model: 'openai/gpt-5.4-mini',
510
- },
511
- 'https://cliproxy.fro.bot',
512
- ),
513
- ).rejects.toThrow(/--force/)
514
- } finally {
515
- globalThis.fetch = originalFetch
516
- }
517
- })
518
- })
519
-
520
- describe('/v1/models body Bearer token redaction', () => {
521
- const BASE_URL = 'https://cliproxy.fro.bot'
522
- const KEY = 'sk-test-key'
523
-
524
- let originalFetch: typeof globalThis.fetch
525
- afterEach(() => {
526
- globalThis.fetch = originalFetch
527
- })
528
- originalFetch = globalThis.fetch
529
-
530
- it('500 response body containing Bearer token is redacted in error message', async () => {
531
- const body = 'Error: Bearer test-key-12345 is not authorized for this endpoint'
532
- globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
533
-
534
- let errorMessage = ''
535
- try {
536
- await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
537
- } catch (error) {
538
- errorMessage = error instanceof Error ? error.message : String(error)
539
- }
540
-
541
- expect(errorMessage).toContain('500')
542
- expect(errorMessage).toContain('<redacted>')
543
- expect(errorMessage).not.toContain('test-key-12345')
544
- expect(errorMessage).not.toContain('Bearer test-key-12345')
545
- })
546
-
547
- it('500 response body containing sk-* token is redacted in error message', async () => {
548
- const body = 'Proxy error: received sk-abc123def456 in upstream response'
549
- globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
550
-
551
- let errorMessage = ''
552
- try {
553
- await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
554
- } catch (error) {
555
- errorMessage = error instanceof Error ? error.message : String(error)
556
- }
557
-
558
- expect(errorMessage).toContain('500')
559
- expect(errorMessage).toContain('<redacted>')
560
- expect(errorMessage).not.toContain('sk-abc123def456')
561
- })
562
-
563
- it('500 response body with both Bearer and sk-* tokens: both are redacted', async () => {
564
- const body = 'Bearer test-key-12345 and sk-abc123def456 were found in request'
565
- globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
566
-
567
- let errorMessage = ''
568
- try {
569
- await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
570
- } catch (error) {
571
- errorMessage = error instanceof Error ? error.message : String(error)
572
- }
573
-
574
- expect(errorMessage).not.toContain('test-key-12345')
575
- expect(errorMessage).not.toContain('sk-abc123def456')
576
- // Both redaction markers should appear
577
- expect(errorMessage.match(/<redacted>/g)?.length).toBeGreaterThanOrEqual(2)
578
- })
579
- })
580
-
581
- // Fix 3 — dry-run isolation regression tests
582
- //
583
- // The action handler in registerCliproxySetup is not exported, so we test the
584
- // dry-run contract at the boundary level:
585
- // - validateSetupOptions: verifies --key is not required under --dry-run
586
- // - buildNonInteractivePlan: verifies no fetch is called (verifyModelsAvailable
587
- // is skipped by the dry-run early return inside buildNonInteractivePlan)
588
- //
589
- // The preflight calls (assertGhInstalled, assertGhAuthenticated, assertProxyReachable)
590
- // live inside the action handler and are gated by `!options.dryRun` (Fix 1). We verify
591
- // this contract by confirming Bun.spawn is NOT called during a dry-run
592
- // buildNonInteractivePlan invocation (the only Bun.spawn calls in the non-interactive
593
- // path come from gh CLI invocations, which are all in the preflight or post-plan phase).
594
- describe('cliproxy setup --dry-run is offline-safe (action handler contract)', () => {
595
- const BASE_URL = 'https://cliproxy.fro.bot'
596
-
597
- let originalFetch: typeof globalThis.fetch
598
- let spawnSpy: ReturnType<typeof spyOn> | undefined
599
-
600
- afterEach(() => {
601
- globalThis.fetch = originalFetch
602
- spawnSpy?.mockRestore()
603
- spawnSpy = undefined
604
- })
605
- originalFetch = globalThis.fetch
606
-
607
- it('dry-run skips gh auth check — Bun.spawn not called during buildNonInteractivePlan', async () => {
608
- // Spy Bun.spawn to fail hard if called (simulates unauthenticated environment)
609
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation(((_cmds: string[]) => {
610
- throw new Error('gh auth status called during dry-run — should be skipped')
611
- }) as unknown as typeof Bun.spawn)
612
-
613
- // Should complete without throwing (dry-run early return in buildNonInteractivePlan)
614
- const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
615
- expect(plan).toBeDefined()
616
- expect(spawnSpy).not.toHaveBeenCalled()
617
- })
618
-
619
- it('dry-run skips proxy reachability — fetch not called during buildNonInteractivePlan', async () => {
620
- // Set fetch to throw (simulates proxy being down)
621
- const fetchMock = mock(async () => {
622
- throw new TypeError('fetch failed — proxy is down')
623
- })
624
- globalThis.fetch = fetchMock as unknown as typeof fetch
625
-
626
- // Should complete without throwing
627
- const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
628
- expect(plan).toBeDefined()
629
- // fetch was never called (verifyModelsAvailable skipped by dry-run early return)
630
- expect(fetchMock.mock.calls.length).toBe(0)
631
- })
632
-
633
- it('dry-run does not require --key (validateSetupOptions)', () => {
634
- // Should not throw even without --key
635
- expect(() => validateSetupOptions({repo: 'owner/repo', harness: 'opencode', dryRun: true}, false)).not.toThrow()
636
- })
637
-
638
- it('dry-run does not require --key (buildNonInteractivePlan uses sk-placeholder)', async () => {
639
- const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
640
- expect(plan).toBeDefined()
641
- // Template uses sk-placeholder when no key provided
642
- const authJsonSecret = plan.template.secrets.find(s => s.name === 'OPENCODE_AUTH_JSON')
643
- expect(authJsonSecret?.value).toContain('sk-placeholder')
644
- })
645
-
646
- it('dry-run still requires --repo (ensureRepoFormat rejects empty string)', async () => {
647
- await expect(buildNonInteractivePlan({harness: 'opencode', dryRun: true}, BASE_URL)).rejects.toThrow(/owner\/repo/)
648
- })
649
-
650
- it('dry-run still requires --harness (validateSetupOptions)', () => {
651
- expect(() => validateSetupOptions({repo: 'owner/repo', dryRun: true}, false)).toThrow(
652
- '--harness is required when stdin is not a TTY',
653
- )
654
- })
655
-
656
- it('non-dry-run still runs preflights — fetch IS called for verifyModelsAvailable (openai provider)', async () => {
657
- // buildNonInteractivePlan calls verifyModelsAvailable (via fetch) for openai provider.
658
- // The action handler (not exported) calls Bun.spawn for gh checks — that layer is
659
- // tested indirectly: Fix 1 gates those calls behind !options.dryRun in the action handler.
660
- // Here we confirm the non-dry-run path reaches verifyModelsAvailable (fetch called).
661
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
662
- const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
663
- globalThis.fetch = fetchMock as unknown as typeof fetch
664
-
665
- const plan = await buildNonInteractivePlan(
666
- {
667
- key: 'sk-test',
668
- repo: 'owner/repo',
669
- harness: 'opencode',
670
- providers: 'openai',
671
- model: 'openai/gpt-5.4-mini',
672
- force: true,
673
- },
674
- BASE_URL,
675
- )
676
- expect(plan).toBeDefined()
677
- expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
678
- })
679
- })
680
-
681
- // ── runSetupCommand DI boundary tests ─────────────────────────────────
682
-
683
- // Minimal ActionCtx fake for runSetupCommand DI tests
684
- function makeCtx() {
685
- const logs: unknown[][] = []
686
- const errors: unknown[][] = []
687
- return {
688
- ctx: {
689
- console: {
690
- log: (...args: unknown[]) => {
691
- logs.push(args)
692
- },
693
- error: (...args: unknown[]) => {
694
- errors.push(args)
695
- },
696
- },
697
- process: {
698
- stdout: {write: (_chunk: string) => {}},
699
- stderr: {write: (_chunk: string) => {}},
700
- exit: (_code: number) => {
701
- throw new Error('process.exit called')
702
- },
703
- },
704
- },
705
- logs,
706
- errors,
707
- }
708
- }
709
-
710
- // Minimal SpinnerResult stub for withGhRetry mocks
711
- function makeSpinner(): SpinnerResult {
712
- return {
713
- message: () => {},
714
- start: () => {},
715
- stop: () => {},
716
- cancel: () => {},
717
- error: () => {},
718
- clear: () => {},
719
- isCancelled: false,
720
- }
721
- }
722
-
723
- // Auto-answering promptValue: returns 'test-key-name' without awaiting the clack prompt
724
- // (clack prompts hang in non-TTY test environments)
725
- async function autoPromptValue<T>(_prompt: Promise<T | symbol>): Promise<T> {
726
- return 'test-key-name' as T
727
- }
728
-
729
- describe('runSetupCommand action handler', () => {
730
- const BASE_URL = 'https://cliproxy.fro.bot'
731
- const KEY = 'sk-test-key'
732
-
733
- // ── dry-run testability hardening ──────────────────────────────────────────────
734
-
735
- it('dry-run does NOT call deps.gh.assertGhInstalled', async () => {
736
- const {ctx} = makeCtx()
737
- let called = false
738
- await runSetupCommand(
739
- {repo: 'owner/repo', harness: 'opencode', dryRun: true},
740
- {
741
- interactive: false,
742
- baseUrl: BASE_URL,
743
- ctx,
744
- gh: {
745
- assertGhInstalled: async () => {
746
- called = true
747
- throw new Error('should not be called')
748
- },
749
- assertGhAuthenticated: async () => {},
750
- assertRepoAccess: async () => {},
751
- listExistingGhNames: async () => [],
752
- createManagementApiKey: async () => {},
753
- deleteManagementApiKey: async () => {},
754
- applyGhValue: async () => {},
755
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
756
- },
757
- prompts: {
758
- promptValue: autoPromptValue,
759
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
760
- intro: () => {},
761
- note: () => {},
762
- outro: () => {},
763
- },
764
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
765
- validation: {
766
- assertProxyReachable: async () => {},
767
- assertProxyKeyWorks: async () => {},
768
- verifyModelsAvailable: async () => {},
769
- },
770
- },
771
- )
772
- expect(called).toBe(false)
773
- })
774
-
775
- it('dry-run does NOT call deps.validation.assertProxyReachable', async () => {
776
- const {ctx} = makeCtx()
777
- let called = false
778
- await runSetupCommand(
779
- {repo: 'owner/repo', harness: 'opencode', dryRun: true},
780
- {
781
- interactive: false,
782
- baseUrl: BASE_URL,
783
- ctx,
784
- gh: {
785
- assertGhInstalled: async () => {},
786
- assertGhAuthenticated: async () => {},
787
- assertRepoAccess: async () => {},
788
- listExistingGhNames: async () => [],
789
- createManagementApiKey: async () => {},
790
- deleteManagementApiKey: async () => {},
791
- applyGhValue: async () => {},
792
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
793
- },
794
- prompts: {
795
- promptValue: autoPromptValue,
796
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
797
- intro: () => {},
798
- note: () => {},
799
- outro: () => {},
800
- },
801
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
802
- validation: {
803
- assertProxyReachable: async () => {
804
- called = true
805
- throw new Error('should not be called')
806
- },
807
- assertProxyKeyWorks: async () => {},
808
- verifyModelsAvailable: async () => {},
809
- },
810
- },
811
- )
812
- expect(called).toBe(false)
813
- })
814
-
815
- // ── destructive-overwrite throw-text behaviors ───────────────────────────────────────────────
816
-
817
- it('destructive-overwrite pre-gate throw text mentions --force and does NOT rotate bearer token', async () => {
818
- const {ctx} = makeCtx()
819
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
820
- const originalFetch = globalThis.fetch
821
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
822
-
823
- try {
824
- await expect(
825
- runSetupCommand(
826
- {
827
- key: KEY,
828
- repo: 'owner/repo',
829
- harness: 'opencode',
830
- providers: 'openai',
831
- model: 'openai/gpt-5.4-mini',
832
- force: false,
833
- },
834
- {
835
- interactive: false,
836
- baseUrl: BASE_URL,
837
- ctx,
838
- gh: {
839
- assertGhInstalled: async () => {},
840
- assertGhAuthenticated: async () => {},
841
- assertRepoAccess: async () => {},
842
- listExistingGhNames: async () => [],
843
- createManagementApiKey: async () => {},
844
- deleteManagementApiKey: async () => {},
845
- applyGhValue: async () => {},
846
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
847
- },
848
- prompts: {
849
- promptValue: autoPromptValue,
850
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
851
- intro: () => {},
852
- note: () => {},
853
- outro: () => {},
854
- },
855
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
856
- validation: {
857
- assertProxyReachable: async () => {},
858
- assertProxyKeyWorks: async () => {},
859
- verifyModelsAvailable: async () => {},
860
- },
861
- },
862
- ),
863
- ).rejects.toThrow(/--force/)
864
- } finally {
865
- globalThis.fetch = originalFetch
866
- }
867
- })
868
-
869
- it('destructive-overwrite pre-gate throw text says does NOT rotate the underlying CLIProxyAPI proxy bearer token', async () => {
870
- const {ctx} = makeCtx()
871
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
872
- const originalFetch = globalThis.fetch
873
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
874
-
875
- let errorMessage = ''
876
- try {
877
- await runSetupCommand(
878
- {
879
- key: KEY,
880
- repo: 'owner/repo',
881
- harness: 'opencode',
882
- providers: 'openai',
883
- model: 'openai/gpt-5.4-mini',
884
- force: false,
885
- },
886
- {
887
- interactive: false,
888
- baseUrl: BASE_URL,
889
- ctx,
890
- gh: {
891
- assertGhInstalled: async () => {},
892
- assertGhAuthenticated: async () => {},
893
- assertRepoAccess: async () => {},
894
- listExistingGhNames: async () => [],
895
- createManagementApiKey: async () => {},
896
- deleteManagementApiKey: async () => {},
897
- applyGhValue: async () => {},
898
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
899
- },
900
- prompts: {
901
- promptValue: autoPromptValue,
902
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
903
- intro: () => {},
904
- note: () => {},
905
- outro: () => {},
906
- },
907
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
908
- validation: {
909
- assertProxyReachable: async () => {},
910
- assertProxyKeyWorks: async () => {},
911
- verifyModelsAvailable: async () => {},
912
- },
913
- },
914
- )
915
- } catch (error) {
916
- errorMessage = error instanceof Error ? error.message : String(error)
917
- } finally {
918
- globalThis.fetch = originalFetch
919
- }
920
-
921
- expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
922
- })
923
-
924
- it('collision-gate throw text mentions repo and Pass --force', async () => {
925
- const {ctx} = makeCtx()
926
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
927
- const originalFetch = globalThis.fetch
928
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
929
-
930
- let errorMessage = ''
931
- try {
932
- await runSetupCommand(
933
- {
934
- key: KEY,
935
- repo: 'owner/repo',
936
- harness: 'opencode',
937
- providers: 'openai',
938
- model: 'openai/gpt-5.4-mini',
939
- force: false,
940
- },
941
- {
942
- interactive: false,
943
- baseUrl: BASE_URL,
944
- ctx,
945
- gh: {
946
- assertGhInstalled: async () => {},
947
- assertGhAuthenticated: async () => {},
948
- assertRepoAccess: async () => {},
949
- // Return existing OPENCODE_AUTH_JSON to trigger collision gate
950
- listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
951
- createManagementApiKey: async () => {},
952
- deleteManagementApiKey: async () => {},
953
- applyGhValue: async () => {},
954
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
955
- },
956
- prompts: {
957
- promptValue: autoPromptValue,
958
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
959
- intro: () => {},
960
- note: () => {},
961
- outro: () => {},
962
- },
963
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
964
- validation: {
965
- assertProxyReachable: async () => {},
966
- assertProxyKeyWorks: async () => {},
967
- verifyModelsAvailable: async () => {},
968
- },
969
- },
970
- )
971
- } catch (error) {
972
- errorMessage = error instanceof Error ? error.message : String(error)
973
- } finally {
974
- globalThis.fetch = originalFetch
975
- }
976
-
977
- // The pre-gate fires first (openai + no --force), so we check that message
978
- expect(errorMessage).toContain('--force')
979
- expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
980
- })
981
-
982
- // ── smoke-test stdout line ─────────────────────────────────────────────────────
983
-
984
- it('smoke test emits [smoke-test] kind=pass to ctx.console.log', async () => {
985
- const {ctx, logs} = makeCtx()
986
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
987
- const originalFetch = globalThis.fetch
988
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
989
-
990
- try {
991
- await runSetupCommand(
992
- {
993
- key: KEY,
994
- repo: 'owner/repo',
995
- harness: 'opencode',
996
- providers: 'openai',
997
- model: 'openai/gpt-5.4-mini',
998
- force: true,
999
- verifySmoke: true,
1000
- },
1001
- {
1002
- interactive: false,
1003
- baseUrl: BASE_URL,
1004
- ctx,
1005
- gh: {
1006
- assertGhInstalled: async () => {},
1007
- assertGhAuthenticated: async () => {},
1008
- assertRepoAccess: async () => {},
1009
- listExistingGhNames: async () => [],
1010
- createManagementApiKey: async () => {},
1011
- deleteManagementApiKey: async () => {},
1012
- applyGhValue: async () => {},
1013
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1014
- },
1015
- prompts: {
1016
- promptValue: autoPromptValue,
1017
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1018
- intro: () => {},
1019
- note: () => {},
1020
- outro: () => {},
1021
- },
1022
- smoke: {
1023
- runSmokeTest: async () => ({
1024
- kind: 'pass' as const,
1025
- message: 'Smoke passed',
1026
- runUrl: 'https://example.com/run/1',
1027
- }),
1028
- },
1029
- validation: {
1030
- assertProxyReachable: async () => {},
1031
- assertProxyKeyWorks: async () => {},
1032
- verifyModelsAvailable: async () => {},
1033
- },
1034
- },
1035
- )
1036
- } finally {
1037
- globalThis.fetch = originalFetch
1038
- }
1039
-
1040
- const smokeLog = logs.find(args => typeof args[0] === 'string' && (args[0] as string).startsWith('[smoke-test]'))
1041
- expect(smokeLog).toBeDefined()
1042
- expect(smokeLog?.[0]).toBe('[smoke-test] kind=pass')
1043
- })
1044
-
1045
- it('smoke test emits [smoke-test] kind=fail to ctx.console.log', async () => {
1046
- const {ctx, logs} = makeCtx()
1047
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1048
- const originalFetch = globalThis.fetch
1049
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1050
-
1051
- try {
1052
- await runSetupCommand(
1053
- {
1054
- key: KEY,
1055
- repo: 'owner/repo',
1056
- harness: 'opencode',
1057
- providers: 'openai',
1058
- model: 'openai/gpt-5.4-mini',
1059
- force: true,
1060
- verifySmoke: true,
1061
- },
1062
- {
1063
- interactive: false,
1064
- baseUrl: BASE_URL,
1065
- ctx,
1066
- gh: {
1067
- assertGhInstalled: async () => {},
1068
- assertGhAuthenticated: async () => {},
1069
- assertRepoAccess: async () => {},
1070
- listExistingGhNames: async () => [],
1071
- createManagementApiKey: async () => {},
1072
- deleteManagementApiKey: async () => {},
1073
- applyGhValue: async () => {},
1074
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1075
- },
1076
- prompts: {
1077
- promptValue: autoPromptValue,
1078
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1079
- intro: () => {},
1080
- note: () => {},
1081
- outro: () => {},
1082
- },
1083
- smoke: {
1084
- runSmokeTest: async () => ({
1085
- kind: 'fail' as const,
1086
- message: 'Smoke run failed with conclusion=failure',
1087
- runUrl: 'https://example.com/run/2',
1088
- }),
1089
- },
1090
- validation: {
1091
- assertProxyReachable: async () => {},
1092
- assertProxyKeyWorks: async () => {},
1093
- verifyModelsAvailable: async () => {},
1094
- },
1095
- },
1096
- )
1097
- } finally {
1098
- globalThis.fetch = originalFetch
1099
- }
1100
-
1101
- const smokeLog = logs.find(args => typeof args[0] === 'string' && (args[0] as string).startsWith('[smoke-test]'))
1102
- expect(smokeLog).toBeDefined()
1103
- expect(smokeLog?.[0]).toBe('[smoke-test] kind=fail')
1104
- })
1105
-
1106
- it('smoke test emits [smoke-test] kind=unverified to ctx.console.log', async () => {
1107
- const {ctx, logs} = makeCtx()
1108
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1109
- const originalFetch = globalThis.fetch
1110
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1111
-
1112
- try {
1113
- await runSetupCommand(
1114
- {
1115
- key: KEY,
1116
- repo: 'owner/repo',
1117
- harness: 'opencode',
1118
- providers: 'openai',
1119
- model: 'openai/gpt-5.4-mini',
1120
- force: true,
1121
- verifySmoke: true,
1122
- },
1123
- {
1124
- interactive: false,
1125
- baseUrl: BASE_URL,
1126
- ctx,
1127
- gh: {
1128
- assertGhInstalled: async () => {},
1129
- assertGhAuthenticated: async () => {},
1130
- assertRepoAccess: async () => {},
1131
- listExistingGhNames: async () => [],
1132
- createManagementApiKey: async () => {},
1133
- deleteManagementApiKey: async () => {},
1134
- applyGhValue: async () => {},
1135
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1136
- },
1137
- prompts: {
1138
- promptValue: autoPromptValue,
1139
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1140
- intro: () => {},
1141
- note: () => {},
1142
- outro: () => {},
1143
- },
1144
- smoke: {
1145
- runSmokeTest: async () => ({
1146
- kind: 'unverified' as const,
1147
- message: 'Workflow requires environment approval',
1148
- runUrl: 'https://example.com/run/3',
1149
- }),
1150
- },
1151
- validation: {
1152
- assertProxyReachable: async () => {},
1153
- assertProxyKeyWorks: async () => {},
1154
- verifyModelsAvailable: async () => {},
1155
- },
1156
- },
1157
- )
1158
- } finally {
1159
- globalThis.fetch = originalFetch
1160
- }
1161
-
1162
- const smokeLog = logs.find(args => typeof args[0] === 'string' && (args[0] as string).startsWith('[smoke-test]'))
1163
- expect(smokeLog).toBeDefined()
1164
- expect(smokeLog?.[0]).toBe('[smoke-test] kind=unverified')
1165
- })
1166
-
1167
- // ── key-reuse acknowledgment tests ────────────────────────────────────────────────
1168
-
1169
- it('non-interactive + --key + existing OPENCODE_AUTH_JSON + no --ack-key-reuse → throws', async () => {
1170
- const {ctx} = makeCtx()
1171
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1172
- const originalFetch = globalThis.fetch
1173
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1174
-
1175
- try {
1176
- await expect(
1177
- runSetupCommand(
1178
- {
1179
- key: KEY,
1180
- repo: 'owner/repo',
1181
- harness: 'opencode',
1182
- providers: 'openai',
1183
- model: 'openai/gpt-5.4-mini',
1184
- force: true,
1185
- ackKeyReuse: false,
1186
- },
1187
- {
1188
- interactive: false,
1189
- baseUrl: BASE_URL,
1190
- ctx,
1191
- gh: {
1192
- assertGhInstalled: async () => {},
1193
- assertGhAuthenticated: async () => {},
1194
- assertRepoAccess: async () => {},
1195
- listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
1196
- createManagementApiKey: async () => {},
1197
- deleteManagementApiKey: async () => {},
1198
- applyGhValue: async () => {},
1199
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1200
- },
1201
- prompts: {
1202
- promptValue: autoPromptValue,
1203
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1204
- intro: () => {},
1205
- note: () => {},
1206
- outro: () => {},
1207
- },
1208
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1209
- validation: {
1210
- assertProxyReachable: async () => {},
1211
- assertProxyKeyWorks: async () => {},
1212
- verifyModelsAvailable: async () => {},
1213
- },
1214
- },
1215
- ),
1216
- ).rejects.toThrow(/Refusing key-reuse without explicit acknowledgment/)
1217
- } finally {
1218
- globalThis.fetch = originalFetch
1219
- }
1220
- })
1221
-
1222
- it('non-interactive + --key + existing OPENCODE_AUTH_JSON + --ack-key-reuse → no throw', async () => {
1223
- const {ctx} = makeCtx()
1224
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1225
- const originalFetch = globalThis.fetch
1226
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1227
-
1228
- try {
1229
- await expect(
1230
- runSetupCommand(
1231
- {
1232
- key: KEY,
1233
- repo: 'owner/repo',
1234
- harness: 'opencode',
1235
- providers: 'openai',
1236
- model: 'openai/gpt-5.4-mini',
1237
- force: true,
1238
- ackKeyReuse: true,
1239
- },
1240
- {
1241
- interactive: false,
1242
- baseUrl: BASE_URL,
1243
- ctx,
1244
- gh: {
1245
- assertGhInstalled: async () => {},
1246
- assertGhAuthenticated: async () => {},
1247
- assertRepoAccess: async () => {},
1248
- listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
1249
- createManagementApiKey: async () => {},
1250
- deleteManagementApiKey: async () => {},
1251
- applyGhValue: async () => {},
1252
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1253
- },
1254
- prompts: {
1255
- promptValue: autoPromptValue,
1256
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1257
- intro: () => {},
1258
- note: () => {},
1259
- outro: () => {},
1260
- },
1261
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1262
- validation: {
1263
- assertProxyReachable: async () => {},
1264
- assertProxyKeyWorks: async () => {},
1265
- verifyModelsAvailable: async () => {},
1266
- },
1267
- },
1268
- ),
1269
- ).resolves.toBeUndefined()
1270
- } finally {
1271
- globalThis.fetch = originalFetch
1272
- }
1273
- })
1274
-
1275
- it('fresh repo (no existing OPENCODE_AUTH_JSON) + --key + no --ack-key-reuse → no throw', async () => {
1276
- const {ctx} = makeCtx()
1277
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1278
- const originalFetch = globalThis.fetch
1279
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1280
-
1281
- try {
1282
- await expect(
1283
- runSetupCommand(
1284
- {
1285
- key: KEY,
1286
- repo: 'owner/repo',
1287
- harness: 'opencode',
1288
- providers: 'openai',
1289
- model: 'openai/gpt-5.4-mini',
1290
- force: true,
1291
- ackKeyReuse: false,
1292
- },
1293
- {
1294
- interactive: false,
1295
- baseUrl: BASE_URL,
1296
- ctx,
1297
- gh: {
1298
- assertGhInstalled: async () => {},
1299
- assertGhAuthenticated: async () => {},
1300
- assertRepoAccess: async () => {},
1301
- // No existing secrets
1302
- listExistingGhNames: async () => [],
1303
- createManagementApiKey: async () => {},
1304
- deleteManagementApiKey: async () => {},
1305
- applyGhValue: async () => {},
1306
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1307
- },
1308
- prompts: {
1309
- promptValue: autoPromptValue,
1310
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1311
- intro: () => {},
1312
- note: () => {},
1313
- outro: () => {},
1314
- },
1315
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1316
- validation: {
1317
- assertProxyReachable: async () => {},
1318
- assertProxyKeyWorks: async () => {},
1319
- verifyModelsAvailable: async () => {},
1320
- },
1321
- },
1322
- ),
1323
- ).resolves.toBeUndefined()
1324
- } finally {
1325
- globalThis.fetch = originalFetch
1326
- }
1327
- })
1328
-
1329
- it('--key omitted + existing OPENCODE_AUTH_JSON + no --ack-key-reuse → no throw (key-reuse path skipped)', async () => {
1330
- const {ctx} = makeCtx()
1331
- // No key supplied, wizard would mint a new one — but in non-interactive mode without key, plan.createKey=true
1332
- // which requires managementKey. We test that the ack-key-reuse guard doesn't fire.
1333
- // Use dry-run to avoid needing management key.
1334
- await expect(
1335
- runSetupCommand(
1336
- {
1337
- repo: 'owner/repo',
1338
- harness: 'opencode',
1339
- dryRun: true,
1340
- ackKeyReuse: false,
1341
- },
1342
- {
1343
- interactive: false,
1344
- baseUrl: BASE_URL,
1345
- ctx,
1346
- gh: {
1347
- assertGhInstalled: async () => {},
1348
- assertGhAuthenticated: async () => {},
1349
- assertRepoAccess: async () => {},
1350
- listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
1351
- createManagementApiKey: async () => {},
1352
- deleteManagementApiKey: async () => {},
1353
- applyGhValue: async () => {},
1354
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1355
- },
1356
- prompts: {
1357
- promptValue: autoPromptValue,
1358
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1359
- intro: () => {},
1360
- note: () => {},
1361
- outro: () => {},
1362
- },
1363
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1364
- validation: {
1365
- assertProxyReachable: async () => {},
1366
- assertProxyKeyWorks: async () => {},
1367
- verifyModelsAvailable: async () => {},
1368
- },
1369
- },
1370
- ),
1371
- ).resolves.toBeUndefined()
1372
- })
1373
-
1374
- // ── Interactive key-reuse confirm prompt: redaction + cancel/continue ──────────
1375
-
1376
- it('redactKey: keys >= 12 chars use first-3 + *** + last-4 shape', () => {
1377
- expect(redactKey('sk-PLAINTEXT-LONGKEY')).toBe('sk-***GKEY')
1378
- expect(redactKey('sk-shortbutok123')).toBe('sk-***k123')
1379
- })
1380
-
1381
- it('redactKey: keys < 12 chars collapse to sk-*** to avoid leaking short strings', () => {
1382
- expect(redactKey('short')).toBe('sk-***')
1383
- expect(redactKey('')).toBe('sk-***')
1384
- expect(redactKey('sk-tiny')).toBe('sk-***')
1385
- })
1386
-
1387
- it('redactKey: every input shorter than the original, never echoes raw key', () => {
1388
- const RAW = 'sk-PLAINTEXT-LONGKEY-MUST-NOT-LEAK'
1389
- const redacted = redactKey(RAW)
1390
- expect(redacted).not.toContain('PLAINTEXT-LONGKEY-MUST-NOT')
1391
- expect(redacted.length).toBeLessThan(RAW.length)
1392
- })
1393
-
1394
- it('interactive key-reuse prompt template uses redactKey output, never the raw key (source-level contract)', async () => {
1395
- // Read the setup.ts source and assert the key-reuse prompt-message template uses ${redactKey(options.key)}
1396
- // and never `${options.key}` raw. This is a source-level guard so a future refactor that
1397
- // accidentally drops the redaction call fails the test even if integration coverage lags.
1398
- const source = await Bun.file(new URL('./setup.ts', import.meta.url).pathname).text()
1399
- const r8PromptIdx = source.indexOf('Verify it matches the bearer token')
1400
- expect(r8PromptIdx).toBeGreaterThan(-1)
1401
- const promptContext = source.slice(Math.max(0, r8PromptIdx - 200), r8PromptIdx + 100)
1402
- expect(promptContext).toContain('redactKey(options.key)')
1403
- expect(promptContext).not.toMatch(/--key \$\{options\.key\}/)
1404
- })
1405
-
1406
- it('interactive key-reuse confirm shows a redacted key, never the raw token', async () => {
1407
- const {ctx} = makeCtx()
1408
- const PLAINTEXT_KEY = 'sk-PLAINTEXT-LONGKEY-SHOULD-NOT-LEAK'
1409
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1410
- const originalFetch = globalThis.fetch
1411
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1412
-
1413
- const confirmMessages: string[] = []
1414
- const captureConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
1415
- confirmMessages.push(opts.message)
1416
- return Promise.resolve(true)
1417
- }
1418
-
1419
- // Interactive mode resolves promptValue to the awaited prompt result. Our captureConfirm
1420
- // returns true, so the wizard proceeds past the key-reuse gate. We assert on the captured message.
1421
- const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
1422
- const result = await prompt
1423
- return result as T
1424
- }
1425
-
1426
- try {
1427
- await runSetupCommand(
1428
- {
1429
- key: PLAINTEXT_KEY,
1430
- repo: 'owner/repo',
1431
- harness: 'opencode',
1432
- providers: 'openai',
1433
- model: 'openai/gpt-5.4-mini',
1434
- force: true,
1435
- },
1436
- {
1437
- interactive: true,
1438
- baseUrl: BASE_URL,
1439
- ctx,
1440
- gh: {
1441
- assertGhInstalled: async () => {},
1442
- assertGhAuthenticated: async () => {},
1443
- assertRepoAccess: async () => {},
1444
- listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
1445
- createManagementApiKey: async () => {},
1446
- deleteManagementApiKey: async () => {},
1447
- applyGhValue: async () => {},
1448
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1449
- },
1450
- prompts: {
1451
- promptValue: interactivePromptValue,
1452
- confirm: captureConfirm,
1453
- intro: () => {},
1454
- note: () => {},
1455
- outro: () => {},
1456
- promptForProviders: (): Promise<ProviderId[]> => Promise.resolve(['openai']),
1457
- promptForModel: (_providers: ProviderId[]): Promise<string> => Promise.resolve('openai/gpt-5.4-mini'),
1458
- },
1459
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1460
- validation: {
1461
- assertProxyReachable: async () => {},
1462
- assertProxyKeyWorks: async () => {},
1463
- verifyModelsAvailable: async () => {},
1464
- },
1465
- },
1466
- )
1467
- } finally {
1468
- globalThis.fetch = originalFetch
1469
- }
1470
-
1471
- // The key-reuse confirm prompt must appear among the captured confirms (interactive flow
1472
- // has more than one confirm in some paths; we find the one with the verify-bearer language).
1473
- const keyReuseMessage = confirmMessages.find(m => m.includes('Verify it matches the bearer token'))
1474
- expect(keyReuseMessage).toBeDefined()
1475
- // The raw key must NEVER appear in the prompt text — security regression guard.
1476
- expect(keyReuseMessage).not.toContain(PLAINTEXT_KEY)
1477
- // Redacted form must be present (first 3 + last 4 chars per redactKey helper).
1478
- expect(keyReuseMessage).toContain('sk-')
1479
- expect(keyReuseMessage).toContain('***')
1480
- expect(keyReuseMessage).toContain('LEAK')
1481
- })
1482
-
1483
- it('interactive key-reuse confirm returning false cancels before any GitHub write', async () => {
1484
- const {ctx} = makeCtx()
1485
- const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1486
- const originalFetch = globalThis.fetch
1487
- globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1488
-
1489
- let applyGhValueCalled = false
1490
- let exitCode: number | undefined
1491
-
1492
- // The interactive flow shows a generic "Proceed?" confirm BEFORE the key-reuse
1493
- // gate. Approve the generic prompt so the run actually reaches the key-reuse
1494
- // confirm, then reject only that one — otherwise the test would cancel at the
1495
- // first prompt and never exercise the gate it claims to cover.
1496
- const confirmMessages: string[] = []
1497
- const messageAwareConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
1498
- confirmMessages.push(opts.message)
1499
- const isKeyReusePrompt = opts.message.includes('Verify it matches the bearer token')
1500
- return Promise.resolve(!isKeyReusePrompt)
1501
- }
1502
-
1503
- const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
1504
- const result = await prompt
1505
- return result as T
1506
- }
1507
-
1508
- // process.exit is intercepted by ctx.process.exit only when ctx is threaded; cancelAndExit
1509
- // calls global process.exit. Stub it to capture the code and throw so the function unwinds.
1510
- const originalExit = process.exit
1511
- process.exit = ((code?: number) => {
1512
- exitCode = code
1513
- throw new Error('process.exit-stubbed')
1514
- }) as typeof process.exit
1515
-
1516
- try {
1517
- await runSetupCommand(
1518
- {
1519
- key: KEY,
1520
- repo: 'owner/repo',
1521
- harness: 'opencode',
1522
- providers: 'openai',
1523
- model: 'openai/gpt-5.4-mini',
1524
- force: true,
1525
- },
1526
- {
1527
- interactive: true,
1528
- baseUrl: BASE_URL,
1529
- ctx,
1530
- gh: {
1531
- assertGhInstalled: async () => {},
1532
- assertGhAuthenticated: async () => {},
1533
- assertRepoAccess: async () => {},
1534
- listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
1535
- createManagementApiKey: async () => {},
1536
- deleteManagementApiKey: async () => {},
1537
- applyGhValue: async () => {
1538
- applyGhValueCalled = true
1539
- },
1540
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1541
- },
1542
- prompts: {
1543
- promptValue: interactivePromptValue,
1544
- // Approve the generic proceed confirm, reject only the key-reuse confirm.
1545
- confirm: messageAwareConfirm,
1546
- intro: () => {},
1547
- note: () => {},
1548
- outro: () => {},
1549
- promptForProviders: (): Promise<ProviderId[]> => Promise.resolve(['openai']),
1550
- promptForModel: (_providers: ProviderId[]): Promise<string> => Promise.resolve('openai/gpt-5.4-mini'),
1551
- },
1552
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1553
- validation: {
1554
- assertProxyReachable: async () => {},
1555
- assertProxyKeyWorks: async () => {},
1556
- verifyModelsAvailable: async () => {},
1557
- },
1558
- },
1559
- )
1560
- // If we get here, cancelAndExit didn't fire. Fail the test.
1561
- throw new Error('expected cancelAndExit to fire on key-reuse reject')
1562
- } catch (error) {
1563
- // cancelAndExit throws because we stubbed process.exit to throw.
1564
- expect(error instanceof Error && error.message).toBe('process.exit-stubbed')
1565
- } finally {
1566
- process.exit = originalExit
1567
- globalThis.fetch = originalFetch
1568
- }
1569
-
1570
- // Guard against a vacuous pass: the run must have actually reached the
1571
- // key-reuse confirm, not cancelled at the earlier generic proceed prompt.
1572
- expect(confirmMessages.some(m => m.includes('Verify it matches the bearer token'))).toBe(true)
1573
- expect(exitCode).toBe(0)
1574
- expect(applyGhValueCalled).toBe(false)
1575
- })
1576
-
1577
- // ── Double-log regression test ────────────────────
1578
-
1579
- it('Bare CLI path (no ctx injected): action error not double-logged via ctx.console.error', async () => {
1580
- // When deps.ctx is undefined, ctx defaults to realCtx (which writes to global console).
1581
- // The catch block should skip ctx.console.error to avoid double-printing — cli.ts top-level
1582
- // catch already prints. We verify by tracking calls to console.error directly.
1583
- const errorCalls: string[] = []
1584
- const originalConsoleError = console.error
1585
- console.error = (...args: unknown[]) => {
1586
- errorCalls.push(args.map(String).join(' '))
1587
- }
1588
-
1589
- try {
1590
- await expect(
1591
- runSetupCommand(
1592
- {
1593
- key: KEY,
1594
- repo: 'owner/repo',
1595
- harness: 'opencode',
1596
- force: true,
1597
- },
1598
- {
1599
- interactive: false,
1600
- baseUrl: BASE_URL,
1601
- // ctx NOT supplied — bare CLI mode
1602
- gh: {
1603
- assertGhInstalled: async () => {
1604
- throw new Error('gh-not-installed-marker')
1605
- },
1606
- assertGhAuthenticated: async () => {},
1607
- assertRepoAccess: async () => {},
1608
- listExistingGhNames: async () => [],
1609
- createManagementApiKey: async () => {},
1610
- deleteManagementApiKey: async () => {},
1611
- applyGhValue: async () => {},
1612
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1613
- },
1614
- prompts: {
1615
- promptValue: autoPromptValue,
1616
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1617
- intro: () => {},
1618
- note: () => {},
1619
- outro: () => {},
1620
- },
1621
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1622
- validation: {
1623
- assertProxyReachable: async () => {},
1624
- assertProxyKeyWorks: async () => {},
1625
- verifyModelsAvailable: async () => {},
1626
- },
1627
- },
1628
- ),
1629
- ).rejects.toThrow('gh-not-installed-marker')
1630
- } finally {
1631
- console.error = originalConsoleError
1632
- }
1633
-
1634
- // The catch block must NOT have called ctx.console.error because deps.ctx was undefined.
1635
- // (cli.ts will handle the logging at the top level.) If the catch wrote here, this fails.
1636
- const errorMatches = errorCalls.filter(line => line.includes('gh-not-installed-marker'))
1637
- expect(errorMatches).toHaveLength(0)
1638
- })
1639
-
1640
- it('MCP path (ctx injected): action error IS logged via ctx.console.error', async () => {
1641
- // When deps.ctx IS supplied (MCP path), the catch block must call ctx.console.error
1642
- // so the MCP transport surfaces the message. cli.ts's top-level catch doesn't run in MCP mode.
1643
- const {ctx, errors} = makeCtx()
1644
-
1645
- await expect(
1646
- runSetupCommand(
1647
- {
1648
- key: KEY,
1649
- repo: 'owner/repo',
1650
- harness: 'opencode',
1651
- force: true,
1652
- },
1653
- {
1654
- interactive: false,
1655
- baseUrl: BASE_URL,
1656
- ctx, // ← injected
1657
- gh: {
1658
- assertGhInstalled: async () => {
1659
- throw new Error('mcp-error-marker')
1660
- },
1661
- assertGhAuthenticated: async () => {},
1662
- assertRepoAccess: async () => {},
1663
- listExistingGhNames: async () => [],
1664
- createManagementApiKey: async () => {},
1665
- deleteManagementApiKey: async () => {},
1666
- applyGhValue: async () => {},
1667
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1668
- },
1669
- prompts: {
1670
- promptValue: autoPromptValue,
1671
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1672
- intro: () => {},
1673
- note: () => {},
1674
- outro: () => {},
1675
- },
1676
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1677
- validation: {
1678
- assertProxyReachable: async () => {},
1679
- assertProxyKeyWorks: async () => {},
1680
- verifyModelsAvailable: async () => {},
1681
- },
1682
- },
1683
- ),
1684
- ).rejects.toThrow('mcp-error-marker')
1685
-
1686
- // The injected ctx.console.error received the message.
1687
- const errorTexts = errors.map(args => args.map(String).join(' '))
1688
- expect(errorTexts.some(line => line.includes('mcp-error-marker'))).toBe(true)
1689
- })
1690
-
1691
- // ── Rollback regression tests ─────────────────────
1692
-
1693
- it('Rollback: applyGhValue throws → deleteManagementApiKey called before error propagates', async () => {
1694
- const {ctx} = makeCtx()
1695
-
1696
- let deleteCalledWith: string | undefined
1697
- let applyCallCount = 0
1698
-
1699
- // Use interactive: true + harness: 'claude-code' (no provider prompts) so validateSetupOptions
1700
- // doesn't require --key. No --key → createKey=true → createManagementApiKey is called → rollback path.
1701
- // Inject resolveManagementKey so no env var needed.
1702
- try {
1703
- await runSetupCommand(
1704
- {
1705
- // No --key → createKey=true, exercises the rollback path
1706
- repo: 'owner/repo',
1707
- harness: 'claude-code',
1708
- force: true,
1709
- },
1710
- {
1711
- interactive: true,
1712
- baseUrl: BASE_URL,
1713
- ctx,
1714
- resolveManagementKey: () => 'mgmt-test-key',
1715
- gh: {
1716
- assertGhInstalled: async () => {},
1717
- assertGhAuthenticated: async () => {},
1718
- assertRepoAccess: async () => {},
1719
- listExistingGhNames: async () => [],
1720
- createManagementApiKey: async (_baseUrl, _mgmtKey, _keyValue) => {
1721
- // createManagementApiKey succeeds — key is now "live"
1722
- },
1723
- deleteManagementApiKey: async (_baseUrl, _mgmtKey, keyValue) => {
1724
- deleteCalledWith = keyValue
1725
- },
1726
- applyGhValue: async () => {
1727
- applyCallCount++
1728
- throw new Error('GitHub API failure')
1729
- },
1730
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1731
- },
1732
- prompts: {
1733
- promptValue: autoPromptValue,
1734
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1735
- intro: () => {},
1736
- note: () => {},
1737
- outro: () => {},
1738
- },
1739
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1740
- validation: {
1741
- assertProxyReachable: async () => {},
1742
- assertProxyKeyWorks: async () => {},
1743
- verifyModelsAvailable: async () => {},
1744
- },
1745
- },
1746
- )
1747
- } catch {
1748
- // Expected to throw
1749
- }
1750
-
1751
- // deleteManagementApiKey must have been called (rollback happened)
1752
- expect(deleteCalledWith).toBeDefined()
1753
- expect(applyCallCount).toBeGreaterThan(0)
1754
- })
1755
-
1756
- it('Rollback: assertProxyKeyWorks throws → deleteManagementApiKey called before error propagates', async () => {
1757
- const {ctx} = makeCtx()
1758
-
1759
- let deleteCalledWith: string | undefined
1760
-
1761
- try {
1762
- await runSetupCommand(
1763
- {
1764
- // No --key → createKey=true, exercises the rollback path
1765
- repo: 'owner/repo',
1766
- harness: 'claude-code',
1767
- force: true,
1768
- },
1769
- {
1770
- interactive: true,
1771
- baseUrl: BASE_URL,
1772
- ctx,
1773
- resolveManagementKey: () => 'mgmt-test-key',
1774
- gh: {
1775
- assertGhInstalled: async () => {},
1776
- assertGhAuthenticated: async () => {},
1777
- assertRepoAccess: async () => {},
1778
- listExistingGhNames: async () => [],
1779
- createManagementApiKey: async () => {},
1780
- deleteManagementApiKey: async (_baseUrl, _mgmtKey, keyValue) => {
1781
- deleteCalledWith = keyValue
1782
- },
1783
- applyGhValue: async () => {},
1784
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1785
- },
1786
- prompts: {
1787
- promptValue: autoPromptValue,
1788
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1789
- intro: () => {},
1790
- note: () => {},
1791
- outro: () => {},
1792
- },
1793
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1794
- validation: {
1795
- assertProxyReachable: async () => {},
1796
- assertProxyKeyWorks: async () => {
1797
- throw new Error('Proxy key verification failed')
1798
- },
1799
- verifyModelsAvailable: async () => {},
1800
- },
1801
- },
1802
- )
1803
- } catch {
1804
- // Expected to throw
1805
- }
1806
-
1807
- // deleteManagementApiKey must have been called (rollback happened)
1808
- expect(deleteCalledWith).toBeDefined()
1809
- })
1810
-
1811
- // ── --dry-run with no --repo/--harness ─────────────────────────────────
1812
-
1813
- it('--dry-run with no --repo/--harness prints preview and does not throw', async () => {
1814
- const {ctx, logs} = makeCtx()
1815
- await runSetupCommand({dryRun: true}, {ctx})
1816
- const output = logs.map(args => args.join(' ')).join('\n')
1817
- expect(output).toContain('OPENCODE_AUTH_JSON')
1818
- expect(output).toContain('No mutations will be performed.')
1819
- })
1820
-
1821
- it('--dry-run does not call assertGhInstalled even with no flags', async () => {
1822
- const {ctx} = makeCtx()
1823
- let ghCalled = false
1824
- await runSetupCommand(
1825
- {dryRun: true},
1826
- {
1827
- ctx,
1828
- gh: {
1829
- assertGhInstalled: async () => {
1830
- ghCalled = true
1831
- },
1832
- assertGhAuthenticated: async () => {},
1833
- assertRepoAccess: async () => {},
1834
- listExistingGhNames: async () => [],
1835
- createManagementApiKey: async () => {},
1836
- deleteManagementApiKey: async () => {},
1837
- applyGhValue: async () => {},
1838
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1839
- },
1840
- },
1841
- )
1842
- expect(ghCalled).toBe(false)
1843
- })
1844
-
1845
- // ── Rollback event-order assertions ────────────────────────────────────
1846
-
1847
- it('applyGhValue fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1848
- const {ctx} = makeCtx()
1849
- const events: string[] = []
1850
-
1851
- await expect(
1852
- runSetupCommand(
1853
- {repo: 'owner/repo', harness: 'claude-code', force: true},
1854
- {
1855
- interactive: true,
1856
- baseUrl: BASE_URL,
1857
- ctx,
1858
- resolveManagementKey: () => 'mgmt-test-key',
1859
- gh: {
1860
- assertGhInstalled: async () => {},
1861
- assertGhAuthenticated: async () => {},
1862
- assertRepoAccess: async () => {},
1863
- listExistingGhNames: async () => [],
1864
- createManagementApiKey: async () => {
1865
- events.push('create')
1866
- },
1867
- deleteManagementApiKey: async () => {
1868
- events.push('delete')
1869
- },
1870
- applyGhValue: async () => {
1871
- events.push('apply-fail')
1872
- throw new Error('apply-fail')
1873
- },
1874
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1875
- },
1876
- prompts: {
1877
- promptValue: autoPromptValue,
1878
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1879
- intro: () => {},
1880
- note: () => {},
1881
- outro: () => {},
1882
- },
1883
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1884
- validation: {
1885
- assertProxyReachable: async () => {},
1886
- assertProxyKeyWorks: async () => {},
1887
- verifyModelsAvailable: async () => {},
1888
- },
1889
- },
1890
- ),
1891
- ).rejects.toThrow('apply-fail')
1892
-
1893
- expect(events).toEqual(['create', 'apply-fail', 'delete'])
1894
- })
1895
-
1896
- it('assertProxyKeyWorks fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1897
- const {ctx} = makeCtx()
1898
- const events: string[] = []
1899
-
1900
- await expect(
1901
- runSetupCommand(
1902
- {repo: 'owner/repo', harness: 'claude-code', force: true},
1903
- {
1904
- interactive: true,
1905
- baseUrl: BASE_URL,
1906
- ctx,
1907
- resolveManagementKey: () => 'mgmt-test-key',
1908
- gh: {
1909
- assertGhInstalled: async () => {},
1910
- assertGhAuthenticated: async () => {},
1911
- assertRepoAccess: async () => {},
1912
- listExistingGhNames: async () => [],
1913
- createManagementApiKey: async () => {
1914
- events.push('create')
1915
- },
1916
- deleteManagementApiKey: async () => {
1917
- events.push('delete')
1918
- },
1919
- applyGhValue: async () => {
1920
- events.push('apply-success')
1921
- },
1922
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
1923
- },
1924
- prompts: {
1925
- promptValue: autoPromptValue,
1926
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1927
- intro: () => {},
1928
- note: () => {},
1929
- outro: () => {},
1930
- },
1931
- smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1932
- validation: {
1933
- assertProxyReachable: async () => {},
1934
- assertProxyKeyWorks: async () => {
1935
- events.push('verify-fail')
1936
- throw new Error('verify-fail')
1937
- },
1938
- verifyModelsAvailable: async () => {},
1939
- },
1940
- },
1941
- ),
1942
- ).rejects.toThrow('verify-fail')
1943
-
1944
- expect(events).toEqual(['create', 'apply-success', 'verify-fail', 'delete'])
1945
- })
1946
-
1947
- // ── --force pre-gate fires before verifyModelsAvailable ────────────────
1948
-
1949
- it('missing --force on provider change does not call fetch (verifyModelsAvailable skipped)', async () => {
1950
- let fetchCalled = false
1951
- const originalFetch = globalThis.fetch
1952
- globalThis.fetch = mock(async () => {
1953
- fetchCalled = true
1954
- return new Response('{}')
1955
- }) as unknown as typeof fetch
1956
-
1957
- try {
1958
- await expect(
1959
- buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai'}, BASE_URL),
1960
- ).rejects.toThrow('--force')
1961
- } finally {
1962
- globalThis.fetch = originalFetch
1963
- }
1964
-
1965
- expect(fetchCalled).toBe(false)
1966
- })
1967
- })
1968
-
1969
- // ── post-write readback verification ──────────────────────────────────────────
1970
-
1971
- describe('post-write readback verification', () => {
1972
- const BASE_URL = 'https://cliproxy.fro.bot'
1973
- const KEY = 'sk-test-key'
1974
-
1975
- // Capture log.warn calls from @clack/prompts
1976
- let warnSpy: ReturnType<typeof spyOn>
1977
- let warnMessages: string[]
1978
-
1979
- // Standard DI deps for a successful non-interactive setup run.
1980
- // listExistingGhNames is overridden per-test to control readback behavior.
1981
- function makeDeps(
1982
- listExistingGhNames: (repo: string, kind: 'secret' | 'variable') => Promise<string[]>,
1983
- deleteManagementApiKey?: () => Promise<void>,
1984
- ) {
1985
- const {ctx} = makeCtx()
1986
- return {
1987
- ctx,
1988
- deps: {
1989
- interactive: false,
1990
- baseUrl: BASE_URL,
1991
- ctx,
1992
- gh: {
1993
- assertGhInstalled: async () => {},
1994
- assertGhAuthenticated: async () => {},
1995
- assertRepoAccess: async () => {},
1996
- listExistingGhNames,
1997
- createManagementApiKey: async () => {},
1998
- deleteManagementApiKey: deleteManagementApiKey ?? (async () => {}),
1999
- applyGhValue: async () => {},
2000
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
2001
- },
2002
- prompts: {
2003
- promptValue: autoPromptValue,
2004
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
2005
- intro: () => {},
2006
- note: () => {},
2007
- outro: () => {},
2008
- },
2009
- smoke: {
2010
- runSmokeTest: async () => ({kind: 'pass' as const, message: 'ok', runUrl: 'https://example.com/run/1'}),
2011
- },
2012
- validation: {
2013
- assertProxyReachable: async () => {},
2014
- assertProxyKeyWorks: async () => {},
2015
- verifyModelsAvailable: async () => {},
2016
- },
2017
- } satisfies Parameters<typeof runSetupCommand>[1],
2018
- }
2019
- }
2020
-
2021
- // Standard options for a non-interactive anthropic-only setup (no --force needed)
2022
- const baseOptions = {
2023
- key: KEY,
2024
- repo: 'owner/repo',
2025
- harness: 'opencode' as const,
2026
- }
2027
-
2028
- beforeEach(() => {
2029
- warnMessages = []
2030
- warnSpy = spyOn(log, 'warn').mockImplementation((msg: string) => {
2031
- warnMessages.push(msg)
2032
- })
2033
- })
2034
-
2035
- afterEach(() => {
2036
- warnSpy.mockRestore()
2037
- })
2038
-
2039
- it('happy path: readback returns all written names → no new warning emitted', async () => {
2040
- // Pre-write list is empty; post-write readback returns all written names
2041
- // The opencode harness writes: OPENCODE_AUTH_JSON, OPENCODE_CONFIG (secrets)
2042
- // and FRO_BOT_MODEL (variable)
2043
- let callCount = 0
2044
- const {deps} = makeDeps(async (_repo, kind) => {
2045
- callCount++
2046
- if (callCount <= 2) {
2047
- // Pre-write calls: return empty (fresh repo)
2048
- return []
2049
- }
2050
- // Post-write readback: return all written names
2051
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2052
- return ['FRO_BOT_MODEL']
2053
- })
2054
-
2055
- await runSetupCommand(baseOptions, deps)
2056
-
2057
- // No verified-mismatch or cannot-verify warning should have been emitted
2058
- const readbackWarnings = warnMessages.filter(
2059
- m => m.includes('not visible') || m.includes('could not verify') || m.includes('may have been bypassed'),
2060
- )
2061
- expect(readbackWarnings).toHaveLength(0)
2062
- })
2063
-
2064
- it('verified mismatch (secret): readback succeeds but written secret absent → loud warning naming absent secret', async () => {
2065
- // Pre-write: empty. Post-write secret readback: missing OPENCODE_AUTH_JSON
2066
- let callCount = 0
2067
- const {deps} = makeDeps(async (_repo, kind) => {
2068
- callCount++
2069
- if (callCount <= 2) return []
2070
- // Post-write: secret readback missing OPENCODE_AUTH_JSON
2071
- if (kind === 'secret') return ['OPENCODE_CONFIG']
2072
- return ['FRO_BOT_MODEL']
2073
- })
2074
-
2075
- await runSetupCommand(baseOptions, deps)
2076
-
2077
- const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2078
- expect(mismatchWarnings.length).toBeGreaterThan(0)
2079
- // Must name the absent secret
2080
- expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
2081
- // Must direct operator to manual verification
2082
- expect(mismatchWarnings.some(m => m.includes('gh secret list'))).toBe(true)
2083
- })
2084
-
2085
- it('verified mismatch (variable): secret readback complete but variable absent → warning lists absent variable', async () => {
2086
- // Pre-write: empty. Post-write: all secrets present, but FRO_BOT_MODEL missing from variables
2087
- let callCount = 0
2088
- const {deps} = makeDeps(async (_repo, kind) => {
2089
- callCount++
2090
- if (callCount <= 2) return []
2091
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2092
- // Variable readback missing FRO_BOT_MODEL
2093
- return []
2094
- })
2095
-
2096
- await runSetupCommand(baseOptions, deps)
2097
-
2098
- const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2099
- expect(mismatchWarnings.length).toBeGreaterThan(0)
2100
- expect(mismatchWarnings.some(m => m.includes('FRO_BOT_MODEL'))).toBe(true)
2101
- expect(mismatchWarnings.some(m => m.includes('gh variable list'))).toBe(true)
2102
- })
2103
-
2104
- it('partial visibility: readback shows some but not all written names → warning lists exactly the absent names', async () => {
2105
- // Post-write: OPENCODE_CONFIG present, OPENCODE_AUTH_JSON absent
2106
- let callCount = 0
2107
- const {deps} = makeDeps(async (_repo, kind) => {
2108
- callCount++
2109
- if (callCount <= 2) return []
2110
- if (kind === 'secret') return ['OPENCODE_CONFIG'] // OPENCODE_AUTH_JSON absent
2111
- return ['FRO_BOT_MODEL']
2112
- })
2113
-
2114
- await runSetupCommand(baseOptions, deps)
2115
-
2116
- const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2117
- expect(mismatchWarnings.length).toBeGreaterThan(0)
2118
- // Must name OPENCODE_AUTH_JSON (absent)
2119
- expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
2120
- // Must NOT name OPENCODE_CONFIG (it IS present)
2121
- expect(mismatchWarnings.some(m => m.includes('OPENCODE_CONFIG'))).toBe(false)
2122
- })
2123
-
2124
- it('cannot verify: listExistingGhNames throws on post-write call → softer warning, command does NOT throw, rollback NOT fired', async () => {
2125
- let deleteCalledWith: string | undefined
2126
- let callCount = 0
2127
-
2128
- const {deps} = makeDeps(
2129
- async (_repo, _kind) => {
2130
- callCount++
2131
- if (callCount <= 2) return [] // Pre-write calls succeed
2132
- // Post-write readback throws
2133
- throw new Error('gh: command failed')
2134
- },
2135
- async () => {
2136
- deleteCalledWith = 'called'
2137
- },
2138
- )
2139
-
2140
- // Command must NOT throw
2141
- await expect(runSetupCommand(baseOptions, deps)).resolves.toBeUndefined()
2142
-
2143
- // Must emit the cannot-verify warning (softer wording)
2144
- const cannotVerifyWarnings = warnMessages.filter(m => m.includes('could not verify'))
2145
- expect(cannotVerifyWarnings.length).toBeGreaterThan(0)
2146
-
2147
- // Must NOT emit the verified-mismatch warning
2148
- const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2149
- expect(mismatchWarnings).toHaveLength(0)
2150
-
2151
- // Rollback must NOT have fired (key was not created by this run since --key was supplied)
2152
- expect(deleteCalledWith).toBeUndefined()
2153
- })
2154
-
2155
- it('createKey:true path — post-write readback throws → command resolves, key created, rollback suppressed', async () => {
2156
- // This test drives the createKey:true path (no --key supplied, wizard mints a key).
2157
- // The post-write readback throws after the key is created and secrets are written.
2158
- // Asserts: (1) createManagementApiKey WAS called, (2) command resolves, (3) deleteManagementApiKey NOT called.
2159
- const {ctx} = makeCtx()
2160
-
2161
- let createCalled = false
2162
- let deleteCalled = false
2163
- let listCallCount = 0
2164
-
2165
- await runSetupCommand(
2166
- {
2167
- // No --key → createKey=true
2168
- repo: 'owner/repo',
2169
- harness: 'claude-code',
2170
- force: true,
2171
- },
2172
- {
2173
- interactive: true,
2174
- baseUrl: BASE_URL,
2175
- ctx,
2176
- resolveManagementKey: () => 'mgmt-test-key',
2177
- gh: {
2178
- assertGhInstalled: async () => {},
2179
- assertGhAuthenticated: async () => {},
2180
- assertRepoAccess: async () => {},
2181
- listExistingGhNames: async (_repo, _kind) => {
2182
- listCallCount++
2183
- if (listCallCount <= 2) return [] // Pre-write calls succeed (empty repo)
2184
- // Post-write readback throws
2185
- throw new Error('gh: post-write readback failed')
2186
- },
2187
- createManagementApiKey: async () => {
2188
- createCalled = true
2189
- },
2190
- deleteManagementApiKey: async () => {
2191
- deleteCalled = true
2192
- },
2193
- applyGhValue: async () => {},
2194
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
2195
- },
2196
- prompts: {
2197
- promptValue: autoPromptValue,
2198
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
2199
- intro: () => {},
2200
- note: () => {},
2201
- outro: () => {},
2202
- },
2203
- smoke: {
2204
- runSmokeTest: async () => ({kind: 'pass' as const, message: 'ok', runUrl: 'https://example.com/run/1'}),
2205
- },
2206
- validation: {
2207
- assertProxyReachable: async () => {},
2208
- assertProxyKeyWorks: async () => {},
2209
- verifyModelsAvailable: async () => {},
2210
- },
2211
- },
2212
- )
2213
-
2214
- // Key was created this run
2215
- expect(createCalled).toBe(true)
2216
- // Command resolved (did not throw)
2217
- // (implicit — if it threw, the test would fail above)
2218
- // Rollback must NOT have fired — post-write readback failure must not trigger key deletion
2219
- expect(deleteCalled).toBe(false)
2220
- })
2221
-
2222
- it('whole-block guard: throw during diff/warning path → command does NOT throw, rollback NOT fired', async () => {
2223
- // Simulate a throw that occurs after the gh calls succeed but during processing.
2224
- // We do this by making the post-write secret readback return a value that causes
2225
- // an error in the diff computation — specifically, we inject a non-iterable value
2226
- // by making listExistingGhNames return a Proxy that throws on iteration.
2227
- let deleteCalledWith: string | undefined
2228
- let callCount = 0
2229
-
2230
- const {deps} = makeDeps(
2231
- async (_repo, _kind) => {
2232
- callCount++
2233
- if (callCount <= 2) return []
2234
- // Return a value that will cause an error during set-difference computation:
2235
- // a Proxy that throws when iterated
2236
- const throwingArray = new Proxy([] as string[], {
2237
- get(target, prop) {
2238
- if (prop === 'includes' || prop === Symbol.iterator || prop === 'forEach') {
2239
- throw new Error('injected-diff-error')
2240
- }
2241
- return Reflect.get(target, prop)
2242
- },
2243
- })
2244
- return throwingArray
2245
- },
2246
- async () => {
2247
- deleteCalledWith = 'called'
2248
- },
2249
- )
2250
-
2251
- // Command must NOT throw
2252
- await expect(runSetupCommand(baseOptions, deps)).resolves.toBeUndefined()
2253
-
2254
- // Rollback must NOT have fired
2255
- expect(deleteCalledWith).toBeUndefined()
2256
- })
2257
-
2258
- it('existing secret + ack-key-reuse: readback shows all names → no new warning', async () => {
2259
- // Pre-write: OPENCODE_AUTH_JSON already exists (triggers ack-key-reuse path)
2260
- // Post-write readback: all names present
2261
- let callCount = 0
2262
- const {deps} = makeDeps(async (_repo, kind) => {
2263
- callCount++
2264
- if (callCount <= 2) {
2265
- // Pre-write: OPENCODE_AUTH_JSON exists
2266
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON']
2267
- return []
2268
- }
2269
- // Post-write readback: all names present
2270
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2271
- return ['FRO_BOT_MODEL']
2272
- })
2273
-
2274
- await runSetupCommand({...baseOptions, ackKeyReuse: true, force: true}, deps)
2275
-
2276
- const readbackWarnings = warnMessages.filter(
2277
- m => m.includes('not visible') || m.includes('could not verify') || m.includes('may have been bypassed'),
2278
- )
2279
- expect(readbackWarnings).toHaveLength(0)
2280
- })
2281
- })
2282
-
2283
- // ── concurrency caveat on the non-interactive overwrite warning ─────────────────
2284
-
2285
- describe('non-interactive overwrite warning concurrency caveat', () => {
2286
- const BASE_URL = 'https://cliproxy.fro.bot'
2287
- const KEY = 'sk-test-key'
2288
-
2289
- let warnSpy: ReturnType<typeof spyOn>
2290
- let warnMessages: string[]
2291
-
2292
- function makeDeps(listExistingGhNames: (repo: string, kind: 'secret' | 'variable') => Promise<string[]>) {
2293
- const {ctx} = makeCtx()
2294
- return {
2295
- interactive: false,
2296
- baseUrl: BASE_URL,
2297
- ctx,
2298
- gh: {
2299
- assertGhInstalled: async () => {},
2300
- assertGhAuthenticated: async () => {},
2301
- assertRepoAccess: async () => {},
2302
- listExistingGhNames,
2303
- createManagementApiKey: async () => {},
2304
- deleteManagementApiKey: async () => {},
2305
- applyGhValue: async () => {},
2306
- withGhRetry: async (_label, fn) => fn(makeSpinner()),
2307
- },
2308
- prompts: {
2309
- promptValue: autoPromptValue,
2310
- confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
2311
- intro: () => {},
2312
- note: () => {},
2313
- outro: () => {},
2314
- },
2315
- smoke: {
2316
- runSmokeTest: async () => ({kind: 'pass' as const, message: 'ok', runUrl: 'https://example.com/run/1'}),
2317
- },
2318
- validation: {
2319
- assertProxyReachable: async () => {},
2320
- assertProxyKeyWorks: async () => {},
2321
- verifyModelsAvailable: async () => {},
2322
- },
2323
- } satisfies Parameters<typeof runSetupCommand>[1]
2324
- }
2325
-
2326
- beforeEach(() => {
2327
- warnMessages = []
2328
- warnSpy = spyOn(log, 'warn').mockImplementation((msg: string) => {
2329
- warnMessages.push(msg)
2330
- })
2331
- })
2332
-
2333
- afterEach(() => {
2334
- warnSpy.mockRestore()
2335
- })
2336
-
2337
- it('--force overwrite with a collision present → warning carries the last-write-wins concurrency caveat', async () => {
2338
- // OPENCODE_AUTH_JSON already exists → collision on the opencode secret set → overwrite warning fires.
2339
- const deps = makeDeps(async (_repo, kind) => {
2340
- if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG']
2341
- return ['FRO_BOT_MODEL']
2342
- })
2343
-
2344
- await runSetupCommand({key: KEY, repo: 'owner/repo', harness: 'opencode', ackKeyReuse: true, force: true}, deps)
2345
-
2346
- const overwriteWarnings = warnMessages.filter(m => m.includes('Overwriting existing GitHub values'))
2347
- expect(overwriteWarnings.length).toBeGreaterThan(0)
2348
- expect(overwriteWarnings.some(m => m.includes('last-write-wins'))).toBe(true)
2349
- expect(overwriteWarnings.some(m => m.includes('two places at once'))).toBe(true)
2350
- })
2351
-
2352
- it('--force with no collision → no overwrite warning, so no concurrency caveat (fresh-run race has no signal)', async () => {
2353
- // Fresh repo: empty pre-write list → no collision → overwrite warning never fires.
2354
- // This documents that the concurrency caveat does NOT cover the fresh-run race.
2355
- const deps = makeDeps(async (_repo, kind) => {
2356
- // Pre-write empty; post-write readback returns all written names (no readback warning either).
2357
- if (kind === 'secret') return []
2358
- return []
2359
- })
2360
-
2361
- await runSetupCommand({key: KEY, repo: 'owner/repo', harness: 'opencode', force: true}, deps)
2362
-
2363
- const concurrencyWarnings = warnMessages.filter(m => m.includes('last-write-wins'))
2364
- expect(concurrencyWarnings).toHaveLength(0)
2365
- })
2366
- })