@marcusrbrown/infra 0.6.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,341 +1,1961 @@
1
1
  /// <reference types="bun" />
2
2
 
3
- import {describe, expect, it} from 'bun:test'
3
+ import type {SpinnerResult} from '@clack/prompts'
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
- formatWorkflowSnippet,
9
- getHarnessTemplate,
10
- interpretGhContentResult,
11
- isGhRateLimitError,
8
+ buildNonInteractivePlan,
9
+ redactKey,
12
10
  registerCliproxySetup,
11
+ requiresDestructiveProviderChangeConfirmation,
12
+ runSetupCommand,
13
13
  validateSetupOptions,
14
- withGhRetry,
15
- type SecretAssignment,
16
- type VariableAssignment,
14
+ verifyModelsAvailable,
17
15
  } from './setup'
18
-
19
- const COMPLETE_WORKFLOW = ` - uses: fro-bot/agent@abc123
20
- with:
21
- github-token: \${{ secrets.FRO_BOT_PAT }}
22
- auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
23
- model: \${{ vars.FRO_BOT_MODEL }}
24
- omo-providers: \${{ secrets.OMO_PROVIDERS }}
25
- opencode-config: \${{ secrets.OPENCODE_CONFIG }}
26
- prompt: \${{ env.PROMPT }}
27
- `
28
-
29
- const MISSING_OPENCODE_CONFIG_WORKFLOW = ` - uses: fro-bot/agent@abc123
30
- with:
31
- auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
32
- github-token: \${{ secrets.FRO_BOT_PAT }}
33
- model: \${{ vars.FRO_BOT_MODEL }}
34
- omo-providers: \${{ secrets.OMO_PROVIDERS }}
35
- prompt: \${{ env.PROMPT }}
36
- `
37
-
38
- // Regression fixture for PR #125 review: a sibling step has `model:` as an input,
39
- // but the fro-bot/agent step is missing it. The step-scoped scan must still flag
40
- // `model` as missing, otherwise the diagnostic is silently suppressed.
41
- const SIBLING_STEP_SHADOWS_MODEL_INPUT = `name: ci
42
- on: [push]
43
- jobs:
44
- run:
45
- runs-on: ubuntu-latest
46
- strategy:
47
- matrix:
48
- model: [opus, sonnet]
49
- steps:
50
- - uses: actions/some-ai-step@abc
51
- with:
52
- model: \${{ matrix.model }}
53
- - name: Run Fro Bot
54
- uses: fro-bot/agent@def
55
- with:
56
- auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
57
- opencode-config: \${{ secrets.OPENCODE_CONFIG }}
58
- omo-providers: \${{ secrets.OMO_PROVIDERS }}
59
- `
60
-
61
- const WORKFLOW_WITHOUT_FRO_BOT_AGENT = `name: ci
62
- on: [push]
63
- jobs:
64
- build:
65
- runs-on: ubuntu-latest
66
- steps:
67
- - uses: actions/checkout@v4
68
- - run: echo hello
69
- `
70
-
71
- // Follow-up from PR #125 second review: the matchAll refactor must report gaps
72
- // in any fro-bot/agent step, not just the first. Step #1 is complete, step #2 is
73
- // missing opencode-config and model — the analyzer should flag only step #2.
74
- const TWO_AGENT_STEPS_SECOND_BROKEN = `name: fro-bot
75
- on: [pull_request, schedule]
76
- jobs:
77
- review:
78
- runs-on: ubuntu-latest
79
- steps:
80
- - name: Run Fro Bot review
81
- uses: fro-bot/agent@abc123
82
- with:
83
- auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
84
- opencode-config: \${{ secrets.OPENCODE_CONFIG }}
85
- omo-providers: \${{ secrets.OMO_PROVIDERS }}
86
- model: \${{ vars.FRO_BOT_MODEL }}
87
- dispatch:
88
- runs-on: ubuntu-latest
89
- steps:
90
- - name: Run Fro Bot dispatch
91
- uses: fro-bot/agent@abc123
92
- with:
93
- auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
94
- omo-providers: \${{ secrets.OMO_PROVIDERS }}
95
- `
16
+ import {formatDryRunPreview} from './setup/preview'
17
+ import {getHarnessTemplate} from './setup/templates'
96
18
 
97
19
  describe('cliproxy setup helpers', () => {
98
- describe('validateSetupOptions', () => {
99
- it('requires --key in non-interactive mode', () => {
100
- expect(() => validateSetupOptions({repo: 'owner/repo', harness: 'opencode'}, false)).toThrow(
101
- '--key is required when stdin is not a TTY',
102
- )
103
- })
20
+ describe('help output', () => {
21
+ it('shows --key, --repo, and --harness flags', () => {
22
+ const cli = goke('infra')
23
+ registerCliproxySetup(cli)
24
+ cli.help()
104
25
 
105
- it('requires --repo in non-interactive mode', () => {
106
- expect(() => validateSetupOptions({key: 'sk-test', harness: 'opencode'}, false)).toThrow(
107
- '--repo is required when stdin is not a TTY',
108
- )
26
+ const helpText = cli.helpText()
27
+
28
+ expect(helpText).toContain('cliproxy setup')
29
+ expect(helpText).toContain('--key [key]')
30
+ expect(helpText).toContain('--repo [repo]')
31
+ expect(helpText).toContain('--harness [harness]')
109
32
  })
110
33
 
111
- it('requires --harness in non-interactive mode', () => {
112
- expect(() => validateSetupOptions({key: 'sk-test', repo: 'owner/repo'}, false)).toThrow(
113
- '--harness is required when stdin is not a TTY',
114
- )
34
+ it('shows the five new provider/model/force/dry-run/verify-smoke flags in help text', () => {
35
+ const cli = goke('infra')
36
+ registerCliproxySetup(cli)
37
+ cli.help()
38
+
39
+ const helpText = cli.helpText()
40
+
41
+ expect(helpText).toContain('--providers')
42
+ expect(helpText).toContain('--model')
43
+ expect(helpText).toContain('--force')
44
+ expect(helpText).toContain('--dry-run')
45
+ expect(helpText).toContain('--verify-smoke')
115
46
  })
116
47
  })
48
+ })
117
49
 
118
- describe('getHarnessTemplate', () => {
119
- it('returns the expected OpenCode secret and variable names', () => {
120
- const template = getHarnessTemplate('opencode')
50
+ describe('option parsing', () => {
51
+ describe('model flag validation', () => {
52
+ // Tightened regex: trailing dot/hyphen rejected; single-char tail accepted
53
+ const MODEL_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
121
54
 
122
- expect(template.secrets.map((entry: SecretAssignment) => entry.name)).toEqual([
123
- 'OPENCODE_AUTH_JSON',
124
- 'OPENCODE_CONFIG',
125
- 'OMO_PROVIDERS',
126
- ])
127
- expect(template.variables.map((entry: VariableAssignment) => entry.name)).toEqual(['FRO_BOT_MODEL'])
55
+ it('accepts "openai/gpt-5.4-mini"', () => {
56
+ expect(MODEL_RE.test('openai/gpt-5.4-mini')).toBe(true)
128
57
  })
129
58
 
130
- it('uses a provider-prefixed FRO_BOT_MODEL default value', () => {
131
- const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
132
- const modelEntry = template.variables.find((entry: VariableAssignment) => entry.name === 'FRO_BOT_MODEL')
59
+ it('rejects "gpt-5.4-mini" (no provider prefix)', () => {
60
+ expect(MODEL_RE.test('gpt-5.4-mini')).toBe(false)
61
+ })
133
62
 
134
- expect(modelEntry?.value).toMatch(/^anthropic\//)
63
+ it('rejects "openai/GPT-5.4-mini" (uppercase)', () => {
64
+ expect(MODEL_RE.test('openai/GPT-5.4-mini')).toBe(false)
135
65
  })
136
66
 
137
- it('uses the expected OMO_PROVIDERS default value', () => {
138
- const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
139
- const providersEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OMO_PROVIDERS')
67
+ it('rejects "openai/gpt-5.4-mini; rm -rf /" (injection attempt)', () => {
68
+ expect(MODEL_RE.test('openai/gpt-5.4-mini; rm -rf /')).toBe(false)
69
+ })
140
70
 
141
- expect(providersEntry?.value).toBe('claude-max20')
71
+ // Fix 5 — trailing dot/hyphen rejection
72
+ it('rejects "openai/gpt-4o." (trailing dot)', () => {
73
+ expect(MODEL_RE.test('openai/gpt-4o.')).toBe(false)
142
74
  })
143
75
 
144
- it('writes an OPENCODE_CONFIG baseURL with the /v1 suffix', () => {
145
- const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
146
- const configEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_CONFIG')
147
- const parsed = JSON.parse(configEntry?.value ?? '{}')
76
+ it('rejects "openai/gpt-4o-" (trailing hyphen)', () => {
77
+ expect(MODEL_RE.test('openai/gpt-4o-')).toBe(false)
78
+ })
148
79
 
149
- expect(parsed.provider.anthropic.options.baseURL).toMatch(/\/v1$/)
80
+ it('accepts "openai/gpt-4o" (regression — still works)', () => {
81
+ expect(MODEL_RE.test('openai/gpt-4o')).toBe(true)
150
82
  })
151
83
 
152
- it('writes OPENCODE_AUTH_JSON with type=api and the supplied key', () => {
153
- const template = getHarnessTemplate('opencode', {keyValue: 'sk-test-key'})
154
- const authEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_AUTH_JSON')
155
- const parsed = JSON.parse(authEntry?.value ?? '{}')
84
+ it('accepts "anthropic/claude-sonnet-4-6" (regression)', () => {
85
+ expect(MODEL_RE.test('anthropic/claude-sonnet-4-6')).toBe(true)
86
+ })
87
+
88
+ it('accepts "openai/a" (single-char tail)', () => {
89
+ expect(MODEL_RE.test('openai/a')).toBe(true)
90
+ })
156
91
 
157
- expect(parsed.anthropic).toEqual({type: 'api', key: 'sk-test-key'})
92
+ it('rejects "openai/" (empty tail)', () => {
93
+ expect(MODEL_RE.test('openai/')).toBe(false)
158
94
  })
159
95
  })
96
+ })
97
+
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
+ }
160
107
 
161
- describe('analyzeFroBotWorkflow', () => {
162
- it('returns empty stepsWithGaps when all four inputs are wired', () => {
163
- const result = analyzeFroBotWorkflow(COMPLETE_WORKFLOW)
108
+ const BASE_URL = 'https://cliproxy.fro.bot'
109
+ const KEY = 'sk-test-key'
164
110
 
165
- expect(result.kind).toBe('analyzed')
166
- if (result.kind !== 'analyzed') throw new Error('unreachable')
167
- expect(result.stepsWithGaps).toEqual([])
111
+ let originalFetch: typeof globalThis.fetch
112
+ afterEach(() => {
113
+ globalThis.fetch = originalFetch
114
+ })
115
+ originalFetch = globalThis.fetch
116
+
117
+ // ── buildNonInteractivePlan ───────────────────────────────────────────────
118
+
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)
122
+
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)
168
130
  })
169
131
 
170
- it('detects a missing opencode-config input on step #1', () => {
171
- const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_WORKFLOW)
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
+ )
172
138
 
173
- expect(result.kind).toBe('analyzed')
174
- if (result.kind !== 'analyzed') throw new Error('unreachable')
175
- expect(result.stepsWithGaps).toHaveLength(1)
176
- expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
177
- expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
139
+ expect(planExplicit.template).toEqual(planDefault.template)
178
140
  })
179
141
 
180
- it('flags model as missing even when a sibling step uses model: as an input', () => {
181
- const result = analyzeFroBotWorkflow(SIBLING_STEP_SHADOWS_MODEL_INPUT)
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
182
145
 
183
- expect(result.kind).toBe('analyzed')
184
- if (result.kind !== 'analyzed') throw new Error('unreachable')
185
- expect(result.stepsWithGaps).toHaveLength(1)
186
- expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
187
- expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['model'])
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
+ )
157
+
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()
188
165
  })
189
166
 
190
- it('returns kind no-agent-step when the workflow has no fro-bot/agent step', () => {
191
- const result = analyzeFroBotWorkflow(WORKFLOW_WITHOUT_FRO_BOT_AGENT)
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
170
+
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
+ )
192
182
 
193
- expect(result.kind).toBe('no-agent-step')
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()
194
188
  })
195
189
 
196
- it('returns kind no-agent-step for empty content', () => {
197
- const result = analyzeFroBotWorkflow('')
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
193
+
194
+ const plan = await buildNonInteractivePlan(
195
+ {key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai', force: true},
196
+ BASE_URL,
197
+ )
198
198
 
199
- expect(result.kind).toBe('no-agent-step')
199
+ const modelEntry = plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')
200
+ expect(modelEntry?.value).toBe('openai/gpt-5.4-mini')
200
201
  })
201
202
 
202
- it('reports only the broken step when a workflow has two fro-bot/agent steps and one is complete', () => {
203
- const result = analyzeFroBotWorkflow(TWO_AGENT_STEPS_SECOND_BROKEN)
203
+ it('verifyModelsAvailable throws buildNonInteractivePlan propagates the error', async () => {
204
+ globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
204
205
 
205
- expect(result.kind).toBe('analyzed')
206
- if (result.kind !== 'analyzed') throw new Error('unreachable')
207
- expect(result.stepsWithGaps).toHaveLength(1)
208
- expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(2)
209
- expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config', 'model'])
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')
220
+ })
221
+
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
225
+
226
+ await buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL)
227
+
228
+ expect(fetchMock.mock.calls.length).toBe(0)
210
229
  })
211
230
  })
231
+ })
212
232
 
213
- describe('interpretGhContentResult', () => {
214
- it('returns kind missing when stderr contains HTTP 404', () => {
215
- const result = interpretGhContentResult({
216
- exitCode: 1,
217
- stdout: '',
218
- stderr: 'gh: Not Found (HTTP 404)',
219
- })
233
+ describe('destructive overwrite UX', () => {
234
+ const BASE_URL = 'https://cliproxy.fro.bot'
235
+ const KEY = 'sk-test-key'
220
236
 
221
- expect(result.kind).toBe('missing')
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
+ }
243
+
244
+ let originalFetch: typeof globalThis.fetch
245
+ afterEach(() => {
246
+ globalThis.fetch = originalFetch
247
+ })
248
+ originalFetch = globalThis.fetch
249
+
250
+ // ── mustConfirmDestructive ────────────────────────────────────────────────
251
+
252
+ describe('requiresDestructiveProviderChangeConfirmation', () => {
253
+ it("['anthropic'] → false (anthropic-only is safe, no confirm needed)", () => {
254
+ expect(requiresDestructiveProviderChangeConfirmation(['anthropic'])).toBe(false)
255
+ })
256
+
257
+ it("['openai'] → true (non-anthropic provider requires confirm)", () => {
258
+ expect(requiresDestructiveProviderChangeConfirmation(['openai'])).toBe(true)
222
259
  })
223
260
 
224
- it('returns kind unreachable with the stderr reason on non-404 failures', () => {
225
- const result = interpretGhContentResult({
226
- exitCode: 1,
227
- stdout: '',
228
- stderr: 'gh: API rate limit exceeded',
229
- })
261
+ it("['anthropic', 'openai'] true (multi-provider requires confirm)", () => {
262
+ expect(requiresDestructiveProviderChangeConfirmation(['anthropic', 'openai'])).toBe(true)
263
+ })
230
264
 
231
- expect(result.kind).toBe('unreachable')
232
- if (result.kind !== 'unreachable') throw new Error('unreachable')
233
- expect(result.reason).toBe('gh: API rate limit exceeded')
265
+ it("['openai', 'anthropic'] → true (order does not matter)", () => {
266
+ expect(requiresDestructiveProviderChangeConfirmation(['openai', 'anthropic'])).toBe(true)
234
267
  })
268
+ })
235
269
 
236
- it('falls back to the exit code when stderr is empty on a non-404 failure', () => {
237
- const result = interpretGhContentResult({exitCode: 2, stdout: '', stderr: ''})
270
+ // ── non-interactive gate: --force / --dry-run ─────────────────────────────
238
271
 
239
- expect(result.kind).toBe('unreachable')
240
- if (result.kind !== 'unreachable') throw new Error('unreachable')
241
- expect(result.reason).toBe('gh api exited with code 2')
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()
242
278
  })
243
279
 
244
- it('delegates to analyzeFroBotWorkflow on a successful response', () => {
245
- const result = interpretGhContentResult({
246
- exitCode: 0,
247
- stdout: COMPLETE_WORKFLOW,
248
- stderr: '',
249
- })
280
+ it('openai-only + --force plan builds without error', async () => {
281
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
250
282
 
251
- expect(result.kind).toBe('analyzed')
252
- if (result.kind !== 'analyzed') throw new Error('unreachable')
253
- expect(result.stepsWithGaps).toEqual([])
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()
254
296
  })
255
- })
256
297
 
257
- describe('formatWorkflowSnippet', () => {
258
- it('renders snippet lines at 10-space indent so they can be pasted directly under with:', () => {
259
- const snippet = formatWorkflowSnippet(['opencode-config', '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
260
300
 
261
- /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
262
- const expected = [
263
- ' opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
264
- ' model: ${{ vars.FRO_BOT_MODEL }}',
265
- ].join('\n')
266
- /* eslint-enable no-template-curly-in-string */
267
- expect(snippet).toBe(expected)
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/)
268
307
  })
269
- })
270
308
 
271
- describe('isGhRateLimitError', () => {
272
- it('returns true when text contains "rate limit"', () => {
273
- expect(isGhRateLimitError('API rate limit exceeded')).toBe(true)
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
311
+
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
+ }
321
+
322
+ expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
274
323
  })
275
324
 
276
- it('is case-insensitive', () => {
277
- expect(isGhRateLimitError('You have exceeded a secondary RATE LIMIT')).toBe(true)
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
327
+
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/)
278
340
  })
279
341
 
280
- it('returns false for unrelated error messages', () => {
281
- expect(isGhRateLimitError('Not Found (HTTP 404)')).toBe(false)
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()
282
357
  })
283
358
 
284
- it('returns false for an empty string', () => {
285
- expect(isGhRateLimitError('')).toBe(false)
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
362
+
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,
373
+ )
374
+
375
+ expect(fetchMock.mock.calls.length).toBe(0)
286
376
  })
287
377
 
288
- it('returns false for a connection timeout', () => {
289
- expect(isGhRateLimitError('connection timeout')).toBe(false)
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()
290
393
  })
291
394
  })
395
+ })
292
396
 
293
- describe('withGhRetry', () => {
294
- it('returns the value when fn succeeds immediately', async () => {
295
- const result = await withGhRetry('test label', async () => 'ok', false)
397
+ // ── Smoke test runner tests moved to setup/smoke-test.test.ts ─────────────────
398
+ // ── P1 regression tests ───────────────────────────────────────────────────────
296
399
 
297
- expect(result).toBe('ok')
298
- })
400
+ describe('P1 #1 regression — dry-run early return before mutations', () => {
401
+ const BASE_URL = 'https://cliproxy.fro.bot'
402
+ const KEY = 'sk-test-key'
299
403
 
300
- it('re-throws non-rate-limit errors without querying the reset time', async () => {
301
- const queryReset = async (): Promise<string> => {
302
- throw new Error('queryReset should not have been called')
303
- }
304
- const err = new Error('some other error')
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
305
410
 
306
- await expect(withGhRetry('test label', async () => Promise.reject(err), false, queryReset)).rejects.toThrow(
307
- 'some other error',
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,
308
422
  )
423
+ expect(plan).toBeDefined()
424
+ expect(fetchMock.mock.calls.length).toBe(0)
425
+ } finally {
426
+ globalThis.fetch = originalFetch
427
+ }
428
+ })
429
+
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,
309
438
  })
310
439
 
311
- it('re-throws with reset time appended in non-interactive mode on rate limit', async () => {
312
- const queryReset = async (): Promise<string> => '2:30 PM'
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)
444
+ })
445
+ })
313
446
 
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.
451
+
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.',
458
+ )
459
+ expect(gateError.message).toMatch(expectedPattern)
460
+ })
461
+
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
471
+
472
+ try {
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
488
+ }
489
+ })
490
+
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
499
+
500
+ try {
314
501
  await expect(
315
- withGhRetry(
316
- 'test label',
317
- async () => {
318
- throw new Error('API rate limit exceeded for url')
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',
319
509
  },
320
- false,
321
- queryReset,
510
+ 'https://cliproxy.fro.bot',
322
511
  ),
323
- ).rejects.toThrow('resets at 2:30 PM')
324
- })
512
+ ).rejects.toThrow(/--force/)
513
+ } finally {
514
+ globalThis.fetch = originalFetch
515
+ }
325
516
  })
517
+ })
326
518
 
327
- describe('help output', () => {
328
- it('shows --key, --repo, and --harness flags', () => {
329
- const cli = goke('infra')
330
- registerCliproxySetup(cli)
331
- cli.help()
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'
332
522
 
333
- const helpText = cli.helpText()
523
+ let originalFetch: typeof globalThis.fetch
524
+ afterEach(() => {
525
+ globalThis.fetch = originalFetch
526
+ })
527
+ originalFetch = globalThis.fetch
334
528
 
335
- expect(helpText).toContain('cliproxy setup')
336
- expect(helpText).toContain('--key [key]')
337
- expect(helpText).toContain('--repo [repo]')
338
- expect(helpText).toContain('--harness [harness]')
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
532
+
533
+ let errorMessage = ''
534
+ try {
535
+ await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
536
+ } catch (error) {
537
+ errorMessage = error instanceof Error ? error.message : String(error)
538
+ }
539
+
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')
544
+ })
545
+
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
549
+
550
+ let errorMessage = ''
551
+ try {
552
+ await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
553
+ } catch (error) {
554
+ errorMessage = error instanceof Error ? error.message : String(error)
555
+ }
556
+
557
+ expect(errorMessage).toContain('500')
558
+ expect(errorMessage).toContain('<redacted>')
559
+ expect(errorMessage).not.toContain('sk-abc123def456')
560
+ })
561
+
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
565
+
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)
571
+ }
572
+
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)
577
+ })
578
+ })
579
+
580
+ /* eslint-disable @typescript-eslint/no-explicit-any -- spyOn mock return values require `any` casts */
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)', () => {
596
+ const BASE_URL = 'https://cliproxy.fro.bot'
597
+
598
+ let originalFetch: typeof globalThis.fetch
599
+ let spawnSpy: ReturnType<typeof spyOn> | undefined
600
+
601
+ afterEach(() => {
602
+ globalThis.fetch = originalFetch
603
+ spawnSpy?.mockRestore()
604
+ spawnSpy = undefined
605
+ })
606
+ originalFetch = globalThis.fetch
607
+
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')
339
612
  })
613
+
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
+ })
619
+
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')
624
+ })
625
+ globalThis.fetch = fetchMock as unknown as typeof fetch
626
+
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
+ })
633
+
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
+ })
638
+
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
+ })
646
+
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
+ })
650
+
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
+ })
656
+
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
665
+
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)
679
+ })
680
+ })
681
+ /* eslint-enable @typescript-eslint/no-explicit-any */
682
+
683
+ // ── runSetupCommand DI boundary tests ─────────────────────────────────
684
+
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
+ }
711
+
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
+ }
724
+
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
+ }
730
+
731
+ describe('runSetupCommand action handler', () => {
732
+ const BASE_URL = 'https://cliproxy.fro.bot'
733
+ const KEY = 'sk-test-key'
734
+
735
+ // ── dry-run testability hardening ──────────────────────────────────────────────
736
+
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()),
758
+ },
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
+ })
776
+
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
+ })
816
+
817
+ // ── destructive-overwrite throw-text behaviors ───────────────────────────────────────────────
818
+
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
824
+
825
+ try {
826
+ await expect(
827
+ runSetupCommand(
828
+ {
829
+ key: KEY,
830
+ repo: 'owner/repo',
831
+ harness: 'opencode',
832
+ providers: 'openai',
833
+ model: 'openai/gpt-5.4-mini',
834
+ force: false,
835
+ },
836
+ {
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
+ },
863
+ },
864
+ ),
865
+ ).rejects.toThrow(/--force/)
866
+ } finally {
867
+ globalThis.fetch = originalFetch
868
+ }
869
+ })
870
+
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
876
+
877
+ let errorMessage = ''
878
+ try {
879
+ await runSetupCommand(
880
+ {
881
+ key: KEY,
882
+ repo: 'owner/repo',
883
+ harness: 'opencode',
884
+ providers: 'openai',
885
+ model: 'openai/gpt-5.4-mini',
886
+ force: false,
887
+ },
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()),
901
+ },
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
+ }
922
+
923
+ expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
924
+ })
925
+
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
931
+
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
+ }
978
+
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')
982
+ })
983
+
984
+ // ── smoke-test stdout line ─────────────────────────────────────────────────────
985
+
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
991
+
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
+ }
1041
+
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')
1045
+ })
1046
+
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
1052
+
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
+ }
1102
+
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')
1106
+ })
1107
+
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
1113
+
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
+ }
1163
+
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')
1167
+ })
1168
+
1169
+ // ── key-reuse acknowledgment tests ────────────────────────────────────────────────
1170
+
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()),
1202
+ },
1203
+ prompts: {
1204
+ promptValue: autoPromptValue,
1205
+ confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
1206
+ intro: () => {},
1207
+ note: () => {},
1208
+ outro: () => {},
1209
+ },
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 () => {},
1215
+ },
1216
+ },
1217
+ ),
1218
+ ).rejects.toThrow(/Refusing key-reuse without explicit acknowledgment/)
1219
+ } finally {
1220
+ globalThis.fetch = originalFetch
1221
+ }
1222
+ })
1223
+
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
1229
+
1230
+ try {
1231
+ await expect(
1232
+ runSetupCommand(
1233
+ {
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,
1241
+ },
1242
+ {
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
+ },
1269
+ },
1270
+ ),
1271
+ ).resolves.toBeUndefined()
1272
+ } finally {
1273
+ globalThis.fetch = originalFetch
1274
+ }
1275
+ })
1276
+
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
1282
+
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
+ }
1329
+ })
1330
+
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()
1374
+ })
1375
+
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
1425
+
1426
+ let confirmMessage = ''
1427
+ const captureConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
1428
+ confirmMessage = opts.message
1429
+ return Promise.resolve(true)
1430
+ }
1431
+
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
+ }
1438
+
1439
+ try {
1440
+ await runSetupCommand(
1441
+ {
1442
+ key: PLAINTEXT_KEY,
1443
+ repo: 'owner/repo',
1444
+ harness: 'opencode',
1445
+ providers: 'openai',
1446
+ model: 'openai/gpt-5.4-mini',
1447
+ force: true,
1448
+ },
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')
1491
+ })
1492
+
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'}]}
1496
+ const originalFetch = globalThis.fetch
1497
+ globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
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
+
1515
+ try {
1516
+ await runSetupCommand(
1517
+ {
1518
+ key: KEY,
1519
+ repo: 'owner/repo',
1520
+ harness: 'opencode',
1521
+ providers: 'openai',
1522
+ model: 'openai/gpt-5.4-mini',
1523
+ force: true,
1524
+ },
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
+ },
1556
+ )
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')
1562
+ } finally {
1563
+ process.exit = originalExit
1564
+ globalThis.fetch = originalFetch
1565
+ }
1566
+
1567
+ expect(exitCode).toBe(0)
1568
+ expect(applyGhValueCalled).toBe(false)
1569
+ })
1570
+
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(' '))
1581
+ }
1582
+
1583
+ try {
1584
+ await expect(
1585
+ runSetupCommand(
1586
+ {
1587
+ key: KEY,
1588
+ repo: 'owner/repo',
1589
+ harness: 'opencode',
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
+ },
1621
+ },
1622
+ ),
1623
+ ).rejects.toThrow('gh-not-installed-marker')
1624
+ } finally {
1625
+ console.error = originalConsoleError
1626
+ }
1627
+
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)
1632
+ })
1633
+
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()
1638
+
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')
1679
+
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)
1683
+ })
1684
+
1685
+ // ── Rollback regression tests ─────────────────────
1686
+
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.
1696
+ try {
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
1743
+ }
1744
+
1745
+ // deleteManagementApiKey must have been called (rollback happened)
1746
+ expect(deleteCalledWith).toBeDefined()
1747
+ expect(applyCallCount).toBeGreaterThan(0)
1748
+ })
1749
+
1750
+ it('Rollback: assertProxyKeyWorks throws → deleteManagementApiKey called before error propagates', async () => {
1751
+ const {ctx} = makeCtx()
1752
+
1753
+ let deleteCalledWith: string | undefined
1754
+
1755
+ try {
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
1799
+ }
1800
+
1801
+ // deleteManagementApiKey must have been called (rollback happened)
1802
+ expect(deleteCalledWith).toBeDefined()
1803
+ })
1804
+
1805
+ // ── F5: --dry-run with no --repo/--harness ─────────────────────────────────
1806
+
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
+ })
1814
+
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)
1837
+ })
1838
+
1839
+ // ── F8: Rollback event-order assertions ────────────────────────────────────
1840
+
1841
+ it('F8: applyGhValue fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1842
+ const {ctx} = makeCtx()
1843
+ const events: string[] = []
1844
+
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')
1886
+
1887
+ expect(events).toEqual(['create', 'apply-fail', 'delete'])
1888
+ })
1889
+
1890
+ it('F8: assertProxyKeyWorks fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1891
+ const {ctx} = makeCtx()
1892
+ const events: string[] = []
1893
+
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')
1937
+
1938
+ expect(events).toEqual(['create', 'apply-success', 'verify-fail', 'delete'])
1939
+ })
1940
+
1941
+ // ── F9: --force pre-gate fires before verifyModelsAvailable ────────────────
1942
+
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
1950
+
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)
340
1960
  })
341
1961
  })