@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 +1 -1
- package/src/commands/cliproxy/setup/providers.test.ts +50 -33
- package/src/commands/cliproxy/setup/smoke-test.test.ts +178 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +29 -11
- package/src/commands/cliproxy/setup.test.ts +454 -48
- package/src/commands/cliproxy/setup.ts +128 -4
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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']
|
|
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(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
})
|
|
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'
|
|
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'
|
|
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__'
|
|
171
|
-
const textSpy = spyOn(clack, 'text').mockResolvedValue('openai/gpt-5.4-mini'
|
|
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__'
|
|
200
|
+
const selectSpy = spyOn(clack, 'select').mockResolvedValue('__custom__')
|
|
185
201
|
let textCallCount = 0
|
|
186
|
-
const textSpy = spyOn(clack, 'text').mockImplementation(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
|
70
|
-
if (
|
|
71
|
-
baselineId =
|
|
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
|
-
|
|
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 {
|
|
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
|
-
// ──
|
|
399
|
+
// ── regression tests ────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
399
400
|
|
|
400
|
-
describe('
|
|
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('
|
|
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('
|
|
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((
|
|
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
|
|
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('
|
|
1404
|
-
// Read the setup.ts source and assert the
|
|
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
|
-
|
|
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
|
-
|
|
1413
|
+
const confirmMessages: string[] = []
|
|
1427
1414
|
const captureConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
|
|
1428
|
-
|
|
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
|
|
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
|
|
1483
|
-
// in some paths; we
|
|
1484
|
-
|
|
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(
|
|
1476
|
+
expect(keyReuseMessage).not.toContain(PLAINTEXT_KEY)
|
|
1487
1477
|
// Redacted form must be present (first 3 + last 4 chars per redactKey helper).
|
|
1488
|
-
expect(
|
|
1489
|
-
expect(
|
|
1490
|
-
expect(
|
|
1478
|
+
expect(keyReuseMessage).toContain('sk-')
|
|
1479
|
+
expect(keyReuseMessage).toContain('***')
|
|
1480
|
+
expect(keyReuseMessage).toContain('LEAK')
|
|
1491
1481
|
})
|
|
1492
1482
|
|
|
1493
|
-
it
|
|
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
|
-
//
|
|
1544
|
-
confirm:
|
|
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
|
|
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
|
-
// ──
|
|
1811
|
+
// ── --dry-run with no --repo/--harness ─────────────────────────────────
|
|
1806
1812
|
|
|
1807
|
-
it('
|
|
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('
|
|
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
|
-
// ──
|
|
1845
|
+
// ── Rollback event-order assertions ────────────────────────────────────
|
|
1840
1846
|
|
|
1841
|
-
it('
|
|
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('
|
|
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
|
-
// ──
|
|
1947
|
+
// ── --force pre-gate fires before verifyModelsAvailable ────────────────
|
|
1942
1948
|
|
|
1943
|
-
it('
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
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(
|
|
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
|
})
|