@marcusrbrown/infra 0.7.0 → 0.8.1

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