@marcusrbrown/infra 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -1,9 +1,23 @@
1
1
  /// <reference types="bun" />
2
2
 
3
+ import type {MultiSelectOptions, TextOptions} from '@clack/prompts'
3
4
  import {describe, expect, it, spyOn} from 'bun:test'
4
5
 
5
6
  import {parseProviders, promptForModel, promptForProviders} from './providers'
6
7
 
8
+ // Type helper: cast a concrete-typed clack implementation to the generic spy type.
9
+ // clack's multiselect/text/select are generic functions; Bun's spyOn preserves the
10
+ // generic signature, so mockImplementation requires the same generic. We provide a
11
+ // concrete instantiation and widen through `unknown` — this is safe because the
12
+ // concrete type is a structural subtype of the generic at the call site.
13
+ function asMultiselectImpl<V>(fn: (opts: MultiSelectOptions<V>) => Promise<V[] | symbol>) {
14
+ return fn as unknown as <Value>(opts: MultiSelectOptions<Value>) => Promise<Value[] | symbol>
15
+ }
16
+
17
+ function asTextImpl(fn: (opts: TextOptions) => Promise<string | symbol>) {
18
+ return fn as unknown as (opts: TextOptions) => Promise<string | symbol>
19
+ }
20
+
7
21
  describe('option parsing', () => {
8
22
  describe('parseProviders', () => {
9
23
  it("parses \"anthropic,openai\" to ['anthropic', 'openai']", () => {
@@ -40,7 +54,6 @@ describe('option parsing', () => {
40
54
  })
41
55
  })
42
56
 
43
- /* eslint-disable @typescript-eslint/no-explicit-any -- spyOn mock return values require `any` casts */
44
57
  describe('interactive provider/model prompts', () => {
45
58
  // We spy on @clack/prompts functions directly since Bun's mock.module
46
59
  // requires static hoisting. Instead we use spyOn on the imported module.
@@ -60,7 +73,8 @@ describe('interactive provider/model prompts', () => {
60
73
  describe('promptForProviders', () => {
61
74
  it('happy path: anthropic-only selection returns [anthropic]', async () => {
62
75
  const clack = await import('@clack/prompts')
63
- const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic'] as any)
76
+ // multiselect<Value> returns Promise<Value[] | symbol>; resolved value is string[] | symbol
77
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic'])
64
78
 
65
79
  const result = await promptForProviders()
66
80
 
@@ -72,7 +86,7 @@ describe('interactive provider/model prompts', () => {
72
86
 
73
87
  it('happy path: both providers selected returns [anthropic, openai]', async () => {
74
88
  const clack = await import('@clack/prompts')
75
- const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic', 'openai'] as any)
89
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(['anthropic', 'openai'])
76
90
 
77
91
  const result = await promptForProviders()
78
92
 
@@ -84,11 +98,13 @@ describe('interactive provider/model prompts', () => {
84
98
  it('edge case: empty selection re-prompts; multiselect called exactly twice', async () => {
85
99
  const clack = await import('@clack/prompts')
86
100
  let callCount = 0
87
- const multiselectSpy = spyOn(clack, 'multiselect').mockImplementation(async () => {
88
- callCount++
89
- if (callCount === 1) return [] as any
90
- return ['anthropic'] as any
91
- })
101
+ const multiselectSpy = spyOn(clack, 'multiselect').mockImplementation(
102
+ asMultiselectImpl<string>(async () => {
103
+ callCount++
104
+ if (callCount === 1) return []
105
+ return ['anthropic']
106
+ }),
107
+ )
92
108
 
93
109
  const result = await promptForProviders()
94
110
 
@@ -101,12 +117,12 @@ describe('interactive provider/model prompts', () => {
101
117
  it('edge case: cancel mid-flow causes process.exit(0)', async () => {
102
118
  const clack = await import('@clack/prompts')
103
119
  const cancelSymbol = Symbol('cancel')
104
- const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(cancelSymbol as any)
120
+ const multiselectSpy = spyOn(clack, 'multiselect').mockResolvedValue(cancelSymbol)
105
121
  const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
106
122
  const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
107
- const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
123
+ const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
108
124
  throw new Error('process.exit called')
109
- }) as any)
125
+ })
110
126
 
111
127
  await expect(promptForProviders()).rejects.toThrow('process.exit called')
112
128
 
@@ -144,7 +160,7 @@ describe('interactive provider/model prompts', () => {
144
160
 
145
161
  it('happy path: both providers, operator picks openai/gpt-5.4-mini from select', async () => {
146
162
  const clack = await import('@clack/prompts')
147
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('openai/gpt-5.4-mini' as any)
163
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('openai/gpt-5.4-mini')
148
164
 
149
165
  const result = await promptForModel(['anthropic', 'openai'])
150
166
 
@@ -156,7 +172,7 @@ describe('interactive provider/model prompts', () => {
156
172
 
157
173
  it('happy path: both providers, operator picks anthropic/claude-sonnet-4-6 from select', async () => {
158
174
  const clack = await import('@clack/prompts')
159
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('anthropic/claude-sonnet-4-6' as any)
175
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('anthropic/claude-sonnet-4-6')
160
176
 
161
177
  const result = await promptForModel(['anthropic', 'openai'])
162
178
 
@@ -167,8 +183,8 @@ describe('interactive provider/model prompts', () => {
167
183
 
168
184
  it('happy path: operator picks "enter custom..." then types openai/gpt-5.4-mini', async () => {
169
185
  const clack = await import('@clack/prompts')
170
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__' as any)
171
- const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini' as any)
186
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
187
+ const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini')
172
188
 
173
189
  const result = await promptForModel(['anthropic', 'openai'])
174
190
 
@@ -181,21 +197,23 @@ describe('interactive provider/model prompts', () => {
181
197
 
182
198
  it('edge case: custom model entry fails regex then succeeds on second attempt', async () => {
183
199
  const clack = await import('@clack/prompts')
184
- const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__' as any)
200
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
185
201
  let textCallCount = 0
186
- const textSpy = spyOn(clack, 'text').mockImplementation(async (_opts: any) => {
187
- textCallCount++
188
- // Simulate the validate function being called inline by the mock
189
- // The real clack text prompt calls validate internally; here we just
190
- // return the value and let the helper's validate logic re-prompt.
191
- // Since we can't simulate clack's internal validate loop, we test
192
- // that the helper's validate function rejects bad input.
193
- if (textCallCount === 1) {
194
- // Return a bad value — the helper should detect this and re-prompt
195
- return 'bad-model' as any
196
- }
197
- return 'openai/gpt-5.4-mini' as any
198
- })
202
+ const textSpy = spyOn(clack, 'text').mockImplementation(
203
+ asTextImpl(async () => {
204
+ textCallCount++
205
+ // Simulate the validate function being called inline by the mock
206
+ // The real clack text prompt calls validate internally; here we just
207
+ // return the value and let the helper's validate logic re-prompt.
208
+ // Since we can't simulate clack's internal validate loop, we test
209
+ // that the helper's validate function rejects bad input.
210
+ if (textCallCount === 1) {
211
+ // Return a bad value — the helper should detect this and re-prompt
212
+ return 'bad-model'
213
+ }
214
+ return 'openai/gpt-5.4-mini'
215
+ }),
216
+ )
199
217
 
200
218
  const result = await promptForModel(['anthropic', 'openai'])
201
219
 
@@ -209,12 +227,12 @@ describe('interactive provider/model prompts', () => {
209
227
  it('edge case: cancel during model select causes process.exit(0)', async () => {
210
228
  const clack = await import('@clack/prompts')
211
229
  const cancelSymbol = Symbol('cancel')
212
- const selectSpy = spyOn(clack, 'select').mockResolvedValue(cancelSymbol as any)
230
+ const selectSpy = spyOn(clack, 'select').mockResolvedValue(cancelSymbol)
213
231
  const isCancelSpy = spyOn(clack, 'isCancel').mockImplementation(v => v === cancelSymbol)
214
232
  const cancelSpy = spyOn(clack, 'cancel').mockImplementation(() => {})
215
- const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
233
+ const exitSpy = spyOn(process, 'exit').mockImplementation((_code?: number): never => {
216
234
  throw new Error('process.exit called')
217
- }) as any)
235
+ })
218
236
 
219
237
  await expect(promptForModel(['anthropic', 'openai'])).rejects.toThrow('process.exit called')
220
238
 
@@ -225,4 +243,3 @@ describe('interactive provider/model prompts', () => {
225
243
  })
226
244
  })
227
245
  })
228
- /* eslint-enable @typescript-eslint/no-explicit-any */
@@ -640,4 +640,182 @@ describe('smoke test runner', () => {
640
640
  expect(result.kind).toBe('pass')
641
641
  expect(result.runUrl).toBe('https://github.com/owner/test-repo/actions/runs/105')
642
642
  })
643
+
644
+ // ── Zod schema validation hardening ──────────────────────────────────────
645
+
646
+ it('poll JSON validation — non-array response degrades to unverified without throwing', async () => {
647
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
648
+
649
+ let callIndex = 0
650
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
651
+ callIndex++
652
+ if (callIndex === 1) {
653
+ // baseline: valid empty list
654
+ return makeSmokeChild('[]', '', 0)
655
+ }
656
+ if (callIndex === 2) {
657
+ // trigger succeeds
658
+ return makeSmokeChild('', '', 0)
659
+ }
660
+ // poll returns a non-array object instead of an array — schema rejects it
661
+ return makeSmokeChild('{"error":"unexpected"}', '', 0)
662
+ })
663
+
664
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
665
+
666
+ // Schema validation fails → pollRuns stays [] → no candidates → all polls exhaust → unverified
667
+ expect(result.kind).toBe('unverified')
668
+ })
669
+
670
+ it('poll JSON validation — missing required databaseId field degrades gracefully', async () => {
671
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
672
+
673
+ let callIndex = 0
674
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
675
+ callIndex++
676
+ if (callIndex === 1) {
677
+ return makeSmokeChild('[]', '', 0)
678
+ }
679
+ if (callIndex === 2) {
680
+ return makeSmokeChild('', '', 0)
681
+ }
682
+ // poll returns array entries missing databaseId — schema rejects it
683
+ return makeSmokeChild(
684
+ '[{"status":"completed","conclusion":"success","url":"https://x","createdAt":"2026-05-25T10:00:05Z"}]',
685
+ '',
686
+ 0,
687
+ )
688
+ })
689
+
690
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
691
+
692
+ expect(result.kind).toBe('unverified')
693
+ })
694
+
695
+ it('poll JSON validation — wrong type for databaseId (string instead of number) degrades gracefully', async () => {
696
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
697
+
698
+ let callIndex = 0
699
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
700
+ callIndex++
701
+ if (callIndex === 1) {
702
+ return makeSmokeChild('[]', '', 0)
703
+ }
704
+ if (callIndex === 2) {
705
+ return makeSmokeChild('', '', 0)
706
+ }
707
+ // databaseId is a string, not a number — schema rejects it
708
+ return makeSmokeChild(
709
+ '[{"databaseId":"not-a-number","status":"completed","conclusion":"success","url":"https://x","createdAt":"2026-05-25T10:00:05Z"}]',
710
+ '',
711
+ 0,
712
+ )
713
+ })
714
+
715
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
716
+
717
+ expect(result.kind).toBe('unverified')
718
+ })
719
+
720
+ it('poll JSON validation — one malformed entry does not discard a valid matching run', async () => {
721
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
722
+ const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
723
+
724
+ let callIndex = 0
725
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
726
+ callIndex++
727
+ if (callIndex === 1) {
728
+ // baseline: valid empty list → createdAt heuristic
729
+ return makeSmokeChild('[]', '', 0)
730
+ }
731
+ if (callIndex === 2) {
732
+ // trigger succeeds
733
+ return makeSmokeChild('', '', 0)
734
+ }
735
+ if (callIndex === 3) {
736
+ // poll: one malformed entry (databaseId is a string) PLUS one valid matching
737
+ // completed-success run. Per-entry validation must drop only the bad row and
738
+ // keep the good one — whole-batch rejection would lose our run.
739
+ return makeSmokeChild(
740
+ `[{"databaseId":"bad","status":"completed","conclusion":"success","url":"https://x","createdAt":"${
741
+ createdAt
742
+ }"},{"databaseId":105,"status":"completed","conclusion":"success","url":"${RUN_URL}","createdAt":"${
743
+ createdAt
744
+ }"}]`,
745
+ '',
746
+ 0,
747
+ )
748
+ }
749
+ // log view for the matched run → contains "ack"
750
+ return makeSmokeChild('some log output with ack in it', '', 0)
751
+ })
752
+
753
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
754
+
755
+ // The valid entry survives per-entry validation and is matched → pass.
756
+ expect(result.kind).toBe('pass')
757
+ expect(result.runUrl).toBe(RUN_URL)
758
+ })
759
+
760
+ it('baseline JSON validation — non-array baseline falls back to createdAt heuristic', async () => {
761
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
762
+ const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
763
+
764
+ let callIndex = 0
765
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
766
+ callIndex++
767
+ if (callIndex === 1) {
768
+ // baseline returns a non-array object — schema rejects it, baselineId stays null
769
+ return makeSmokeChild('{"databaseId":100}', '', 0)
770
+ }
771
+ if (callIndex === 2) {
772
+ return makeSmokeChild('', '', 0)
773
+ }
774
+ if (callIndex === 3) {
775
+ return makeSmokeChild(
776
+ makeSmokeRunList([{databaseId: 1, status: 'completed', conclusion: 'success', url: RUN_URL, createdAt}]),
777
+ '',
778
+ 0,
779
+ )
780
+ }
781
+ return makeSmokeChild('ack', '', 0)
782
+ })
783
+
784
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
785
+
786
+ // Schema rejects non-array → baselineId stays null → createdAt heuristic → run found → pass
787
+ expect(result.kind).toBe('pass')
788
+ expect(result.runUrl).toBe(RUN_URL)
789
+ })
790
+
791
+ it('baseline JSON validation — missing databaseId in baseline entry falls back to createdAt heuristic', async () => {
792
+ const triggerTime = new Date('2026-05-25T10:00:00Z')
793
+ const createdAt = new Date(triggerTime.getTime() + 5000).toISOString()
794
+
795
+ let callIndex = 0
796
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: unknown[]) => {
797
+ callIndex++
798
+ if (callIndex === 1) {
799
+ // baseline entry missing databaseId — schema rejects it, baselineId stays null
800
+ return makeSmokeChild('[{"id":100}]', '', 0)
801
+ }
802
+ if (callIndex === 2) {
803
+ return makeSmokeChild('', '', 0)
804
+ }
805
+ if (callIndex === 3) {
806
+ return makeSmokeChild(
807
+ makeSmokeRunList([{databaseId: 1, status: 'completed', conclusion: 'success', url: RUN_URL, createdAt}]),
808
+ '',
809
+ 0,
810
+ )
811
+ }
812
+ return makeSmokeChild('ack', '', 0)
813
+ })
814
+
815
+ const result = await runSmokeTest(REPO, MODEL, {_testDelayMs: 0, _testTriggerTime: triggerTime})
816
+
817
+ // Schema rejects missing databaseId → baselineId stays null → createdAt heuristic → run found → pass
818
+ expect(result.kind).toBe('pass')
819
+ expect(result.runUrl).toBe(RUN_URL)
820
+ })
643
821
  })
@@ -1,18 +1,25 @@
1
1
  /// <reference types="bun" />
2
2
 
3
+ import {z} from 'zod'
4
+
3
5
  export type SmokeResult =
4
6
  | {kind: 'pass'; message: string; runUrl: string}
5
7
  | {kind: 'fail'; message: string; runUrl: string}
6
8
  | {kind: 'unverified'; message: string; runUrl?: string}
7
9
 
10
+ // Zod schemas for gh CLI JSON output — single source of truth.
11
+ const baselineRunSchema = z.array(z.object({databaseId: z.number()}))
12
+
13
+ const ghRunEntrySchema = z.object({
14
+ databaseId: z.number(),
15
+ status: z.string(),
16
+ conclusion: z.string().nullable(),
17
+ url: z.string(),
18
+ createdAt: z.string(),
19
+ })
20
+
8
21
  // Exported for tests only.
9
- export interface GhRunEntry {
10
- databaseId: number
11
- status: string
12
- conclusion: string | null
13
- url: string
14
- createdAt: string
15
- }
22
+ export type GhRunEntry = z.infer<typeof ghRunEntrySchema>
16
23
 
17
24
  // Exported for tests only. Override poll delays and trigger time.
18
25
  export interface SmokeTestInternals {
@@ -66,10 +73,11 @@ export async function runSmokeTest(
66
73
  baselineChild.exited,
67
74
  ])
68
75
  if (baselineExit === 0) {
69
- const parsed = JSON.parse(baselineStdout) as {databaseId: number}[]
70
- if (parsed.length > 0 && parsed[0]) {
71
- baselineId = parsed[0].databaseId
76
+ const parseResult = baselineRunSchema.safeParse(JSON.parse(baselineStdout))
77
+ if (parseResult.success && parseResult.data.length > 0 && parseResult.data[0]) {
78
+ baselineId = parseResult.data[0].databaseId
72
79
  }
80
+ // If schema validation fails, baselineId stays null — we'll use createdAt heuristic
73
81
  }
74
82
  // If baseline call fails, baselineId stays null — we'll use createdAt heuristic
75
83
  } catch {
@@ -123,7 +131,17 @@ export async function runSmokeTest(
123
131
  pollChild.exited,
124
132
  ])
125
133
  if (pollExit === 0) {
126
- pollRuns = JSON.parse(pollStdout) as GhRunEntry[]
134
+ const rawParsed: unknown = JSON.parse(pollStdout)
135
+ if (Array.isArray(rawParsed)) {
136
+ // Validate each entry independently so a single malformed row does not
137
+ // discard the whole batch — dropping a legitimate matching run would be
138
+ // worse than skipping the bad entry.
139
+ pollRuns = rawParsed.flatMap(entry => {
140
+ const entryResult = ghRunEntrySchema.safeParse(entry)
141
+ return entryResult.success ? [entryResult.data] : []
142
+ })
143
+ }
144
+ // Non-array payload or all entries malformed → pollRuns stays [] — retry on next poll
127
145
  }
128
146
  } catch {
129
147
  // Parse/network error — retry on next poll
@@ -1,9 +1,10 @@
1
1
  /// <reference types="bun" />
2
2
 
3
3
  import type {SpinnerResult} from '@clack/prompts'
4
- import {afterEach, describe, expect, it, mock, spyOn} from 'bun:test'
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'
5
7
  import {goke} from 'goke'
6
-
7
8
  import {
8
9
  buildNonInteractivePlan,
9
10
  redactKey,
@@ -395,9 +396,9 @@ describe('destructive overwrite UX', () => {
395
396
  })
396
397
 
397
398
  // ── Smoke test runner tests moved to setup/smoke-test.test.ts ─────────────────
398
- // ── P1 regression tests ───────────────────────────────────────────────────────
399
+ // ── regression tests ────────────────────────────────────────────────────────────────────────────────────────────────────────────────
399
400
 
400
- describe('P1 #1 regression — dry-run early return before mutations', () => {
401
+ describe('dry-run early return before mutations', () => {
401
402
  const BASE_URL = 'https://cliproxy.fro.bot'
402
403
  const KEY = 'sk-test-key'
403
404
 
@@ -444,7 +445,7 @@ describe('P1 #1 regression — dry-run early return before mutations', () => {
444
445
  })
445
446
  })
446
447
 
447
- describe('P1 #2 regression — --force honored by non-interactive collision gate', () => {
448
+ describe('--force honored by non-interactive collision gate', () => {
448
449
  // The collision gate lives in runSetupCommand (not exported), so we test the
449
450
  // surrounding logic: buildNonInteractivePlan succeeds with --force, and the
450
451
  // collision gate behavior is verified via the error message shape.
@@ -516,7 +517,7 @@ describe('P1 #2 regression — --force honored by non-interactive collision gate
516
517
  })
517
518
  })
518
519
 
519
- describe('safe_auto #2 regression — /v1/models body Bearer token redaction', () => {
520
+ describe('/v1/models body Bearer token redaction', () => {
520
521
  const BASE_URL = 'https://cliproxy.fro.bot'
521
522
  const KEY = 'sk-test-key'
522
523
 
@@ -577,8 +578,6 @@ describe('safe_auto #2 regression — /v1/models body Bearer token redaction', (
577
578
  })
578
579
  })
579
580
 
580
- /* eslint-disable @typescript-eslint/no-explicit-any -- spyOn mock return values require `any` casts */
581
-
582
581
  // Fix 3 — dry-run isolation regression tests
583
582
  //
584
583
  // The action handler in registerCliproxySetup is not exported, so we test the
@@ -607,9 +606,9 @@ describe('cliproxy setup --dry-run is offline-safe (action handler contract)', (
607
606
 
608
607
  it('dry-run skips gh auth check — Bun.spawn not called during buildNonInteractivePlan', async () => {
609
608
  // Spy Bun.spawn to fail hard if called (simulates unauthenticated environment)
610
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: any[]) => {
609
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(((_cmds: string[]) => {
611
610
  throw new Error('gh auth status called during dry-run — should be skipped')
612
- })
611
+ }) as unknown as typeof Bun.spawn)
613
612
 
614
613
  // Should complete without throwing (dry-run early return in buildNonInteractivePlan)
615
614
  const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
@@ -678,7 +677,6 @@ describe('cliproxy setup --dry-run is offline-safe (action handler contract)', (
678
677
  expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
679
678
  })
680
679
  })
681
- /* eslint-enable @typescript-eslint/no-explicit-any */
682
680
 
683
681
  // ── runSetupCommand DI boundary tests ─────────────────────────────────
684
682
 
@@ -1373,14 +1371,7 @@ describe('runSetupCommand action handler', () => {
1373
1371
  ).resolves.toBeUndefined()
1374
1372
  })
1375
1373
 
1376
- // ── Interactive R8 ack-key-reuse prompt: redaction + cancel/continue ──────────
1377
- //
1378
- // Note: full interactive integration tests are limited by the F16 (issue #311) gap —
1379
- // buildInteractivePlan calls real @clack/prompts.text() for the key-name and harness
1380
- // prompts that DI doesn't cover yet. We test the redaction contract directly via
1381
- // the exported redactKey helper, then a unit test confirms the prompt template uses
1382
- // the redacted form. Interactive cancel/continue paths are exercised under F16 once
1383
- // RunSetupDeps covers all prompt sites.
1374
+ // ── Interactive key-reuse confirm prompt: redaction + cancel/continue ──────────
1384
1375
 
1385
1376
  it('redactKey: keys >= 12 chars use first-3 + *** + last-4 shape', () => {
1386
1377
  expect(redactKey('sk-PLAINTEXT-LONGKEY')).toBe('sk-***GKEY')
@@ -1400,8 +1391,8 @@ describe('runSetupCommand action handler', () => {
1400
1391
  expect(redacted.length).toBeLessThan(RAW.length)
1401
1392
  })
1402
1393
 
1403
- it('Interactive R8 prompt template uses redactKey output, never the raw key (source-level contract)', async () => {
1404
- // Read the setup.ts source and assert the R8 prompt-message template uses ${redactKey(options.key)}
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)}
1405
1396
  // and never `${options.key}` raw. This is a source-level guard so a future refactor that
1406
1397
  // accidentally drops the redaction call fails the test even if integration coverage lags.
1407
1398
  const source = await Bun.file(new URL('./setup.ts', import.meta.url).pathname).text()
@@ -1412,25 +1403,21 @@ describe('runSetupCommand action handler', () => {
1412
1403
  expect(promptContext).not.toMatch(/--key \$\{options\.key\}/)
1413
1404
  })
1414
1405
 
1415
- // The two interactive R8 integration tests below are skipped pending F16 (issue #311):
1416
- // RunSetupDeps must cover the buildInteractivePlan prompt sites before the interactive
1417
- // path can be exercised end-to-end with deps mocks alone.
1418
-
1419
- it.skip('Interactive R8: confirm prompt fires with redacted key (not raw token)', async () => {
1406
+ it('interactive key-reuse confirm shows a redacted key, never the raw token', async () => {
1420
1407
  const {ctx} = makeCtx()
1421
1408
  const PLAINTEXT_KEY = 'sk-PLAINTEXT-LONGKEY-SHOULD-NOT-LEAK'
1422
1409
  const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1423
1410
  const originalFetch = globalThis.fetch
1424
1411
  globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1425
1412
 
1426
- let confirmMessage = ''
1413
+ const confirmMessages: string[] = []
1427
1414
  const captureConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
1428
- confirmMessage = opts.message
1415
+ confirmMessages.push(opts.message)
1429
1416
  return Promise.resolve(true)
1430
1417
  }
1431
1418
 
1432
1419
  // Interactive mode resolves promptValue to the awaited prompt result. Our captureConfirm
1433
- // returns true, so the wizard proceeds past the R8 gate. We assert on the captured message.
1420
+ // returns true, so the wizard proceeds past the key-reuse gate. We assert on the captured message.
1434
1421
  const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
1435
1422
  const result = await prompt
1436
1423
  return result as T
@@ -1466,6 +1453,8 @@ describe('runSetupCommand action handler', () => {
1466
1453
  intro: () => {},
1467
1454
  note: () => {},
1468
1455
  outro: () => {},
1456
+ promptForProviders: (): Promise<ProviderId[]> => Promise.resolve(['openai']),
1457
+ promptForModel: (_providers: ProviderId[]): Promise<string> => Promise.resolve('openai/gpt-5.4-mini'),
1469
1458
  },
1470
1459
  smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1471
1460
  validation: {
@@ -1479,18 +1468,19 @@ describe('runSetupCommand action handler', () => {
1479
1468
  globalThis.fetch = originalFetch
1480
1469
  }
1481
1470
 
1482
- // The R8 confirm prompt must be the one captured (interactive flow has more than one confirm
1483
- // in some paths; we look for the one containing the verify-bearer language).
1484
- expect(confirmMessage).toContain('Verify it matches the bearer token')
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()
1485
1475
  // The raw key must NEVER appear in the prompt text — security regression guard.
1486
- expect(confirmMessage).not.toContain(PLAINTEXT_KEY)
1476
+ expect(keyReuseMessage).not.toContain(PLAINTEXT_KEY)
1487
1477
  // Redacted form must be present (first 3 + last 4 chars per redactKey helper).
1488
- expect(confirmMessage).toContain('sk-')
1489
- expect(confirmMessage).toContain('***')
1490
- expect(confirmMessage).toContain('LEAK')
1478
+ expect(keyReuseMessage).toContain('sk-')
1479
+ expect(keyReuseMessage).toContain('***')
1480
+ expect(keyReuseMessage).toContain('LEAK')
1491
1481
  })
1492
1482
 
1493
- it.skip('Interactive R8: confirm returns false cancelAndExit invoked, applyGhValue never called', async () => {
1483
+ it('interactive key-reuse confirm returning false cancels before any GitHub write', async () => {
1494
1484
  const {ctx} = makeCtx()
1495
1485
  const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1496
1486
  const originalFetch = globalThis.fetch
@@ -1499,6 +1489,17 @@ describe('runSetupCommand action handler', () => {
1499
1489
  let applyGhValueCalled = false
1500
1490
  let exitCode: number | undefined
1501
1491
 
1492
+ // The interactive flow shows a generic "Proceed?" confirm BEFORE the key-reuse
1493
+ // gate. Approve the generic prompt so the run actually reaches the key-reuse
1494
+ // confirm, then reject only that one — otherwise the test would cancel at the
1495
+ // first prompt and never exercise the gate it claims to cover.
1496
+ const confirmMessages: string[] = []
1497
+ const messageAwareConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
1498
+ confirmMessages.push(opts.message)
1499
+ const isKeyReusePrompt = opts.message.includes('Verify it matches the bearer token')
1500
+ return Promise.resolve(!isKeyReusePrompt)
1501
+ }
1502
+
1502
1503
  const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
1503
1504
  const result = await prompt
1504
1505
  return result as T
@@ -1540,11 +1541,13 @@ describe('runSetupCommand action handler', () => {
1540
1541
  },
1541
1542
  prompts: {
1542
1543
  promptValue: interactivePromptValue,
1543
- // User rejects the R8 confirmation cancelAndExit fires.
1544
- confirm: () => Promise.resolve(false) as Promise<boolean | symbol>,
1544
+ // Approve the generic proceed confirm, reject only the key-reuse confirm.
1545
+ confirm: messageAwareConfirm,
1545
1546
  intro: () => {},
1546
1547
  note: () => {},
1547
1548
  outro: () => {},
1549
+ promptForProviders: (): Promise<ProviderId[]> => Promise.resolve(['openai']),
1550
+ promptForModel: (_providers: ProviderId[]): Promise<string> => Promise.resolve('openai/gpt-5.4-mini'),
1548
1551
  },
1549
1552
  smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1550
1553
  validation: {
@@ -1555,7 +1558,7 @@ describe('runSetupCommand action handler', () => {
1555
1558
  },
1556
1559
  )
1557
1560
  // If we get here, cancelAndExit didn't fire. Fail the test.
1558
- throw new Error('expected cancelAndExit to fire on R8 reject')
1561
+ throw new Error('expected cancelAndExit to fire on key-reuse reject')
1559
1562
  } catch (error) {
1560
1563
  // cancelAndExit throws because we stubbed process.exit to throw.
1561
1564
  expect(error instanceof Error && error.message).toBe('process.exit-stubbed')
@@ -1564,6 +1567,9 @@ describe('runSetupCommand action handler', () => {
1564
1567
  globalThis.fetch = originalFetch
1565
1568
  }
1566
1569
 
1570
+ // Guard against a vacuous pass: the run must have actually reached the
1571
+ // key-reuse confirm, not cancelled at the earlier generic proceed prompt.
1572
+ expect(confirmMessages.some(m => m.includes('Verify it matches the bearer token'))).toBe(true)
1567
1573
  expect(exitCode).toBe(0)
1568
1574
  expect(applyGhValueCalled).toBe(false)
1569
1575
  })
@@ -1802,9 +1808,9 @@ describe('runSetupCommand action handler', () => {
1802
1808
  expect(deleteCalledWith).toBeDefined()
1803
1809
  })
1804
1810
 
1805
- // ── F5: --dry-run with no --repo/--harness ─────────────────────────────────
1811
+ // ── --dry-run with no --repo/--harness ─────────────────────────────────
1806
1812
 
1807
- it('F5: --dry-run with no --repo/--harness prints preview and does not throw', async () => {
1813
+ it('--dry-run with no --repo/--harness prints preview and does not throw', async () => {
1808
1814
  const {ctx, logs} = makeCtx()
1809
1815
  await runSetupCommand({dryRun: true}, {ctx})
1810
1816
  const output = logs.map(args => args.join(' ')).join('\n')
@@ -1812,7 +1818,7 @@ describe('runSetupCommand action handler', () => {
1812
1818
  expect(output).toContain('No mutations will be performed.')
1813
1819
  })
1814
1820
 
1815
- it('F5: --dry-run does not call assertGhInstalled even with no flags', async () => {
1821
+ it('--dry-run does not call assertGhInstalled even with no flags', async () => {
1816
1822
  const {ctx} = makeCtx()
1817
1823
  let ghCalled = false
1818
1824
  await runSetupCommand(
@@ -1836,9 +1842,9 @@ describe('runSetupCommand action handler', () => {
1836
1842
  expect(ghCalled).toBe(false)
1837
1843
  })
1838
1844
 
1839
- // ── F8: Rollback event-order assertions ────────────────────────────────────
1845
+ // ── Rollback event-order assertions ────────────────────────────────────
1840
1846
 
1841
- it('F8: applyGhValue fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1847
+ it('applyGhValue fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1842
1848
  const {ctx} = makeCtx()
1843
1849
  const events: string[] = []
1844
1850
 
@@ -1887,7 +1893,7 @@ describe('runSetupCommand action handler', () => {
1887
1893
  expect(events).toEqual(['create', 'apply-fail', 'delete'])
1888
1894
  })
1889
1895
 
1890
- it('F8: assertProxyKeyWorks fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1896
+ it('assertProxyKeyWorks fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1891
1897
  const {ctx} = makeCtx()
1892
1898
  const events: string[] = []
1893
1899
 
@@ -1938,9 +1944,9 @@ describe('runSetupCommand action handler', () => {
1938
1944
  expect(events).toEqual(['create', 'apply-success', 'verify-fail', 'delete'])
1939
1945
  })
1940
1946
 
1941
- // ── F9: --force pre-gate fires before verifyModelsAvailable ────────────────
1947
+ // ── --force pre-gate fires before verifyModelsAvailable ────────────────
1942
1948
 
1943
- it('F9: missing --force on provider change does not call fetch (verifyModelsAvailable skipped)', async () => {
1949
+ it('missing --force on provider change does not call fetch (verifyModelsAvailable skipped)', async () => {
1944
1950
  let fetchCalled = false
1945
1951
  const originalFetch = globalThis.fetch
1946
1952
  globalThis.fetch = mock(async () => {
@@ -1959,3 +1965,403 @@ describe('runSetupCommand action handler', () => {
1959
1965
  expect(fetchCalled).toBe(false)
1960
1966
  })
1961
1967
  })
1968
+
1969
+ // ── post-write readback verification ──────────────────────────────────────────
1970
+
1971
+ describe('post-write readback verification', () => {
1972
+ const BASE_URL = 'https://cliproxy.fro.bot'
1973
+ const KEY = 'sk-test-key'
1974
+
1975
+ // Capture log.warn calls from @clack/prompts
1976
+ let warnSpy: ReturnType<typeof spyOn>
1977
+ let warnMessages: string[]
1978
+
1979
+ // Standard DI deps for a successful non-interactive setup run.
1980
+ // listExistingGhNames is overridden per-test to control readback behavior.
1981
+ function makeDeps(
1982
+ listExistingGhNames: (repo: string, kind: 'secret' | 'variable') => Promise<string[]>,
1983
+ deleteManagementApiKey?: () => Promise<void>,
1984
+ ) {
1985
+ const {ctx} = makeCtx()
1986
+ return {
1987
+ ctx,
1988
+ deps: {
1989
+ interactive: false,
1990
+ baseUrl: BASE_URL,
1991
+ ctx,
1992
+ gh: {
1993
+ assertGhInstalled: async () => {},
1994
+ assertGhAuthenticated: async () => {},
1995
+ assertRepoAccess: async () => {},
1996
+ listExistingGhNames,
1997
+ createManagementApiKey: async () => {},
1998
+ deleteManagementApiKey: deleteManagementApiKey ?? (async () => {}),
1999
+ applyGhValue: async () => {},
2000
+ withGhRetry: async (_label, fn) => fn(makeSpinner()),
2001
+ },
2002
+ prompts: {
2003
+ promptValue: autoPromptValue,
2004
+ confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
2005
+ intro: () => {},
2006
+ note: () => {},
2007
+ outro: () => {},
2008
+ },
2009
+ smoke: {
2010
+ runSmokeTest: async () => ({kind: 'pass' as const, message: 'ok', runUrl: 'https://example.com/run/1'}),
2011
+ },
2012
+ validation: {
2013
+ assertProxyReachable: async () => {},
2014
+ assertProxyKeyWorks: async () => {},
2015
+ verifyModelsAvailable: async () => {},
2016
+ },
2017
+ } satisfies Parameters<typeof runSetupCommand>[1],
2018
+ }
2019
+ }
2020
+
2021
+ // Standard options for a non-interactive anthropic-only setup (no --force needed)
2022
+ const baseOptions = {
2023
+ key: KEY,
2024
+ repo: 'owner/repo',
2025
+ harness: 'opencode' as const,
2026
+ }
2027
+
2028
+ beforeEach(() => {
2029
+ warnMessages = []
2030
+ warnSpy = spyOn(log, 'warn').mockImplementation((msg: string) => {
2031
+ warnMessages.push(msg)
2032
+ })
2033
+ })
2034
+
2035
+ afterEach(() => {
2036
+ warnSpy.mockRestore()
2037
+ })
2038
+
2039
+ it('happy path: readback returns all written names → no new warning emitted', async () => {
2040
+ // Pre-write list is empty; post-write readback returns all written names
2041
+ // The opencode harness writes: OPENCODE_AUTH_JSON, OPENCODE_CONFIG, 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)
2062
+ })
2063
+
2064
+ it('verified mismatch (secret): readback succeeds but written secret absent → loud warning naming absent secret', async () => {
2065
+ // Pre-write: empty. Post-write secret readback: missing OPENCODE_AUTH_JSON
2066
+ let callCount = 0
2067
+ const {deps} = makeDeps(async (_repo, kind) => {
2068
+ callCount++
2069
+ if (callCount <= 2) return []
2070
+ // Post-write: secret readback missing OPENCODE_AUTH_JSON
2071
+ if (kind === 'secret') return ['OPENCODE_CONFIG', 'OMO_PROVIDERS']
2072
+ return ['FRO_BOT_MODEL']
2073
+ })
2074
+
2075
+ await runSetupCommand(baseOptions, deps)
2076
+
2077
+ const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2078
+ expect(mismatchWarnings.length).toBeGreaterThan(0)
2079
+ // Must name the absent secret
2080
+ expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
2081
+ // Must direct operator to manual verification
2082
+ expect(mismatchWarnings.some(m => m.includes('gh secret list'))).toBe(true)
2083
+ })
2084
+
2085
+ it('verified mismatch (variable): secret readback complete but variable absent → warning lists absent variable', async () => {
2086
+ // Pre-write: empty. Post-write: all secrets present, but FRO_BOT_MODEL missing from variables
2087
+ let callCount = 0
2088
+ const {deps} = makeDeps(async (_repo, kind) => {
2089
+ callCount++
2090
+ if (callCount <= 2) return []
2091
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2092
+ // Variable readback missing FRO_BOT_MODEL
2093
+ return []
2094
+ })
2095
+
2096
+ await runSetupCommand(baseOptions, deps)
2097
+
2098
+ const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2099
+ expect(mismatchWarnings.length).toBeGreaterThan(0)
2100
+ expect(mismatchWarnings.some(m => m.includes('FRO_BOT_MODEL'))).toBe(true)
2101
+ expect(mismatchWarnings.some(m => m.includes('gh variable list'))).toBe(true)
2102
+ })
2103
+
2104
+ it('partial visibility: readback shows some but not all written names → warning lists exactly the absent names', async () => {
2105
+ // Post-write: OPENCODE_CONFIG 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
+ })
2113
+
2114
+ await runSetupCommand(baseOptions, deps)
2115
+
2116
+ const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2117
+ expect(mismatchWarnings.length).toBeGreaterThan(0)
2118
+ // Must name OPENCODE_AUTH_JSON (absent)
2119
+ expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
2120
+ // Must NOT name OPENCODE_CONFIG 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)
2123
+ })
2124
+
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
2128
+
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
+ )
2140
+
2141
+ // Command must NOT throw
2142
+ await expect(runSetupCommand(baseOptions, deps)).resolves.toBeUndefined()
2143
+
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()
2154
+ })
2155
+
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()
2161
+
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)
2221
+ })
2222
+
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
2230
+
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()
2257
+ })
2258
+
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)
2281
+ })
2282
+ })
2283
+
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
+ })
2332
+ })
2333
+
2334
+ afterEach(() => {
2335
+ warnSpy.mockRestore()
2336
+ })
2337
+
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)
2351
+ })
2352
+
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
+ })
2361
+
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)
2366
+ })
2367
+ })
@@ -90,6 +90,8 @@ export interface RunSetupDeps {
90
90
  intro: typeof intro
91
91
  note: typeof note
92
92
  outro: typeof outro
93
+ promptForProviders?: typeof promptForProviders
94
+ promptForModel?: typeof promptForModel
93
95
  }
94
96
  smoke?: {
95
97
  runSmokeTest: typeof runSmokeTest
@@ -105,13 +107,121 @@ function resolveBaseUrl(input?: string): string {
105
107
  return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
106
108
  }
107
109
 
110
+ /**
111
+ * Emit a warning without ever throwing. Used inside verifyWrittenNamesVisible so that a
112
+ * failure in warning emission itself cannot escape to the outer write catch and wrongly
113
+ * roll back a key whose secrets are already written.
114
+ */
115
+ function safeWarn(message: string): void {
116
+ try {
117
+ log.warn(message)
118
+ } catch {
119
+ // Warning emission must never escape the post-write readback — a throw here would
120
+ // reach the outer write catch and wrongly roll back a key whose secrets are written.
121
+ }
122
+ }
123
+
124
+ function buildCannotVerifyMessage(repo: string, writtenSecretNames: string[], writtenVariableNames: string[]): string {
125
+ const secretPart =
126
+ writtenSecretNames.length > 0
127
+ ? `gh secret list --repo ${repo} (expect: ${writtenSecretNames.join(', ')})`
128
+ : `gh secret list --repo ${repo}`
129
+ const variablePart =
130
+ writtenVariableNames.length > 0
131
+ ? `gh variable list --repo ${repo} (expect: ${writtenVariableNames.join(', ')})`
132
+ : `gh variable list --repo ${repo}`
133
+ return (
134
+ `Post-write readback: could not verify the written names are visible in ${repo} ` +
135
+ `(the GitHub list call failed). Verify manually: ${secretPart}; ${variablePart}.`
136
+ )
137
+ }
138
+
139
+ /**
140
+ * After a successful write, re-list secret and variable names and warn if any written name
141
+ * is absent on readback — signaling an unreliable token list view that may have bypassed
142
+ * the pre-write safety gates.
143
+ *
144
+ * Distinguishes:
145
+ * - Verified mismatch: readback succeeded but a written name is absent (strong signal).
146
+ * - Cannot verify: the readback gh call itself failed (weaker signal).
147
+ *
148
+ * NEVER throws. The entire body is wrapped in a single try/catch so any failure — including
149
+ * errors during diff computation or warning emission — degrades to the cannot-verify warning.
150
+ * A throw here would propagate to the mutationError rollback and wrongly delete a key whose
151
+ * secrets are already written.
152
+ */
153
+ async function verifyWrittenNamesVisible(
154
+ repo: string,
155
+ writtenSecretNames: string[],
156
+ writtenVariableNames: string[],
157
+ listExistingGhNames: typeof import('./setup/gh').listExistingGhNames,
158
+ ): Promise<void> {
159
+ try {
160
+ let secretReadback: string[]
161
+ let variableReadback: string[]
162
+ let readbackFailed = false
163
+
164
+ try {
165
+ secretReadback = await listExistingGhNames(repo, 'secret')
166
+ } catch {
167
+ readbackFailed = true
168
+ secretReadback = []
169
+ }
170
+
171
+ if (readbackFailed) {
172
+ variableReadback = []
173
+ } else {
174
+ try {
175
+ variableReadback = await listExistingGhNames(repo, 'variable')
176
+ } catch {
177
+ readbackFailed = true
178
+ variableReadback = []
179
+ }
180
+ }
181
+
182
+ if (readbackFailed) {
183
+ safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
184
+ return
185
+ }
186
+
187
+ const absentSecrets = writtenSecretNames.filter(name => !secretReadback.includes(name))
188
+ const absentVariables = writtenVariableNames.filter(name => !variableReadback.includes(name))
189
+
190
+ if (absentSecrets.length === 0 && absentVariables.length === 0) {
191
+ // Happy path: all written names are visible. Emit nothing.
192
+ return
193
+ }
194
+
195
+ const lines: string[] = [
196
+ `Post-write readback: the following written names are not visible in ${repo} — ` +
197
+ `the token's list view may be unreliable and the pre-write safety gates may have been bypassed.`,
198
+ ]
199
+
200
+ if (absentSecrets.length > 0) {
201
+ lines.push(` Absent secrets: ${absentSecrets.join(', ')}`)
202
+ lines.push(` Verify manually: gh secret list --repo ${repo}`)
203
+ }
204
+
205
+ if (absentVariables.length > 0) {
206
+ lines.push(` Absent variables: ${absentVariables.join(', ')}`)
207
+ lines.push(` Verify manually: gh variable list --repo ${repo}`)
208
+ }
209
+
210
+ safeWarn(lines.join('\n'))
211
+ } catch {
212
+ // Any failure in the entire verification block (readback, diff, or warning emission)
213
+ // degrades to this softer cannot-verify warning. Never re-throw.
214
+ safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
215
+ }
216
+ }
217
+
108
218
  function extractErrorMessage(error: unknown): string {
109
219
  return error instanceof Error ? error.message : String(error)
110
220
  }
111
221
 
112
222
  // Redact a bearer token for display in interactive prompts — never show raw key values.
113
223
  // Exported for direct unit testing of the redaction contract. The redacted form is
114
- // what gets shown in the interactive R8 prompt; the raw key must never reach the prompt UI.
224
+ // what gets shown in the interactive key-reuse prompt; the raw key must never reach the prompt UI.
115
225
  export function redactKey(key: string): string {
116
226
  if (key.length < 12) return 'sk-***'
117
227
  return `${key.slice(0, 3)}***${key.slice(-4)}`
@@ -172,8 +282,10 @@ async function buildInteractivePlan(
172
282
  let model: string | undefined
173
283
 
174
284
  if (harness === 'opencode') {
175
- providers = await promptForProviders()
176
- model = await promptForModel(providers)
285
+ const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders
286
+ const doPromptForModel = promptsImpl.promptForModel ?? promptForModel
287
+ providers = await doPromptForProviders()
288
+ model = await doPromptForModel(providers)
177
289
  }
178
290
 
179
291
  const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
@@ -271,6 +383,8 @@ const realPrompts: Required<RunSetupDeps>['prompts'] = {
271
383
  intro,
272
384
  note,
273
385
  outro,
386
+ promptForProviders,
387
+ promptForModel,
274
388
  }
275
389
 
276
390
  const realSmoke: Required<RunSetupDeps>['smoke'] = {
@@ -437,7 +551,10 @@ export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps
437
551
  }
438
552
 
439
553
  if (!interactive && options.force) {
440
- log.warn(`Overwriting existing GitHub values: ${collisions.join(', ')}`)
554
+ log.warn(
555
+ `Overwriting existing GitHub values: ${collisions.join(', ')}. ` +
556
+ `Concurrent setup runs against the same repo are not coordinated and resolve last-write-wins — don't run setup against this repo from two places at once.`,
557
+ )
441
558
  // proceed
442
559
  }
443
560
 
@@ -486,6 +603,13 @@ export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps
486
603
  interactive,
487
604
  )
488
605
 
606
+ await verifyWrittenNamesVisible(
607
+ plan.repo,
608
+ plan.template.secrets.map(s => s.name),
609
+ plan.template.variables.map(v => v.name),
610
+ gh.listExistingGhNames,
611
+ )
612
+
489
613
  await withSpinner('Verifying the new key through the proxy', async () => {
490
614
  await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
491
615
  })