@marcusrbrown/infra 0.7.0 → 0.8.0

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