@marcusrbrown/infra 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__snapshots__/cli.test.ts.snap +6 -0
- package/src/commands/cliproxy/config.ts +2 -26
- package/src/commands/cliproxy/keys.ts +8 -43
- package/src/commands/cliproxy/setup/gh.test.ts +218 -0
- package/src/commands/cliproxy/setup/gh.ts +250 -0
- package/src/commands/cliproxy/setup/preview.test.ts +159 -0
- package/src/commands/cliproxy/setup/preview.ts +41 -0
- package/src/commands/cliproxy/setup/prompts.test.ts +58 -0
- package/src/commands/cliproxy/setup/prompts.ts +99 -0
- package/src/commands/cliproxy/setup/providers.test.ts +228 -0
- package/src/commands/cliproxy/setup/providers.ts +136 -0
- package/src/commands/cliproxy/setup/smoke-test.test.ts +643 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +205 -0
- package/src/commands/cliproxy/setup/templates.test.ts +358 -0
- package/src/commands/cliproxy/setup/templates.ts +158 -0
- package/src/commands/cliproxy/setup/validation.test.ts +399 -0
- package/src/commands/cliproxy/setup/validation.ts +182 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +341 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +137 -0
- package/src/commands/cliproxy/setup.test.ts +1867 -247
- package/src/commands/cliproxy/setup.ts +544 -831
- package/src/commands/cliproxy/shared.test.ts +118 -0
- package/src/commands/cliproxy/shared.ts +84 -0
- package/src/commands/cliproxy/status.ts +2 -7
|
@@ -1,341 +1,1961 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type {SpinnerResult} from '@clack/prompts'
|
|
4
|
+
import {afterEach, describe, expect, it, mock, spyOn} from 'bun:test'
|
|
4
5
|
import {goke} from 'goke'
|
|
5
6
|
|
|
6
7
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
getHarnessTemplate,
|
|
10
|
-
interpretGhContentResult,
|
|
11
|
-
isGhRateLimitError,
|
|
8
|
+
buildNonInteractivePlan,
|
|
9
|
+
redactKey,
|
|
12
10
|
registerCliproxySetup,
|
|
11
|
+
requiresDestructiveProviderChangeConfirmation,
|
|
12
|
+
runSetupCommand,
|
|
13
13
|
validateSetupOptions,
|
|
14
|
-
|
|
15
|
-
type SecretAssignment,
|
|
16
|
-
type VariableAssignment,
|
|
14
|
+
verifyModelsAvailable,
|
|
17
15
|
} from './setup'
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
with:
|
|
21
|
-
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
22
|
-
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
23
|
-
model: \${{ vars.FRO_BOT_MODEL }}
|
|
24
|
-
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
25
|
-
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
26
|
-
prompt: \${{ env.PROMPT }}
|
|
27
|
-
`
|
|
28
|
-
|
|
29
|
-
const MISSING_OPENCODE_CONFIG_WORKFLOW = ` - uses: fro-bot/agent@abc123
|
|
30
|
-
with:
|
|
31
|
-
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
32
|
-
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
33
|
-
model: \${{ vars.FRO_BOT_MODEL }}
|
|
34
|
-
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
35
|
-
prompt: \${{ env.PROMPT }}
|
|
36
|
-
`
|
|
37
|
-
|
|
38
|
-
// Regression fixture for PR #125 review: a sibling step has `model:` as an input,
|
|
39
|
-
// but the fro-bot/agent step is missing it. The step-scoped scan must still flag
|
|
40
|
-
// `model` as missing, otherwise the diagnostic is silently suppressed.
|
|
41
|
-
const SIBLING_STEP_SHADOWS_MODEL_INPUT = `name: ci
|
|
42
|
-
on: [push]
|
|
43
|
-
jobs:
|
|
44
|
-
run:
|
|
45
|
-
runs-on: ubuntu-latest
|
|
46
|
-
strategy:
|
|
47
|
-
matrix:
|
|
48
|
-
model: [opus, sonnet]
|
|
49
|
-
steps:
|
|
50
|
-
- uses: actions/some-ai-step@abc
|
|
51
|
-
with:
|
|
52
|
-
model: \${{ matrix.model }}
|
|
53
|
-
- name: Run Fro Bot
|
|
54
|
-
uses: fro-bot/agent@def
|
|
55
|
-
with:
|
|
56
|
-
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
57
|
-
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
58
|
-
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
const WORKFLOW_WITHOUT_FRO_BOT_AGENT = `name: ci
|
|
62
|
-
on: [push]
|
|
63
|
-
jobs:
|
|
64
|
-
build:
|
|
65
|
-
runs-on: ubuntu-latest
|
|
66
|
-
steps:
|
|
67
|
-
- uses: actions/checkout@v4
|
|
68
|
-
- run: echo hello
|
|
69
|
-
`
|
|
70
|
-
|
|
71
|
-
// Follow-up from PR #125 second review: the matchAll refactor must report gaps
|
|
72
|
-
// in any fro-bot/agent step, not just the first. Step #1 is complete, step #2 is
|
|
73
|
-
// missing opencode-config and model — the analyzer should flag only step #2.
|
|
74
|
-
const TWO_AGENT_STEPS_SECOND_BROKEN = `name: fro-bot
|
|
75
|
-
on: [pull_request, schedule]
|
|
76
|
-
jobs:
|
|
77
|
-
review:
|
|
78
|
-
runs-on: ubuntu-latest
|
|
79
|
-
steps:
|
|
80
|
-
- name: Run Fro Bot review
|
|
81
|
-
uses: fro-bot/agent@abc123
|
|
82
|
-
with:
|
|
83
|
-
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
84
|
-
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
85
|
-
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
86
|
-
model: \${{ vars.FRO_BOT_MODEL }}
|
|
87
|
-
dispatch:
|
|
88
|
-
runs-on: ubuntu-latest
|
|
89
|
-
steps:
|
|
90
|
-
- name: Run Fro Bot dispatch
|
|
91
|
-
uses: fro-bot/agent@abc123
|
|
92
|
-
with:
|
|
93
|
-
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
94
|
-
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
95
|
-
`
|
|
16
|
+
import {formatDryRunPreview} from './setup/preview'
|
|
17
|
+
import {getHarnessTemplate} from './setup/templates'
|
|
96
18
|
|
|
97
19
|
describe('cliproxy setup helpers', () => {
|
|
98
|
-
describe('
|
|
99
|
-
it('
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
103
|
-
})
|
|
20
|
+
describe('help output', () => {
|
|
21
|
+
it('shows --key, --repo, and --harness flags', () => {
|
|
22
|
+
const cli = goke('infra')
|
|
23
|
+
registerCliproxySetup(cli)
|
|
24
|
+
cli.help()
|
|
104
25
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
)
|
|
26
|
+
const helpText = cli.helpText()
|
|
27
|
+
|
|
28
|
+
expect(helpText).toContain('cliproxy setup')
|
|
29
|
+
expect(helpText).toContain('--key [key]')
|
|
30
|
+
expect(helpText).toContain('--repo [repo]')
|
|
31
|
+
expect(helpText).toContain('--harness [harness]')
|
|
109
32
|
})
|
|
110
33
|
|
|
111
|
-
it('
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
)
|
|
34
|
+
it('shows the five new provider/model/force/dry-run/verify-smoke flags in help text', () => {
|
|
35
|
+
const cli = goke('infra')
|
|
36
|
+
registerCliproxySetup(cli)
|
|
37
|
+
cli.help()
|
|
38
|
+
|
|
39
|
+
const helpText = cli.helpText()
|
|
40
|
+
|
|
41
|
+
expect(helpText).toContain('--providers')
|
|
42
|
+
expect(helpText).toContain('--model')
|
|
43
|
+
expect(helpText).toContain('--force')
|
|
44
|
+
expect(helpText).toContain('--dry-run')
|
|
45
|
+
expect(helpText).toContain('--verify-smoke')
|
|
115
46
|
})
|
|
116
47
|
})
|
|
48
|
+
})
|
|
117
49
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
50
|
+
describe('option parsing', () => {
|
|
51
|
+
describe('model flag validation', () => {
|
|
52
|
+
// Tightened regex: trailing dot/hyphen rejected; single-char tail accepted
|
|
53
|
+
const MODEL_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
|
|
121
54
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
'OPENCODE_CONFIG',
|
|
125
|
-
'OMO_PROVIDERS',
|
|
126
|
-
])
|
|
127
|
-
expect(template.variables.map((entry: VariableAssignment) => entry.name)).toEqual(['FRO_BOT_MODEL'])
|
|
55
|
+
it('accepts "openai/gpt-5.4-mini"', () => {
|
|
56
|
+
expect(MODEL_RE.test('openai/gpt-5.4-mini')).toBe(true)
|
|
128
57
|
})
|
|
129
58
|
|
|
130
|
-
it('
|
|
131
|
-
|
|
132
|
-
|
|
59
|
+
it('rejects "gpt-5.4-mini" (no provider prefix)', () => {
|
|
60
|
+
expect(MODEL_RE.test('gpt-5.4-mini')).toBe(false)
|
|
61
|
+
})
|
|
133
62
|
|
|
134
|
-
|
|
63
|
+
it('rejects "openai/GPT-5.4-mini" (uppercase)', () => {
|
|
64
|
+
expect(MODEL_RE.test('openai/GPT-5.4-mini')).toBe(false)
|
|
135
65
|
})
|
|
136
66
|
|
|
137
|
-
it('
|
|
138
|
-
|
|
139
|
-
|
|
67
|
+
it('rejects "openai/gpt-5.4-mini; rm -rf /" (injection attempt)', () => {
|
|
68
|
+
expect(MODEL_RE.test('openai/gpt-5.4-mini; rm -rf /')).toBe(false)
|
|
69
|
+
})
|
|
140
70
|
|
|
141
|
-
|
|
71
|
+
// Fix 5 — trailing dot/hyphen rejection
|
|
72
|
+
it('rejects "openai/gpt-4o." (trailing dot)', () => {
|
|
73
|
+
expect(MODEL_RE.test('openai/gpt-4o.')).toBe(false)
|
|
142
74
|
})
|
|
143
75
|
|
|
144
|
-
it('
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const parsed = JSON.parse(configEntry?.value ?? '{}')
|
|
76
|
+
it('rejects "openai/gpt-4o-" (trailing hyphen)', () => {
|
|
77
|
+
expect(MODEL_RE.test('openai/gpt-4o-')).toBe(false)
|
|
78
|
+
})
|
|
148
79
|
|
|
149
|
-
|
|
80
|
+
it('accepts "openai/gpt-4o" (regression — still works)', () => {
|
|
81
|
+
expect(MODEL_RE.test('openai/gpt-4o')).toBe(true)
|
|
150
82
|
})
|
|
151
83
|
|
|
152
|
-
it('
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
84
|
+
it('accepts "anthropic/claude-sonnet-4-6" (regression)', () => {
|
|
85
|
+
expect(MODEL_RE.test('anthropic/claude-sonnet-4-6')).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('accepts "openai/a" (single-char tail)', () => {
|
|
89
|
+
expect(MODEL_RE.test('openai/a')).toBe(true)
|
|
90
|
+
})
|
|
156
91
|
|
|
157
|
-
|
|
92
|
+
it('rejects "openai/" (empty tail)', () => {
|
|
93
|
+
expect(MODEL_RE.test('openai/')).toBe(false)
|
|
158
94
|
})
|
|
159
95
|
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('validation matrix + non-interactive plan', () => {
|
|
99
|
+
const MODELS_FIXTURE = {
|
|
100
|
+
data: [
|
|
101
|
+
{id: 'claude-3-7-sonnet-20250219', owned_by: 'anthropic'},
|
|
102
|
+
{id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
|
|
103
|
+
{id: 'gpt-5.4-mini', owned_by: 'openai'},
|
|
104
|
+
{id: 'gpt-5.5', owned_by: 'openai'},
|
|
105
|
+
],
|
|
106
|
+
}
|
|
160
107
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const result = analyzeFroBotWorkflow(COMPLETE_WORKFLOW)
|
|
108
|
+
const BASE_URL = 'https://cliproxy.fro.bot'
|
|
109
|
+
const KEY = 'sk-test-key'
|
|
164
110
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
111
|
+
let originalFetch: typeof globalThis.fetch
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
globalThis.fetch = originalFetch
|
|
114
|
+
})
|
|
115
|
+
originalFetch = globalThis.fetch
|
|
116
|
+
|
|
117
|
+
// ── buildNonInteractivePlan ───────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe('buildNonInteractivePlan', () => {
|
|
120
|
+
it('regression: no providers/model → byte-identical plan to existing behavior', async () => {
|
|
121
|
+
const plan = await buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL)
|
|
122
|
+
|
|
123
|
+
expect(plan.createKey).toBe(false)
|
|
124
|
+
expect(plan.keyValue).toBe(KEY)
|
|
125
|
+
expect(plan.repo).toBe('owner/repo')
|
|
126
|
+
expect(plan.harness).toBe('opencode')
|
|
127
|
+
// Template must match what getHarnessTemplate('opencode', {keyValue, baseUrl}) produces
|
|
128
|
+
const expected = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
129
|
+
expect(plan.template).toEqual(expected)
|
|
168
130
|
})
|
|
169
131
|
|
|
170
|
-
it('
|
|
171
|
-
const
|
|
132
|
+
it('explicit providers: anthropic → byte-identical to no-providers case', async () => {
|
|
133
|
+
const planDefault = await buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL)
|
|
134
|
+
const planExplicit = await buildNonInteractivePlan(
|
|
135
|
+
{key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'anthropic'},
|
|
136
|
+
BASE_URL,
|
|
137
|
+
)
|
|
172
138
|
|
|
173
|
-
expect(
|
|
174
|
-
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
175
|
-
expect(result.stepsWithGaps).toHaveLength(1)
|
|
176
|
-
expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
|
|
177
|
-
expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
|
|
139
|
+
expect(planExplicit.template).toEqual(planDefault.template)
|
|
178
140
|
})
|
|
179
141
|
|
|
180
|
-
it('
|
|
181
|
-
const
|
|
142
|
+
it('openai-only + model → correct template; verifyModelsAvailable IS called', async () => {
|
|
143
|
+
const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
|
|
144
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
182
145
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
146
|
+
const plan = await buildNonInteractivePlan(
|
|
147
|
+
{
|
|
148
|
+
key: KEY,
|
|
149
|
+
repo: 'owner/repo',
|
|
150
|
+
harness: 'opencode',
|
|
151
|
+
providers: 'openai',
|
|
152
|
+
model: 'openai/gpt-5.4-mini',
|
|
153
|
+
force: true,
|
|
154
|
+
},
|
|
155
|
+
BASE_URL,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// verifyModelsAvailable should have called fetch
|
|
159
|
+
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
|
|
160
|
+
// Template should have openai provider
|
|
161
|
+
const authEntry = plan.template.secrets.find(s => s.name === 'OPENCODE_AUTH_JSON')
|
|
162
|
+
const parsed = JSON.parse(authEntry?.value ?? '{}')
|
|
163
|
+
expect(parsed.openai).toBeDefined()
|
|
164
|
+
expect(parsed.anthropic).toBeUndefined()
|
|
188
165
|
})
|
|
189
166
|
|
|
190
|
-
it('
|
|
191
|
-
const
|
|
167
|
+
it('dual providers + model → verifyModelsAvailable IS called', async () => {
|
|
168
|
+
const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
|
|
169
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
170
|
+
|
|
171
|
+
const plan = await buildNonInteractivePlan(
|
|
172
|
+
{
|
|
173
|
+
key: KEY,
|
|
174
|
+
repo: 'owner/repo',
|
|
175
|
+
harness: 'opencode',
|
|
176
|
+
providers: 'anthropic,openai',
|
|
177
|
+
model: 'openai/gpt-5.4-mini',
|
|
178
|
+
force: true,
|
|
179
|
+
},
|
|
180
|
+
BASE_URL,
|
|
181
|
+
)
|
|
192
182
|
|
|
193
|
-
expect(
|
|
183
|
+
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
|
|
184
|
+
const authEntry = plan.template.secrets.find(s => s.name === 'OPENCODE_AUTH_JSON')
|
|
185
|
+
const parsed = JSON.parse(authEntry?.value ?? '{}')
|
|
186
|
+
expect(parsed.anthropic).toBeDefined()
|
|
187
|
+
expect(parsed.openai).toBeDefined()
|
|
194
188
|
})
|
|
195
189
|
|
|
196
|
-
it('
|
|
197
|
-
const
|
|
190
|
+
it('openai-only without model → uses PROVIDER_DEFAULTS openai/gpt-5.4-mini', async () => {
|
|
191
|
+
const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
|
|
192
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
193
|
+
|
|
194
|
+
const plan = await buildNonInteractivePlan(
|
|
195
|
+
{key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai', force: true},
|
|
196
|
+
BASE_URL,
|
|
197
|
+
)
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
const modelEntry = plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')
|
|
200
|
+
expect(modelEntry?.value).toBe('openai/gpt-5.4-mini')
|
|
200
201
|
})
|
|
201
202
|
|
|
202
|
-
it('
|
|
203
|
-
|
|
203
|
+
it('verifyModelsAvailable throws → buildNonInteractivePlan propagates the error', async () => {
|
|
204
|
+
globalThis.fetch = mock(async () => new Response('Unauthorized', {status: 401})) as unknown as typeof fetch
|
|
204
205
|
|
|
205
|
-
expect(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
await expect(
|
|
207
|
+
buildNonInteractivePlan(
|
|
208
|
+
// force: true bypasses the destructive-provider pre-gate so verifyModelsAvailable is reached
|
|
209
|
+
{
|
|
210
|
+
key: KEY,
|
|
211
|
+
repo: 'owner/repo',
|
|
212
|
+
harness: 'opencode',
|
|
213
|
+
providers: 'openai',
|
|
214
|
+
model: 'openai/gpt-5.4-mini',
|
|
215
|
+
force: true,
|
|
216
|
+
},
|
|
217
|
+
BASE_URL,
|
|
218
|
+
),
|
|
219
|
+
).rejects.toThrow('Proxy key rejected')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('anthropic-only: verifyModelsAvailable is NOT called (no fetch)', async () => {
|
|
223
|
+
const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
|
|
224
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
225
|
+
|
|
226
|
+
await buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL)
|
|
227
|
+
|
|
228
|
+
expect(fetchMock.mock.calls.length).toBe(0)
|
|
210
229
|
})
|
|
211
230
|
})
|
|
231
|
+
})
|
|
212
232
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
exitCode: 1,
|
|
217
|
-
stdout: '',
|
|
218
|
-
stderr: 'gh: Not Found (HTTP 404)',
|
|
219
|
-
})
|
|
233
|
+
describe('destructive overwrite UX', () => {
|
|
234
|
+
const BASE_URL = 'https://cliproxy.fro.bot'
|
|
235
|
+
const KEY = 'sk-test-key'
|
|
220
236
|
|
|
221
|
-
|
|
237
|
+
const MODELS_FIXTURE = {
|
|
238
|
+
data: [
|
|
239
|
+
{id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
|
|
240
|
+
{id: 'gpt-5.4-mini', owned_by: 'openai'},
|
|
241
|
+
],
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let originalFetch: typeof globalThis.fetch
|
|
245
|
+
afterEach(() => {
|
|
246
|
+
globalThis.fetch = originalFetch
|
|
247
|
+
})
|
|
248
|
+
originalFetch = globalThis.fetch
|
|
249
|
+
|
|
250
|
+
// ── mustConfirmDestructive ────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
describe('requiresDestructiveProviderChangeConfirmation', () => {
|
|
253
|
+
it("['anthropic'] → false (anthropic-only is safe, no confirm needed)", () => {
|
|
254
|
+
expect(requiresDestructiveProviderChangeConfirmation(['anthropic'])).toBe(false)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("['openai'] → true (non-anthropic provider requires confirm)", () => {
|
|
258
|
+
expect(requiresDestructiveProviderChangeConfirmation(['openai'])).toBe(true)
|
|
222
259
|
})
|
|
223
260
|
|
|
224
|
-
it('
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
stdout: '',
|
|
228
|
-
stderr: 'gh: API rate limit exceeded',
|
|
229
|
-
})
|
|
261
|
+
it("['anthropic', 'openai'] → true (multi-provider requires confirm)", () => {
|
|
262
|
+
expect(requiresDestructiveProviderChangeConfirmation(['anthropic', 'openai'])).toBe(true)
|
|
263
|
+
})
|
|
230
264
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
expect(result.reason).toBe('gh: API rate limit exceeded')
|
|
265
|
+
it("['openai', 'anthropic'] → true (order does not matter)", () => {
|
|
266
|
+
expect(requiresDestructiveProviderChangeConfirmation(['openai', 'anthropic'])).toBe(true)
|
|
234
267
|
})
|
|
268
|
+
})
|
|
235
269
|
|
|
236
|
-
|
|
237
|
-
const result = interpretGhContentResult({exitCode: 2, stdout: '', stderr: ''})
|
|
270
|
+
// ── non-interactive gate: --force / --dry-run ─────────────────────────────
|
|
238
271
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
272
|
+
describe('buildNonInteractivePlan — force/dry-run gate', () => {
|
|
273
|
+
it('anthropic-only + no --force → plan builds without error (G7 invariant)', async () => {
|
|
274
|
+
// Anthropic-only should never require --force
|
|
275
|
+
await expect(
|
|
276
|
+
buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode'}, BASE_URL),
|
|
277
|
+
).resolves.toBeDefined()
|
|
242
278
|
})
|
|
243
279
|
|
|
244
|
-
it('
|
|
245
|
-
|
|
246
|
-
exitCode: 0,
|
|
247
|
-
stdout: COMPLETE_WORKFLOW,
|
|
248
|
-
stderr: '',
|
|
249
|
-
})
|
|
280
|
+
it('openai-only + --force → plan builds without error', async () => {
|
|
281
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
250
282
|
|
|
251
|
-
expect(
|
|
252
|
-
|
|
253
|
-
|
|
283
|
+
await expect(
|
|
284
|
+
buildNonInteractivePlan(
|
|
285
|
+
{
|
|
286
|
+
key: KEY,
|
|
287
|
+
repo: 'owner/repo',
|
|
288
|
+
harness: 'opencode',
|
|
289
|
+
providers: 'openai',
|
|
290
|
+
model: 'openai/gpt-5.4-mini',
|
|
291
|
+
force: true,
|
|
292
|
+
},
|
|
293
|
+
BASE_URL,
|
|
294
|
+
),
|
|
295
|
+
).resolves.toBeDefined()
|
|
254
296
|
})
|
|
255
|
-
})
|
|
256
297
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const snippet = formatWorkflowSnippet(['opencode-config', 'model'])
|
|
298
|
+
it('openai-only + no --force + no --dry-run → throws destructive provider change error', async () => {
|
|
299
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
260
300
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
expect(snippet).toBe(expected)
|
|
301
|
+
await expect(
|
|
302
|
+
buildNonInteractivePlan(
|
|
303
|
+
{key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai', model: 'openai/gpt-5.4-mini'},
|
|
304
|
+
BASE_URL,
|
|
305
|
+
),
|
|
306
|
+
).rejects.toThrow(/--force/)
|
|
268
307
|
})
|
|
269
|
-
})
|
|
270
308
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
309
|
+
it('openai-only + no --force + no --dry-run → error message mentions bearer token note', async () => {
|
|
310
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
311
|
+
|
|
312
|
+
let errorMessage = ''
|
|
313
|
+
try {
|
|
314
|
+
await buildNonInteractivePlan(
|
|
315
|
+
{key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai', model: 'openai/gpt-5.4-mini'},
|
|
316
|
+
BASE_URL,
|
|
317
|
+
)
|
|
318
|
+
} catch (error) {
|
|
319
|
+
errorMessage = error instanceof Error ? error.message : String(error)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
|
|
274
323
|
})
|
|
275
324
|
|
|
276
|
-
it('
|
|
277
|
-
|
|
325
|
+
it('dual-provider + no --force + no --dry-run → throws destructive provider change error', async () => {
|
|
326
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
327
|
+
|
|
328
|
+
await expect(
|
|
329
|
+
buildNonInteractivePlan(
|
|
330
|
+
{
|
|
331
|
+
key: KEY,
|
|
332
|
+
repo: 'owner/repo',
|
|
333
|
+
harness: 'opencode',
|
|
334
|
+
providers: 'anthropic,openai',
|
|
335
|
+
model: 'openai/gpt-5.4-mini',
|
|
336
|
+
},
|
|
337
|
+
BASE_URL,
|
|
338
|
+
),
|
|
339
|
+
).rejects.toThrow(/--force/)
|
|
278
340
|
})
|
|
279
341
|
|
|
280
|
-
it('
|
|
281
|
-
|
|
342
|
+
it('openai-only + --dry-run → plan builds without error (dry-run bypasses force check)', async () => {
|
|
343
|
+
// dry-run skips verifyModelsAvailable too, so no fetch mock needed
|
|
344
|
+
await expect(
|
|
345
|
+
buildNonInteractivePlan(
|
|
346
|
+
{
|
|
347
|
+
key: KEY,
|
|
348
|
+
repo: 'owner/repo',
|
|
349
|
+
harness: 'opencode',
|
|
350
|
+
providers: 'openai',
|
|
351
|
+
model: 'openai/gpt-5.4-mini',
|
|
352
|
+
dryRun: true,
|
|
353
|
+
},
|
|
354
|
+
BASE_URL,
|
|
355
|
+
),
|
|
356
|
+
).resolves.toBeDefined()
|
|
282
357
|
})
|
|
283
358
|
|
|
284
|
-
it('
|
|
285
|
-
|
|
359
|
+
it('--dry-run does NOT call verifyModelsAvailable (no fetch calls)', async () => {
|
|
360
|
+
const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
|
|
361
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
362
|
+
|
|
363
|
+
await buildNonInteractivePlan(
|
|
364
|
+
{
|
|
365
|
+
key: KEY,
|
|
366
|
+
repo: 'owner/repo',
|
|
367
|
+
harness: 'opencode',
|
|
368
|
+
providers: 'openai',
|
|
369
|
+
model: 'openai/gpt-5.4-mini',
|
|
370
|
+
dryRun: true,
|
|
371
|
+
},
|
|
372
|
+
BASE_URL,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
expect(fetchMock.mock.calls.length).toBe(0)
|
|
286
376
|
})
|
|
287
377
|
|
|
288
|
-
it('
|
|
289
|
-
|
|
378
|
+
it('--dry-run + openai + missing --key → plan still builds (renders <proxy-key> placeholder)', async () => {
|
|
379
|
+
// dry-run with empty key should not throw; key renders as placeholder
|
|
380
|
+
await expect(
|
|
381
|
+
buildNonInteractivePlan(
|
|
382
|
+
{
|
|
383
|
+
key: '',
|
|
384
|
+
repo: 'owner/repo',
|
|
385
|
+
harness: 'opencode',
|
|
386
|
+
providers: 'openai',
|
|
387
|
+
model: 'openai/gpt-5.4-mini',
|
|
388
|
+
dryRun: true,
|
|
389
|
+
},
|
|
390
|
+
BASE_URL,
|
|
391
|
+
),
|
|
392
|
+
).resolves.toBeDefined()
|
|
290
393
|
})
|
|
291
394
|
})
|
|
395
|
+
})
|
|
292
396
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const result = await withGhRetry('test label', async () => 'ok', false)
|
|
397
|
+
// ── Smoke test runner tests moved to setup/smoke-test.test.ts ─────────────────
|
|
398
|
+
// ── P1 regression tests ───────────────────────────────────────────────────────
|
|
296
399
|
|
|
297
|
-
|
|
298
|
-
|
|
400
|
+
describe('P1 #1 regression — dry-run early return before mutations', () => {
|
|
401
|
+
const BASE_URL = 'https://cliproxy.fro.bot'
|
|
402
|
+
const KEY = 'sk-test-key'
|
|
299
403
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
404
|
+
// buildNonInteractivePlan with dryRun=true must return a plan without calling fetch
|
|
405
|
+
// (verifyModelsAvailable is skipped) — this is the unit-level coverage for the early return.
|
|
406
|
+
it('buildNonInteractivePlan --dry-run skips verifyModelsAvailable (no fetch) for openai provider', async () => {
|
|
407
|
+
const fetchMock = mock(async () => new Response('{}'))
|
|
408
|
+
const originalFetch = globalThis.fetch
|
|
409
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
305
410
|
|
|
306
|
-
|
|
307
|
-
|
|
411
|
+
try {
|
|
412
|
+
const plan = await buildNonInteractivePlan(
|
|
413
|
+
{
|
|
414
|
+
key: KEY,
|
|
415
|
+
repo: 'owner/repo',
|
|
416
|
+
harness: 'opencode',
|
|
417
|
+
providers: 'openai',
|
|
418
|
+
model: 'openai/gpt-5.4-mini',
|
|
419
|
+
dryRun: true,
|
|
420
|
+
},
|
|
421
|
+
BASE_URL,
|
|
308
422
|
)
|
|
423
|
+
expect(plan).toBeDefined()
|
|
424
|
+
expect(fetchMock.mock.calls.length).toBe(0)
|
|
425
|
+
} finally {
|
|
426
|
+
globalThis.fetch = originalFetch
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('formatDryRunPreview output contains dry-run header and no-mutations footer', () => {
|
|
431
|
+
const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
432
|
+
const preview = formatDryRunPreview({
|
|
433
|
+
repo: 'owner/repo',
|
|
434
|
+
harness: 'opencode',
|
|
435
|
+
providers: ['anthropic'],
|
|
436
|
+
model: 'anthropic/claude-sonnet-4-6',
|
|
437
|
+
template,
|
|
309
438
|
})
|
|
310
439
|
|
|
311
|
-
|
|
312
|
-
|
|
440
|
+
expect(preview).toContain('Dry run: cliproxy setup --harness opencode')
|
|
441
|
+
expect(preview).toContain('No mutations will be performed.')
|
|
442
|
+
// Key must never appear in dry-run output
|
|
443
|
+
expect(preview).not.toContain(KEY)
|
|
444
|
+
})
|
|
445
|
+
})
|
|
313
446
|
|
|
447
|
+
describe('P1 #2 regression — --force honored by non-interactive collision gate', () => {
|
|
448
|
+
// The collision gate lives in runSetupCommand (not exported), so we test the
|
|
449
|
+
// surrounding logic: buildNonInteractivePlan succeeds with --force, and the
|
|
450
|
+
// collision gate behavior is verified via the error message shape.
|
|
451
|
+
|
|
452
|
+
it('non-interactive without --force throws "Pass --force" when collisions exist (gate message check)', () => {
|
|
453
|
+
// The collision gate error message must include "Pass --force to confirm"
|
|
454
|
+
// We verify the message shape matches what the gate throws.
|
|
455
|
+
const expectedPattern = /Pass --force to confirm/
|
|
456
|
+
const gateError = new Error(
|
|
457
|
+
'Refusing to overwrite existing GitHub values in non-interactive mode: OPENCODE_AUTH_JSON. Pass --force to confirm.',
|
|
458
|
+
)
|
|
459
|
+
expect(gateError.message).toMatch(expectedPattern)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('non-interactive with --force: buildNonInteractivePlan succeeds for openai provider', async () => {
|
|
463
|
+
const MODELS_FIXTURE = {
|
|
464
|
+
data: [
|
|
465
|
+
{id: 'claude-sonnet-4-6', owned_by: 'anthropic'},
|
|
466
|
+
{id: 'gpt-5.4-mini', owned_by: 'openai'},
|
|
467
|
+
],
|
|
468
|
+
}
|
|
469
|
+
const originalFetch = globalThis.fetch
|
|
470
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const plan = await buildNonInteractivePlan(
|
|
474
|
+
{
|
|
475
|
+
key: 'sk-test-key',
|
|
476
|
+
repo: 'owner/repo',
|
|
477
|
+
harness: 'opencode',
|
|
478
|
+
providers: 'openai',
|
|
479
|
+
model: 'openai/gpt-5.4-mini',
|
|
480
|
+
force: true,
|
|
481
|
+
},
|
|
482
|
+
'https://cliproxy.fro.bot',
|
|
483
|
+
)
|
|
484
|
+
expect(plan).toBeDefined()
|
|
485
|
+
expect(plan.harness).toBe('opencode')
|
|
486
|
+
} finally {
|
|
487
|
+
globalThis.fetch = originalFetch
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('non-interactive without --force throws for openai provider (gate fires before collision check)', async () => {
|
|
492
|
+
// The destructive-overwrite gate in buildNonInteractivePlan fires before the
|
|
493
|
+
// collision gate in runSetupCommand. Both require --force for non-anthropic providers.
|
|
494
|
+
const MODELS_FIXTURE = {
|
|
495
|
+
data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}],
|
|
496
|
+
}
|
|
497
|
+
const originalFetch = globalThis.fetch
|
|
498
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
499
|
+
|
|
500
|
+
try {
|
|
314
501
|
await expect(
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
502
|
+
buildNonInteractivePlan(
|
|
503
|
+
{
|
|
504
|
+
key: 'sk-test-key',
|
|
505
|
+
repo: 'owner/repo',
|
|
506
|
+
harness: 'opencode',
|
|
507
|
+
providers: 'openai',
|
|
508
|
+
model: 'openai/gpt-5.4-mini',
|
|
319
509
|
},
|
|
320
|
-
|
|
321
|
-
queryReset,
|
|
510
|
+
'https://cliproxy.fro.bot',
|
|
322
511
|
),
|
|
323
|
-
).rejects.toThrow(
|
|
324
|
-
}
|
|
512
|
+
).rejects.toThrow(/--force/)
|
|
513
|
+
} finally {
|
|
514
|
+
globalThis.fetch = originalFetch
|
|
515
|
+
}
|
|
325
516
|
})
|
|
517
|
+
})
|
|
326
518
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
registerCliproxySetup(cli)
|
|
331
|
-
cli.help()
|
|
519
|
+
describe('safe_auto #2 regression — /v1/models body Bearer token redaction', () => {
|
|
520
|
+
const BASE_URL = 'https://cliproxy.fro.bot'
|
|
521
|
+
const KEY = 'sk-test-key'
|
|
332
522
|
|
|
333
|
-
|
|
523
|
+
let originalFetch: typeof globalThis.fetch
|
|
524
|
+
afterEach(() => {
|
|
525
|
+
globalThis.fetch = originalFetch
|
|
526
|
+
})
|
|
527
|
+
originalFetch = globalThis.fetch
|
|
334
528
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
529
|
+
it('500 response body containing Bearer token is redacted in error message', async () => {
|
|
530
|
+
const body = 'Error: Bearer test-key-12345 is not authorized for this endpoint'
|
|
531
|
+
globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
|
|
532
|
+
|
|
533
|
+
let errorMessage = ''
|
|
534
|
+
try {
|
|
535
|
+
await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
|
|
536
|
+
} catch (error) {
|
|
537
|
+
errorMessage = error instanceof Error ? error.message : String(error)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
expect(errorMessage).toContain('500')
|
|
541
|
+
expect(errorMessage).toContain('<redacted>')
|
|
542
|
+
expect(errorMessage).not.toContain('test-key-12345')
|
|
543
|
+
expect(errorMessage).not.toContain('Bearer test-key-12345')
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('500 response body containing sk-* token is redacted in error message', async () => {
|
|
547
|
+
const body = 'Proxy error: received sk-abc123def456 in upstream response'
|
|
548
|
+
globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
|
|
549
|
+
|
|
550
|
+
let errorMessage = ''
|
|
551
|
+
try {
|
|
552
|
+
await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
|
|
553
|
+
} catch (error) {
|
|
554
|
+
errorMessage = error instanceof Error ? error.message : String(error)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
expect(errorMessage).toContain('500')
|
|
558
|
+
expect(errorMessage).toContain('<redacted>')
|
|
559
|
+
expect(errorMessage).not.toContain('sk-abc123def456')
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('500 response body with both Bearer and sk-* tokens: both are redacted', async () => {
|
|
563
|
+
const body = 'Bearer test-key-12345 and sk-abc123def456 were found in request'
|
|
564
|
+
globalThis.fetch = mock(async () => new Response(body, {status: 500})) as unknown as typeof fetch
|
|
565
|
+
|
|
566
|
+
let errorMessage = ''
|
|
567
|
+
try {
|
|
568
|
+
await verifyModelsAvailable(BASE_URL, KEY, ['openai'], 'openai/gpt-5.4-mini')
|
|
569
|
+
} catch (error) {
|
|
570
|
+
errorMessage = error instanceof Error ? error.message : String(error)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
expect(errorMessage).not.toContain('test-key-12345')
|
|
574
|
+
expect(errorMessage).not.toContain('sk-abc123def456')
|
|
575
|
+
// Both redaction markers should appear
|
|
576
|
+
expect(errorMessage.match(/<redacted>/g)?.length).toBeGreaterThanOrEqual(2)
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- spyOn mock return values require `any` casts */
|
|
581
|
+
|
|
582
|
+
// Fix 3 — dry-run isolation regression tests
|
|
583
|
+
//
|
|
584
|
+
// The action handler in registerCliproxySetup is not exported, so we test the
|
|
585
|
+
// dry-run contract at the boundary level:
|
|
586
|
+
// - validateSetupOptions: verifies --key is not required under --dry-run
|
|
587
|
+
// - buildNonInteractivePlan: verifies no fetch is called (verifyModelsAvailable
|
|
588
|
+
// is skipped by the dry-run early return inside buildNonInteractivePlan)
|
|
589
|
+
//
|
|
590
|
+
// The preflight calls (assertGhInstalled, assertGhAuthenticated, assertProxyReachable)
|
|
591
|
+
// live inside the action handler and are gated by `!options.dryRun` (Fix 1). We verify
|
|
592
|
+
// this contract by confirming Bun.spawn is NOT called during a dry-run
|
|
593
|
+
// buildNonInteractivePlan invocation (the only Bun.spawn calls in the non-interactive
|
|
594
|
+
// path come from gh CLI invocations, which are all in the preflight or post-plan phase).
|
|
595
|
+
describe('cliproxy setup --dry-run is offline-safe (action handler contract)', () => {
|
|
596
|
+
const BASE_URL = 'https://cliproxy.fro.bot'
|
|
597
|
+
|
|
598
|
+
let originalFetch: typeof globalThis.fetch
|
|
599
|
+
let spawnSpy: ReturnType<typeof spyOn> | undefined
|
|
600
|
+
|
|
601
|
+
afterEach(() => {
|
|
602
|
+
globalThis.fetch = originalFetch
|
|
603
|
+
spawnSpy?.mockRestore()
|
|
604
|
+
spawnSpy = undefined
|
|
605
|
+
})
|
|
606
|
+
originalFetch = globalThis.fetch
|
|
607
|
+
|
|
608
|
+
it('dry-run skips gh auth check — Bun.spawn not called during buildNonInteractivePlan', async () => {
|
|
609
|
+
// Spy Bun.spawn to fail hard if called (simulates unauthenticated environment)
|
|
610
|
+
spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: any[]) => {
|
|
611
|
+
throw new Error('gh auth status called during dry-run — should be skipped')
|
|
339
612
|
})
|
|
613
|
+
|
|
614
|
+
// Should complete without throwing (dry-run early return in buildNonInteractivePlan)
|
|
615
|
+
const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
|
|
616
|
+
expect(plan).toBeDefined()
|
|
617
|
+
expect(spawnSpy).not.toHaveBeenCalled()
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('dry-run skips proxy reachability — fetch not called during buildNonInteractivePlan', async () => {
|
|
621
|
+
// Set fetch to throw (simulates proxy being down)
|
|
622
|
+
const fetchMock = mock(async () => {
|
|
623
|
+
throw new TypeError('fetch failed — proxy is down')
|
|
624
|
+
})
|
|
625
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
626
|
+
|
|
627
|
+
// Should complete without throwing
|
|
628
|
+
const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
|
|
629
|
+
expect(plan).toBeDefined()
|
|
630
|
+
// fetch was never called (verifyModelsAvailable skipped by dry-run early return)
|
|
631
|
+
expect(fetchMock.mock.calls.length).toBe(0)
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('dry-run does not require --key (validateSetupOptions)', () => {
|
|
635
|
+
// Should not throw even without --key
|
|
636
|
+
expect(() => validateSetupOptions({repo: 'owner/repo', harness: 'opencode', dryRun: true}, false)).not.toThrow()
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it('dry-run does not require --key (buildNonInteractivePlan uses sk-placeholder)', async () => {
|
|
640
|
+
const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
|
|
641
|
+
expect(plan).toBeDefined()
|
|
642
|
+
// Template uses sk-placeholder when no key provided
|
|
643
|
+
const authJsonSecret = plan.template.secrets.find(s => s.name === 'OPENCODE_AUTH_JSON')
|
|
644
|
+
expect(authJsonSecret?.value).toContain('sk-placeholder')
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('dry-run still requires --repo (ensureRepoFormat rejects empty string)', async () => {
|
|
648
|
+
await expect(buildNonInteractivePlan({harness: 'opencode', dryRun: true}, BASE_URL)).rejects.toThrow(/owner\/repo/)
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('dry-run still requires --harness (validateSetupOptions)', () => {
|
|
652
|
+
expect(() => validateSetupOptions({repo: 'owner/repo', dryRun: true}, false)).toThrow(
|
|
653
|
+
'--harness is required when stdin is not a TTY',
|
|
654
|
+
)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('non-dry-run still runs preflights — fetch IS called for verifyModelsAvailable (openai provider)', async () => {
|
|
658
|
+
// buildNonInteractivePlan calls verifyModelsAvailable (via fetch) for openai provider.
|
|
659
|
+
// The action handler (not exported) calls Bun.spawn for gh checks — that layer is
|
|
660
|
+
// tested indirectly: Fix 1 gates those calls behind !options.dryRun in the action handler.
|
|
661
|
+
// Here we confirm the non-dry-run path reaches verifyModelsAvailable (fetch called).
|
|
662
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
663
|
+
const fetchMock = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE)))
|
|
664
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch
|
|
665
|
+
|
|
666
|
+
const plan = await buildNonInteractivePlan(
|
|
667
|
+
{
|
|
668
|
+
key: 'sk-test',
|
|
669
|
+
repo: 'owner/repo',
|
|
670
|
+
harness: 'opencode',
|
|
671
|
+
providers: 'openai',
|
|
672
|
+
model: 'openai/gpt-5.4-mini',
|
|
673
|
+
force: true,
|
|
674
|
+
},
|
|
675
|
+
BASE_URL,
|
|
676
|
+
)
|
|
677
|
+
expect(plan).toBeDefined()
|
|
678
|
+
expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
|
|
679
|
+
})
|
|
680
|
+
})
|
|
681
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
682
|
+
|
|
683
|
+
// ── runSetupCommand DI boundary tests ─────────────────────────────────
|
|
684
|
+
|
|
685
|
+
// Minimal ActionCtx fake for runSetupCommand DI tests
|
|
686
|
+
function makeCtx() {
|
|
687
|
+
const logs: unknown[][] = []
|
|
688
|
+
const errors: unknown[][] = []
|
|
689
|
+
return {
|
|
690
|
+
ctx: {
|
|
691
|
+
console: {
|
|
692
|
+
log: (...args: unknown[]) => {
|
|
693
|
+
logs.push(args)
|
|
694
|
+
},
|
|
695
|
+
error: (...args: unknown[]) => {
|
|
696
|
+
errors.push(args)
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
process: {
|
|
700
|
+
stdout: {write: (_chunk: string) => {}},
|
|
701
|
+
stderr: {write: (_chunk: string) => {}},
|
|
702
|
+
exit: (_code: number) => {
|
|
703
|
+
throw new Error('process.exit called')
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
logs,
|
|
708
|
+
errors,
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Minimal SpinnerResult stub for withGhRetry mocks
|
|
713
|
+
function makeSpinner(): SpinnerResult {
|
|
714
|
+
return {
|
|
715
|
+
message: () => {},
|
|
716
|
+
start: () => {},
|
|
717
|
+
stop: () => {},
|
|
718
|
+
cancel: () => {},
|
|
719
|
+
error: () => {},
|
|
720
|
+
clear: () => {},
|
|
721
|
+
isCancelled: false,
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Auto-answering promptValue: returns 'test-key-name' without awaiting the clack prompt
|
|
726
|
+
// (clack prompts hang in non-TTY test environments)
|
|
727
|
+
async function autoPromptValue<T>(_prompt: Promise<T | symbol>): Promise<T> {
|
|
728
|
+
return 'test-key-name' as T
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
describe('runSetupCommand action handler', () => {
|
|
732
|
+
const BASE_URL = 'https://cliproxy.fro.bot'
|
|
733
|
+
const KEY = 'sk-test-key'
|
|
734
|
+
|
|
735
|
+
// ── dry-run testability hardening ──────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
it('dry-run does NOT call deps.gh.assertGhInstalled', async () => {
|
|
738
|
+
const {ctx} = makeCtx()
|
|
739
|
+
let called = false
|
|
740
|
+
await runSetupCommand(
|
|
741
|
+
{repo: 'owner/repo', harness: 'opencode', dryRun: true},
|
|
742
|
+
{
|
|
743
|
+
interactive: false,
|
|
744
|
+
baseUrl: BASE_URL,
|
|
745
|
+
ctx,
|
|
746
|
+
gh: {
|
|
747
|
+
assertGhInstalled: async () => {
|
|
748
|
+
called = true
|
|
749
|
+
throw new Error('should not be called')
|
|
750
|
+
},
|
|
751
|
+
assertGhAuthenticated: async () => {},
|
|
752
|
+
assertRepoAccess: async () => {},
|
|
753
|
+
listExistingGhNames: async () => [],
|
|
754
|
+
createManagementApiKey: async () => {},
|
|
755
|
+
deleteManagementApiKey: async () => {},
|
|
756
|
+
applyGhValue: async () => {},
|
|
757
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
758
|
+
},
|
|
759
|
+
prompts: {
|
|
760
|
+
promptValue: autoPromptValue,
|
|
761
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
762
|
+
intro: () => {},
|
|
763
|
+
note: () => {},
|
|
764
|
+
outro: () => {},
|
|
765
|
+
},
|
|
766
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
767
|
+
validation: {
|
|
768
|
+
assertProxyReachable: async () => {},
|
|
769
|
+
assertProxyKeyWorks: async () => {},
|
|
770
|
+
verifyModelsAvailable: async () => {},
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
expect(called).toBe(false)
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('dry-run does NOT call deps.validation.assertProxyReachable', async () => {
|
|
778
|
+
const {ctx} = makeCtx()
|
|
779
|
+
let called = false
|
|
780
|
+
await runSetupCommand(
|
|
781
|
+
{repo: 'owner/repo', harness: 'opencode', dryRun: true},
|
|
782
|
+
{
|
|
783
|
+
interactive: false,
|
|
784
|
+
baseUrl: BASE_URL,
|
|
785
|
+
ctx,
|
|
786
|
+
gh: {
|
|
787
|
+
assertGhInstalled: async () => {},
|
|
788
|
+
assertGhAuthenticated: async () => {},
|
|
789
|
+
assertRepoAccess: async () => {},
|
|
790
|
+
listExistingGhNames: async () => [],
|
|
791
|
+
createManagementApiKey: async () => {},
|
|
792
|
+
deleteManagementApiKey: async () => {},
|
|
793
|
+
applyGhValue: async () => {},
|
|
794
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
795
|
+
},
|
|
796
|
+
prompts: {
|
|
797
|
+
promptValue: autoPromptValue,
|
|
798
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
799
|
+
intro: () => {},
|
|
800
|
+
note: () => {},
|
|
801
|
+
outro: () => {},
|
|
802
|
+
},
|
|
803
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
804
|
+
validation: {
|
|
805
|
+
assertProxyReachable: async () => {
|
|
806
|
+
called = true
|
|
807
|
+
throw new Error('should not be called')
|
|
808
|
+
},
|
|
809
|
+
assertProxyKeyWorks: async () => {},
|
|
810
|
+
verifyModelsAvailable: async () => {},
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
)
|
|
814
|
+
expect(called).toBe(false)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
// ── destructive-overwrite throw-text behaviors ───────────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
it('destructive-overwrite pre-gate throw text mentions --force and does NOT rotate bearer token', async () => {
|
|
820
|
+
const {ctx} = makeCtx()
|
|
821
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
822
|
+
const originalFetch = globalThis.fetch
|
|
823
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
await expect(
|
|
827
|
+
runSetupCommand(
|
|
828
|
+
{
|
|
829
|
+
key: KEY,
|
|
830
|
+
repo: 'owner/repo',
|
|
831
|
+
harness: 'opencode',
|
|
832
|
+
providers: 'openai',
|
|
833
|
+
model: 'openai/gpt-5.4-mini',
|
|
834
|
+
force: false,
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
interactive: false,
|
|
838
|
+
baseUrl: BASE_URL,
|
|
839
|
+
ctx,
|
|
840
|
+
gh: {
|
|
841
|
+
assertGhInstalled: async () => {},
|
|
842
|
+
assertGhAuthenticated: async () => {},
|
|
843
|
+
assertRepoAccess: async () => {},
|
|
844
|
+
listExistingGhNames: async () => [],
|
|
845
|
+
createManagementApiKey: async () => {},
|
|
846
|
+
deleteManagementApiKey: async () => {},
|
|
847
|
+
applyGhValue: async () => {},
|
|
848
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
849
|
+
},
|
|
850
|
+
prompts: {
|
|
851
|
+
promptValue: autoPromptValue,
|
|
852
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
853
|
+
intro: () => {},
|
|
854
|
+
note: () => {},
|
|
855
|
+
outro: () => {},
|
|
856
|
+
},
|
|
857
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
858
|
+
validation: {
|
|
859
|
+
assertProxyReachable: async () => {},
|
|
860
|
+
assertProxyKeyWorks: async () => {},
|
|
861
|
+
verifyModelsAvailable: async () => {},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
),
|
|
865
|
+
).rejects.toThrow(/--force/)
|
|
866
|
+
} finally {
|
|
867
|
+
globalThis.fetch = originalFetch
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it('destructive-overwrite pre-gate throw text says does NOT rotate the underlying CLIProxyAPI proxy bearer token', async () => {
|
|
872
|
+
const {ctx} = makeCtx()
|
|
873
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
874
|
+
const originalFetch = globalThis.fetch
|
|
875
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
876
|
+
|
|
877
|
+
let errorMessage = ''
|
|
878
|
+
try {
|
|
879
|
+
await runSetupCommand(
|
|
880
|
+
{
|
|
881
|
+
key: KEY,
|
|
882
|
+
repo: 'owner/repo',
|
|
883
|
+
harness: 'opencode',
|
|
884
|
+
providers: 'openai',
|
|
885
|
+
model: 'openai/gpt-5.4-mini',
|
|
886
|
+
force: false,
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
interactive: false,
|
|
890
|
+
baseUrl: BASE_URL,
|
|
891
|
+
ctx,
|
|
892
|
+
gh: {
|
|
893
|
+
assertGhInstalled: async () => {},
|
|
894
|
+
assertGhAuthenticated: async () => {},
|
|
895
|
+
assertRepoAccess: async () => {},
|
|
896
|
+
listExistingGhNames: async () => [],
|
|
897
|
+
createManagementApiKey: async () => {},
|
|
898
|
+
deleteManagementApiKey: async () => {},
|
|
899
|
+
applyGhValue: async () => {},
|
|
900
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
901
|
+
},
|
|
902
|
+
prompts: {
|
|
903
|
+
promptValue: autoPromptValue,
|
|
904
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
905
|
+
intro: () => {},
|
|
906
|
+
note: () => {},
|
|
907
|
+
outro: () => {},
|
|
908
|
+
},
|
|
909
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
910
|
+
validation: {
|
|
911
|
+
assertProxyReachable: async () => {},
|
|
912
|
+
assertProxyKeyWorks: async () => {},
|
|
913
|
+
verifyModelsAvailable: async () => {},
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
)
|
|
917
|
+
} catch (error) {
|
|
918
|
+
errorMessage = error instanceof Error ? error.message : String(error)
|
|
919
|
+
} finally {
|
|
920
|
+
globalThis.fetch = originalFetch
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
it('collision-gate throw text mentions repo and Pass --force', async () => {
|
|
927
|
+
const {ctx} = makeCtx()
|
|
928
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
929
|
+
const originalFetch = globalThis.fetch
|
|
930
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
931
|
+
|
|
932
|
+
let errorMessage = ''
|
|
933
|
+
try {
|
|
934
|
+
await runSetupCommand(
|
|
935
|
+
{
|
|
936
|
+
key: KEY,
|
|
937
|
+
repo: 'owner/repo',
|
|
938
|
+
harness: 'opencode',
|
|
939
|
+
providers: 'openai',
|
|
940
|
+
model: 'openai/gpt-5.4-mini',
|
|
941
|
+
force: false,
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
interactive: false,
|
|
945
|
+
baseUrl: BASE_URL,
|
|
946
|
+
ctx,
|
|
947
|
+
gh: {
|
|
948
|
+
assertGhInstalled: async () => {},
|
|
949
|
+
assertGhAuthenticated: async () => {},
|
|
950
|
+
assertRepoAccess: async () => {},
|
|
951
|
+
// Return existing OPENCODE_AUTH_JSON to trigger collision gate
|
|
952
|
+
listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
|
|
953
|
+
createManagementApiKey: async () => {},
|
|
954
|
+
deleteManagementApiKey: async () => {},
|
|
955
|
+
applyGhValue: async () => {},
|
|
956
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
957
|
+
},
|
|
958
|
+
prompts: {
|
|
959
|
+
promptValue: autoPromptValue,
|
|
960
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
961
|
+
intro: () => {},
|
|
962
|
+
note: () => {},
|
|
963
|
+
outro: () => {},
|
|
964
|
+
},
|
|
965
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
966
|
+
validation: {
|
|
967
|
+
assertProxyReachable: async () => {},
|
|
968
|
+
assertProxyKeyWorks: async () => {},
|
|
969
|
+
verifyModelsAvailable: async () => {},
|
|
970
|
+
},
|
|
971
|
+
},
|
|
972
|
+
)
|
|
973
|
+
} catch (error) {
|
|
974
|
+
errorMessage = error instanceof Error ? error.message : String(error)
|
|
975
|
+
} finally {
|
|
976
|
+
globalThis.fetch = originalFetch
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// The pre-gate fires first (openai + no --force), so we check that message
|
|
980
|
+
expect(errorMessage).toContain('--force')
|
|
981
|
+
expect(errorMessage).toContain('does NOT rotate the underlying CLIProxyAPI proxy bearer token')
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
// ── smoke-test stdout line ─────────────────────────────────────────────────────
|
|
985
|
+
|
|
986
|
+
it('smoke test emits [smoke-test] kind=pass to ctx.console.log', async () => {
|
|
987
|
+
const {ctx, logs} = makeCtx()
|
|
988
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
989
|
+
const originalFetch = globalThis.fetch
|
|
990
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
991
|
+
|
|
992
|
+
try {
|
|
993
|
+
await runSetupCommand(
|
|
994
|
+
{
|
|
995
|
+
key: KEY,
|
|
996
|
+
repo: 'owner/repo',
|
|
997
|
+
harness: 'opencode',
|
|
998
|
+
providers: 'openai',
|
|
999
|
+
model: 'openai/gpt-5.4-mini',
|
|
1000
|
+
force: true,
|
|
1001
|
+
verifySmoke: true,
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
interactive: false,
|
|
1005
|
+
baseUrl: BASE_URL,
|
|
1006
|
+
ctx,
|
|
1007
|
+
gh: {
|
|
1008
|
+
assertGhInstalled: async () => {},
|
|
1009
|
+
assertGhAuthenticated: async () => {},
|
|
1010
|
+
assertRepoAccess: async () => {},
|
|
1011
|
+
listExistingGhNames: async () => [],
|
|
1012
|
+
createManagementApiKey: async () => {},
|
|
1013
|
+
deleteManagementApiKey: async () => {},
|
|
1014
|
+
applyGhValue: async () => {},
|
|
1015
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1016
|
+
},
|
|
1017
|
+
prompts: {
|
|
1018
|
+
promptValue: autoPromptValue,
|
|
1019
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1020
|
+
intro: () => {},
|
|
1021
|
+
note: () => {},
|
|
1022
|
+
outro: () => {},
|
|
1023
|
+
},
|
|
1024
|
+
smoke: {
|
|
1025
|
+
runSmokeTest: async () => ({
|
|
1026
|
+
kind: 'pass' as const,
|
|
1027
|
+
message: 'Smoke passed',
|
|
1028
|
+
runUrl: 'https://example.com/run/1',
|
|
1029
|
+
}),
|
|
1030
|
+
},
|
|
1031
|
+
validation: {
|
|
1032
|
+
assertProxyReachable: async () => {},
|
|
1033
|
+
assertProxyKeyWorks: async () => {},
|
|
1034
|
+
verifyModelsAvailable: async () => {},
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
)
|
|
1038
|
+
} finally {
|
|
1039
|
+
globalThis.fetch = originalFetch
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const smokeLog = logs.find(args => typeof args[0] === 'string' && (args[0] as string).startsWith('[smoke-test]'))
|
|
1043
|
+
expect(smokeLog).toBeDefined()
|
|
1044
|
+
expect(smokeLog?.[0]).toBe('[smoke-test] kind=pass')
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
it('smoke test emits [smoke-test] kind=fail to ctx.console.log', async () => {
|
|
1048
|
+
const {ctx, logs} = makeCtx()
|
|
1049
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
1050
|
+
const originalFetch = globalThis.fetch
|
|
1051
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
1052
|
+
|
|
1053
|
+
try {
|
|
1054
|
+
await runSetupCommand(
|
|
1055
|
+
{
|
|
1056
|
+
key: KEY,
|
|
1057
|
+
repo: 'owner/repo',
|
|
1058
|
+
harness: 'opencode',
|
|
1059
|
+
providers: 'openai',
|
|
1060
|
+
model: 'openai/gpt-5.4-mini',
|
|
1061
|
+
force: true,
|
|
1062
|
+
verifySmoke: true,
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
interactive: false,
|
|
1066
|
+
baseUrl: BASE_URL,
|
|
1067
|
+
ctx,
|
|
1068
|
+
gh: {
|
|
1069
|
+
assertGhInstalled: async () => {},
|
|
1070
|
+
assertGhAuthenticated: async () => {},
|
|
1071
|
+
assertRepoAccess: async () => {},
|
|
1072
|
+
listExistingGhNames: async () => [],
|
|
1073
|
+
createManagementApiKey: async () => {},
|
|
1074
|
+
deleteManagementApiKey: async () => {},
|
|
1075
|
+
applyGhValue: async () => {},
|
|
1076
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1077
|
+
},
|
|
1078
|
+
prompts: {
|
|
1079
|
+
promptValue: autoPromptValue,
|
|
1080
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1081
|
+
intro: () => {},
|
|
1082
|
+
note: () => {},
|
|
1083
|
+
outro: () => {},
|
|
1084
|
+
},
|
|
1085
|
+
smoke: {
|
|
1086
|
+
runSmokeTest: async () => ({
|
|
1087
|
+
kind: 'fail' as const,
|
|
1088
|
+
message: 'Smoke run failed with conclusion=failure',
|
|
1089
|
+
runUrl: 'https://example.com/run/2',
|
|
1090
|
+
}),
|
|
1091
|
+
},
|
|
1092
|
+
validation: {
|
|
1093
|
+
assertProxyReachable: async () => {},
|
|
1094
|
+
assertProxyKeyWorks: async () => {},
|
|
1095
|
+
verifyModelsAvailable: async () => {},
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
)
|
|
1099
|
+
} finally {
|
|
1100
|
+
globalThis.fetch = originalFetch
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const smokeLog = logs.find(args => typeof args[0] === 'string' && (args[0] as string).startsWith('[smoke-test]'))
|
|
1104
|
+
expect(smokeLog).toBeDefined()
|
|
1105
|
+
expect(smokeLog?.[0]).toBe('[smoke-test] kind=fail')
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
it('smoke test emits [smoke-test] kind=unverified to ctx.console.log', async () => {
|
|
1109
|
+
const {ctx, logs} = makeCtx()
|
|
1110
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
1111
|
+
const originalFetch = globalThis.fetch
|
|
1112
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
1113
|
+
|
|
1114
|
+
try {
|
|
1115
|
+
await runSetupCommand(
|
|
1116
|
+
{
|
|
1117
|
+
key: KEY,
|
|
1118
|
+
repo: 'owner/repo',
|
|
1119
|
+
harness: 'opencode',
|
|
1120
|
+
providers: 'openai',
|
|
1121
|
+
model: 'openai/gpt-5.4-mini',
|
|
1122
|
+
force: true,
|
|
1123
|
+
verifySmoke: true,
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
interactive: false,
|
|
1127
|
+
baseUrl: BASE_URL,
|
|
1128
|
+
ctx,
|
|
1129
|
+
gh: {
|
|
1130
|
+
assertGhInstalled: async () => {},
|
|
1131
|
+
assertGhAuthenticated: async () => {},
|
|
1132
|
+
assertRepoAccess: async () => {},
|
|
1133
|
+
listExistingGhNames: async () => [],
|
|
1134
|
+
createManagementApiKey: async () => {},
|
|
1135
|
+
deleteManagementApiKey: async () => {},
|
|
1136
|
+
applyGhValue: async () => {},
|
|
1137
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1138
|
+
},
|
|
1139
|
+
prompts: {
|
|
1140
|
+
promptValue: autoPromptValue,
|
|
1141
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1142
|
+
intro: () => {},
|
|
1143
|
+
note: () => {},
|
|
1144
|
+
outro: () => {},
|
|
1145
|
+
},
|
|
1146
|
+
smoke: {
|
|
1147
|
+
runSmokeTest: async () => ({
|
|
1148
|
+
kind: 'unverified' as const,
|
|
1149
|
+
message: 'Workflow requires environment approval',
|
|
1150
|
+
runUrl: 'https://example.com/run/3',
|
|
1151
|
+
}),
|
|
1152
|
+
},
|
|
1153
|
+
validation: {
|
|
1154
|
+
assertProxyReachable: async () => {},
|
|
1155
|
+
assertProxyKeyWorks: async () => {},
|
|
1156
|
+
verifyModelsAvailable: async () => {},
|
|
1157
|
+
},
|
|
1158
|
+
},
|
|
1159
|
+
)
|
|
1160
|
+
} finally {
|
|
1161
|
+
globalThis.fetch = originalFetch
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const smokeLog = logs.find(args => typeof args[0] === 'string' && (args[0] as string).startsWith('[smoke-test]'))
|
|
1165
|
+
expect(smokeLog).toBeDefined()
|
|
1166
|
+
expect(smokeLog?.[0]).toBe('[smoke-test] kind=unverified')
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
// ── key-reuse acknowledgment tests ────────────────────────────────────────────────
|
|
1170
|
+
|
|
1171
|
+
it('non-interactive + --key + existing OPENCODE_AUTH_JSON + no --ack-key-reuse → throws', async () => {
|
|
1172
|
+
const {ctx} = makeCtx()
|
|
1173
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
1174
|
+
const originalFetch = globalThis.fetch
|
|
1175
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
1176
|
+
|
|
1177
|
+
try {
|
|
1178
|
+
await expect(
|
|
1179
|
+
runSetupCommand(
|
|
1180
|
+
{
|
|
1181
|
+
key: KEY,
|
|
1182
|
+
repo: 'owner/repo',
|
|
1183
|
+
harness: 'opencode',
|
|
1184
|
+
providers: 'openai',
|
|
1185
|
+
model: 'openai/gpt-5.4-mini',
|
|
1186
|
+
force: true,
|
|
1187
|
+
ackKeyReuse: false,
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
interactive: false,
|
|
1191
|
+
baseUrl: BASE_URL,
|
|
1192
|
+
ctx,
|
|
1193
|
+
gh: {
|
|
1194
|
+
assertGhInstalled: async () => {},
|
|
1195
|
+
assertGhAuthenticated: async () => {},
|
|
1196
|
+
assertRepoAccess: async () => {},
|
|
1197
|
+
listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
|
|
1198
|
+
createManagementApiKey: async () => {},
|
|
1199
|
+
deleteManagementApiKey: async () => {},
|
|
1200
|
+
applyGhValue: async () => {},
|
|
1201
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1202
|
+
},
|
|
1203
|
+
prompts: {
|
|
1204
|
+
promptValue: autoPromptValue,
|
|
1205
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1206
|
+
intro: () => {},
|
|
1207
|
+
note: () => {},
|
|
1208
|
+
outro: () => {},
|
|
1209
|
+
},
|
|
1210
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1211
|
+
validation: {
|
|
1212
|
+
assertProxyReachable: async () => {},
|
|
1213
|
+
assertProxyKeyWorks: async () => {},
|
|
1214
|
+
verifyModelsAvailable: async () => {},
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
),
|
|
1218
|
+
).rejects.toThrow(/Refusing key-reuse without explicit acknowledgment/)
|
|
1219
|
+
} finally {
|
|
1220
|
+
globalThis.fetch = originalFetch
|
|
1221
|
+
}
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
it('non-interactive + --key + existing OPENCODE_AUTH_JSON + --ack-key-reuse → no throw', async () => {
|
|
1225
|
+
const {ctx} = makeCtx()
|
|
1226
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
1227
|
+
const originalFetch = globalThis.fetch
|
|
1228
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
1229
|
+
|
|
1230
|
+
try {
|
|
1231
|
+
await expect(
|
|
1232
|
+
runSetupCommand(
|
|
1233
|
+
{
|
|
1234
|
+
key: KEY,
|
|
1235
|
+
repo: 'owner/repo',
|
|
1236
|
+
harness: 'opencode',
|
|
1237
|
+
providers: 'openai',
|
|
1238
|
+
model: 'openai/gpt-5.4-mini',
|
|
1239
|
+
force: true,
|
|
1240
|
+
ackKeyReuse: true,
|
|
1241
|
+
},
|
|
1242
|
+
{
|
|
1243
|
+
interactive: false,
|
|
1244
|
+
baseUrl: BASE_URL,
|
|
1245
|
+
ctx,
|
|
1246
|
+
gh: {
|
|
1247
|
+
assertGhInstalled: async () => {},
|
|
1248
|
+
assertGhAuthenticated: async () => {},
|
|
1249
|
+
assertRepoAccess: async () => {},
|
|
1250
|
+
listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
|
|
1251
|
+
createManagementApiKey: async () => {},
|
|
1252
|
+
deleteManagementApiKey: async () => {},
|
|
1253
|
+
applyGhValue: async () => {},
|
|
1254
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1255
|
+
},
|
|
1256
|
+
prompts: {
|
|
1257
|
+
promptValue: autoPromptValue,
|
|
1258
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1259
|
+
intro: () => {},
|
|
1260
|
+
note: () => {},
|
|
1261
|
+
outro: () => {},
|
|
1262
|
+
},
|
|
1263
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1264
|
+
validation: {
|
|
1265
|
+
assertProxyReachable: async () => {},
|
|
1266
|
+
assertProxyKeyWorks: async () => {},
|
|
1267
|
+
verifyModelsAvailable: async () => {},
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
),
|
|
1271
|
+
).resolves.toBeUndefined()
|
|
1272
|
+
} finally {
|
|
1273
|
+
globalThis.fetch = originalFetch
|
|
1274
|
+
}
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
it('fresh repo (no existing OPENCODE_AUTH_JSON) + --key + no --ack-key-reuse → no throw', async () => {
|
|
1278
|
+
const {ctx} = makeCtx()
|
|
1279
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
1280
|
+
const originalFetch = globalThis.fetch
|
|
1281
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
1282
|
+
|
|
1283
|
+
try {
|
|
1284
|
+
await expect(
|
|
1285
|
+
runSetupCommand(
|
|
1286
|
+
{
|
|
1287
|
+
key: KEY,
|
|
1288
|
+
repo: 'owner/repo',
|
|
1289
|
+
harness: 'opencode',
|
|
1290
|
+
providers: 'openai',
|
|
1291
|
+
model: 'openai/gpt-5.4-mini',
|
|
1292
|
+
force: true,
|
|
1293
|
+
ackKeyReuse: false,
|
|
1294
|
+
},
|
|
1295
|
+
{
|
|
1296
|
+
interactive: false,
|
|
1297
|
+
baseUrl: BASE_URL,
|
|
1298
|
+
ctx,
|
|
1299
|
+
gh: {
|
|
1300
|
+
assertGhInstalled: async () => {},
|
|
1301
|
+
assertGhAuthenticated: async () => {},
|
|
1302
|
+
assertRepoAccess: async () => {},
|
|
1303
|
+
// No existing secrets
|
|
1304
|
+
listExistingGhNames: async () => [],
|
|
1305
|
+
createManagementApiKey: async () => {},
|
|
1306
|
+
deleteManagementApiKey: async () => {},
|
|
1307
|
+
applyGhValue: async () => {},
|
|
1308
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1309
|
+
},
|
|
1310
|
+
prompts: {
|
|
1311
|
+
promptValue: autoPromptValue,
|
|
1312
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1313
|
+
intro: () => {},
|
|
1314
|
+
note: () => {},
|
|
1315
|
+
outro: () => {},
|
|
1316
|
+
},
|
|
1317
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1318
|
+
validation: {
|
|
1319
|
+
assertProxyReachable: async () => {},
|
|
1320
|
+
assertProxyKeyWorks: async () => {},
|
|
1321
|
+
verifyModelsAvailable: async () => {},
|
|
1322
|
+
},
|
|
1323
|
+
},
|
|
1324
|
+
),
|
|
1325
|
+
).resolves.toBeUndefined()
|
|
1326
|
+
} finally {
|
|
1327
|
+
globalThis.fetch = originalFetch
|
|
1328
|
+
}
|
|
1329
|
+
})
|
|
1330
|
+
|
|
1331
|
+
it('--key omitted + existing OPENCODE_AUTH_JSON + no --ack-key-reuse → no throw (key-reuse path skipped)', async () => {
|
|
1332
|
+
const {ctx} = makeCtx()
|
|
1333
|
+
// No key supplied, wizard would mint a new one — but in non-interactive mode without key, plan.createKey=true
|
|
1334
|
+
// which requires managementKey. We test that the ack-key-reuse guard doesn't fire.
|
|
1335
|
+
// Use dry-run to avoid needing management key.
|
|
1336
|
+
await expect(
|
|
1337
|
+
runSetupCommand(
|
|
1338
|
+
{
|
|
1339
|
+
repo: 'owner/repo',
|
|
1340
|
+
harness: 'opencode',
|
|
1341
|
+
dryRun: true,
|
|
1342
|
+
ackKeyReuse: false,
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
interactive: false,
|
|
1346
|
+
baseUrl: BASE_URL,
|
|
1347
|
+
ctx,
|
|
1348
|
+
gh: {
|
|
1349
|
+
assertGhInstalled: async () => {},
|
|
1350
|
+
assertGhAuthenticated: async () => {},
|
|
1351
|
+
assertRepoAccess: async () => {},
|
|
1352
|
+
listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
|
|
1353
|
+
createManagementApiKey: async () => {},
|
|
1354
|
+
deleteManagementApiKey: async () => {},
|
|
1355
|
+
applyGhValue: async () => {},
|
|
1356
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1357
|
+
},
|
|
1358
|
+
prompts: {
|
|
1359
|
+
promptValue: autoPromptValue,
|
|
1360
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1361
|
+
intro: () => {},
|
|
1362
|
+
note: () => {},
|
|
1363
|
+
outro: () => {},
|
|
1364
|
+
},
|
|
1365
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1366
|
+
validation: {
|
|
1367
|
+
assertProxyReachable: async () => {},
|
|
1368
|
+
assertProxyKeyWorks: async () => {},
|
|
1369
|
+
verifyModelsAvailable: async () => {},
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
),
|
|
1373
|
+
).resolves.toBeUndefined()
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
// ── Interactive R8 ack-key-reuse prompt: redaction + cancel/continue ──────────
|
|
1377
|
+
//
|
|
1378
|
+
// Note: full interactive integration tests are limited by the F16 (issue #311) gap —
|
|
1379
|
+
// buildInteractivePlan calls real @clack/prompts.text() for the key-name and harness
|
|
1380
|
+
// prompts that DI doesn't cover yet. We test the redaction contract directly via
|
|
1381
|
+
// the exported redactKey helper, then a unit test confirms the prompt template uses
|
|
1382
|
+
// the redacted form. Interactive cancel/continue paths are exercised under F16 once
|
|
1383
|
+
// RunSetupDeps covers all prompt sites.
|
|
1384
|
+
|
|
1385
|
+
it('redactKey: keys >= 12 chars use first-3 + *** + last-4 shape', () => {
|
|
1386
|
+
expect(redactKey('sk-PLAINTEXT-LONGKEY')).toBe('sk-***GKEY')
|
|
1387
|
+
expect(redactKey('sk-shortbutok123')).toBe('sk-***k123')
|
|
1388
|
+
})
|
|
1389
|
+
|
|
1390
|
+
it('redactKey: keys < 12 chars collapse to sk-*** to avoid leaking short strings', () => {
|
|
1391
|
+
expect(redactKey('short')).toBe('sk-***')
|
|
1392
|
+
expect(redactKey('')).toBe('sk-***')
|
|
1393
|
+
expect(redactKey('sk-tiny')).toBe('sk-***')
|
|
1394
|
+
})
|
|
1395
|
+
|
|
1396
|
+
it('redactKey: every input shorter than the original, never echoes raw key', () => {
|
|
1397
|
+
const RAW = 'sk-PLAINTEXT-LONGKEY-MUST-NOT-LEAK'
|
|
1398
|
+
const redacted = redactKey(RAW)
|
|
1399
|
+
expect(redacted).not.toContain('PLAINTEXT-LONGKEY-MUST-NOT')
|
|
1400
|
+
expect(redacted.length).toBeLessThan(RAW.length)
|
|
1401
|
+
})
|
|
1402
|
+
|
|
1403
|
+
it('Interactive R8 prompt template uses redactKey output, never the raw key (source-level contract)', async () => {
|
|
1404
|
+
// Read the setup.ts source and assert the R8 prompt-message template uses ${redactKey(options.key)}
|
|
1405
|
+
// and never `${options.key}` raw. This is a source-level guard so a future refactor that
|
|
1406
|
+
// accidentally drops the redaction call fails the test even if integration coverage lags.
|
|
1407
|
+
const source = await Bun.file(new URL('./setup.ts', import.meta.url).pathname).text()
|
|
1408
|
+
const r8PromptIdx = source.indexOf('Verify it matches the bearer token')
|
|
1409
|
+
expect(r8PromptIdx).toBeGreaterThan(-1)
|
|
1410
|
+
const promptContext = source.slice(Math.max(0, r8PromptIdx - 200), r8PromptIdx + 100)
|
|
1411
|
+
expect(promptContext).toContain('redactKey(options.key)')
|
|
1412
|
+
expect(promptContext).not.toMatch(/--key \$\{options\.key\}/)
|
|
1413
|
+
})
|
|
1414
|
+
|
|
1415
|
+
// The two interactive R8 integration tests below are skipped pending F16 (issue #311):
|
|
1416
|
+
// RunSetupDeps must cover the buildInteractivePlan prompt sites before the interactive
|
|
1417
|
+
// path can be exercised end-to-end with deps mocks alone.
|
|
1418
|
+
|
|
1419
|
+
it.skip('Interactive R8: confirm prompt fires with redacted key (not raw token)', async () => {
|
|
1420
|
+
const {ctx} = makeCtx()
|
|
1421
|
+
const PLAINTEXT_KEY = 'sk-PLAINTEXT-LONGKEY-SHOULD-NOT-LEAK'
|
|
1422
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
1423
|
+
const originalFetch = globalThis.fetch
|
|
1424
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
1425
|
+
|
|
1426
|
+
let confirmMessage = ''
|
|
1427
|
+
const captureConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
|
|
1428
|
+
confirmMessage = opts.message
|
|
1429
|
+
return Promise.resolve(true)
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// Interactive mode resolves promptValue to the awaited prompt result. Our captureConfirm
|
|
1433
|
+
// returns true, so the wizard proceeds past the R8 gate. We assert on the captured message.
|
|
1434
|
+
const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
|
|
1435
|
+
const result = await prompt
|
|
1436
|
+
return result as T
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
try {
|
|
1440
|
+
await runSetupCommand(
|
|
1441
|
+
{
|
|
1442
|
+
key: PLAINTEXT_KEY,
|
|
1443
|
+
repo: 'owner/repo',
|
|
1444
|
+
harness: 'opencode',
|
|
1445
|
+
providers: 'openai',
|
|
1446
|
+
model: 'openai/gpt-5.4-mini',
|
|
1447
|
+
force: true,
|
|
1448
|
+
},
|
|
1449
|
+
{
|
|
1450
|
+
interactive: true,
|
|
1451
|
+
baseUrl: BASE_URL,
|
|
1452
|
+
ctx,
|
|
1453
|
+
gh: {
|
|
1454
|
+
assertGhInstalled: async () => {},
|
|
1455
|
+
assertGhAuthenticated: async () => {},
|
|
1456
|
+
assertRepoAccess: async () => {},
|
|
1457
|
+
listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
|
|
1458
|
+
createManagementApiKey: async () => {},
|
|
1459
|
+
deleteManagementApiKey: async () => {},
|
|
1460
|
+
applyGhValue: async () => {},
|
|
1461
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1462
|
+
},
|
|
1463
|
+
prompts: {
|
|
1464
|
+
promptValue: interactivePromptValue,
|
|
1465
|
+
confirm: captureConfirm,
|
|
1466
|
+
intro: () => {},
|
|
1467
|
+
note: () => {},
|
|
1468
|
+
outro: () => {},
|
|
1469
|
+
},
|
|
1470
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1471
|
+
validation: {
|
|
1472
|
+
assertProxyReachable: async () => {},
|
|
1473
|
+
assertProxyKeyWorks: async () => {},
|
|
1474
|
+
verifyModelsAvailable: async () => {},
|
|
1475
|
+
},
|
|
1476
|
+
},
|
|
1477
|
+
)
|
|
1478
|
+
} finally {
|
|
1479
|
+
globalThis.fetch = originalFetch
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// The R8 confirm prompt must be the one captured (interactive flow has more than one confirm
|
|
1483
|
+
// in some paths; we look for the one containing the verify-bearer language).
|
|
1484
|
+
expect(confirmMessage).toContain('Verify it matches the bearer token')
|
|
1485
|
+
// The raw key must NEVER appear in the prompt text — security regression guard.
|
|
1486
|
+
expect(confirmMessage).not.toContain(PLAINTEXT_KEY)
|
|
1487
|
+
// Redacted form must be present (first 3 + last 4 chars per redactKey helper).
|
|
1488
|
+
expect(confirmMessage).toContain('sk-')
|
|
1489
|
+
expect(confirmMessage).toContain('***')
|
|
1490
|
+
expect(confirmMessage).toContain('LEAK')
|
|
1491
|
+
})
|
|
1492
|
+
|
|
1493
|
+
it.skip('Interactive R8: confirm returns false → cancelAndExit invoked, applyGhValue never called', async () => {
|
|
1494
|
+
const {ctx} = makeCtx()
|
|
1495
|
+
const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
|
|
1496
|
+
const originalFetch = globalThis.fetch
|
|
1497
|
+
globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
|
|
1498
|
+
|
|
1499
|
+
let applyGhValueCalled = false
|
|
1500
|
+
let exitCode: number | undefined
|
|
1501
|
+
|
|
1502
|
+
const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
|
|
1503
|
+
const result = await prompt
|
|
1504
|
+
return result as T
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// process.exit is intercepted by ctx.process.exit only when ctx is threaded; cancelAndExit
|
|
1508
|
+
// calls global process.exit. Stub it to capture the code and throw so the function unwinds.
|
|
1509
|
+
const originalExit = process.exit
|
|
1510
|
+
process.exit = ((code?: number) => {
|
|
1511
|
+
exitCode = code
|
|
1512
|
+
throw new Error('process.exit-stubbed')
|
|
1513
|
+
}) as typeof process.exit
|
|
1514
|
+
|
|
1515
|
+
try {
|
|
1516
|
+
await runSetupCommand(
|
|
1517
|
+
{
|
|
1518
|
+
key: KEY,
|
|
1519
|
+
repo: 'owner/repo',
|
|
1520
|
+
harness: 'opencode',
|
|
1521
|
+
providers: 'openai',
|
|
1522
|
+
model: 'openai/gpt-5.4-mini',
|
|
1523
|
+
force: true,
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
interactive: true,
|
|
1527
|
+
baseUrl: BASE_URL,
|
|
1528
|
+
ctx,
|
|
1529
|
+
gh: {
|
|
1530
|
+
assertGhInstalled: async () => {},
|
|
1531
|
+
assertGhAuthenticated: async () => {},
|
|
1532
|
+
assertRepoAccess: async () => {},
|
|
1533
|
+
listExistingGhNames: async (_repo, kind) => (kind === 'secret' ? ['OPENCODE_AUTH_JSON'] : []),
|
|
1534
|
+
createManagementApiKey: async () => {},
|
|
1535
|
+
deleteManagementApiKey: async () => {},
|
|
1536
|
+
applyGhValue: async () => {
|
|
1537
|
+
applyGhValueCalled = true
|
|
1538
|
+
},
|
|
1539
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1540
|
+
},
|
|
1541
|
+
prompts: {
|
|
1542
|
+
promptValue: interactivePromptValue,
|
|
1543
|
+
// User rejects the R8 confirmation → cancelAndExit fires.
|
|
1544
|
+
confirm: () => Promise.resolve(false) as Promise<boolean | symbol>,
|
|
1545
|
+
intro: () => {},
|
|
1546
|
+
note: () => {},
|
|
1547
|
+
outro: () => {},
|
|
1548
|
+
},
|
|
1549
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1550
|
+
validation: {
|
|
1551
|
+
assertProxyReachable: async () => {},
|
|
1552
|
+
assertProxyKeyWorks: async () => {},
|
|
1553
|
+
verifyModelsAvailable: async () => {},
|
|
1554
|
+
},
|
|
1555
|
+
},
|
|
1556
|
+
)
|
|
1557
|
+
// If we get here, cancelAndExit didn't fire. Fail the test.
|
|
1558
|
+
throw new Error('expected cancelAndExit to fire on R8 reject')
|
|
1559
|
+
} catch (error) {
|
|
1560
|
+
// cancelAndExit throws because we stubbed process.exit to throw.
|
|
1561
|
+
expect(error instanceof Error && error.message).toBe('process.exit-stubbed')
|
|
1562
|
+
} finally {
|
|
1563
|
+
process.exit = originalExit
|
|
1564
|
+
globalThis.fetch = originalFetch
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
expect(exitCode).toBe(0)
|
|
1568
|
+
expect(applyGhValueCalled).toBe(false)
|
|
1569
|
+
})
|
|
1570
|
+
|
|
1571
|
+
// ── Double-log regression test ────────────────────
|
|
1572
|
+
|
|
1573
|
+
it('Bare CLI path (no ctx injected): action error not double-logged via ctx.console.error', async () => {
|
|
1574
|
+
// When deps.ctx is undefined, ctx defaults to realCtx (which writes to global console).
|
|
1575
|
+
// The catch block should skip ctx.console.error to avoid double-printing — cli.ts top-level
|
|
1576
|
+
// catch already prints. We verify by tracking calls to console.error directly.
|
|
1577
|
+
const errorCalls: string[] = []
|
|
1578
|
+
const originalConsoleError = console.error
|
|
1579
|
+
console.error = (...args: unknown[]) => {
|
|
1580
|
+
errorCalls.push(args.map(String).join(' '))
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
try {
|
|
1584
|
+
await expect(
|
|
1585
|
+
runSetupCommand(
|
|
1586
|
+
{
|
|
1587
|
+
key: KEY,
|
|
1588
|
+
repo: 'owner/repo',
|
|
1589
|
+
harness: 'opencode',
|
|
1590
|
+
force: true,
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
interactive: false,
|
|
1594
|
+
baseUrl: BASE_URL,
|
|
1595
|
+
// ctx NOT supplied — bare CLI mode
|
|
1596
|
+
gh: {
|
|
1597
|
+
assertGhInstalled: async () => {
|
|
1598
|
+
throw new Error('gh-not-installed-marker')
|
|
1599
|
+
},
|
|
1600
|
+
assertGhAuthenticated: async () => {},
|
|
1601
|
+
assertRepoAccess: async () => {},
|
|
1602
|
+
listExistingGhNames: async () => [],
|
|
1603
|
+
createManagementApiKey: async () => {},
|
|
1604
|
+
deleteManagementApiKey: async () => {},
|
|
1605
|
+
applyGhValue: async () => {},
|
|
1606
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1607
|
+
},
|
|
1608
|
+
prompts: {
|
|
1609
|
+
promptValue: autoPromptValue,
|
|
1610
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1611
|
+
intro: () => {},
|
|
1612
|
+
note: () => {},
|
|
1613
|
+
outro: () => {},
|
|
1614
|
+
},
|
|
1615
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1616
|
+
validation: {
|
|
1617
|
+
assertProxyReachable: async () => {},
|
|
1618
|
+
assertProxyKeyWorks: async () => {},
|
|
1619
|
+
verifyModelsAvailable: async () => {},
|
|
1620
|
+
},
|
|
1621
|
+
},
|
|
1622
|
+
),
|
|
1623
|
+
).rejects.toThrow('gh-not-installed-marker')
|
|
1624
|
+
} finally {
|
|
1625
|
+
console.error = originalConsoleError
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// The catch block must NOT have called ctx.console.error because deps.ctx was undefined.
|
|
1629
|
+
// (cli.ts will handle the logging at the top level.) If the catch wrote here, this fails.
|
|
1630
|
+
const errorMatches = errorCalls.filter(line => line.includes('gh-not-installed-marker'))
|
|
1631
|
+
expect(errorMatches).toHaveLength(0)
|
|
1632
|
+
})
|
|
1633
|
+
|
|
1634
|
+
it('MCP path (ctx injected): action error IS logged via ctx.console.error', async () => {
|
|
1635
|
+
// When deps.ctx IS supplied (MCP path), the catch block must call ctx.console.error
|
|
1636
|
+
// so the MCP transport surfaces the message. cli.ts's top-level catch doesn't run in MCP mode.
|
|
1637
|
+
const {ctx, errors} = makeCtx()
|
|
1638
|
+
|
|
1639
|
+
await expect(
|
|
1640
|
+
runSetupCommand(
|
|
1641
|
+
{
|
|
1642
|
+
key: KEY,
|
|
1643
|
+
repo: 'owner/repo',
|
|
1644
|
+
harness: 'opencode',
|
|
1645
|
+
force: true,
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
interactive: false,
|
|
1649
|
+
baseUrl: BASE_URL,
|
|
1650
|
+
ctx, // ← injected
|
|
1651
|
+
gh: {
|
|
1652
|
+
assertGhInstalled: async () => {
|
|
1653
|
+
throw new Error('mcp-error-marker')
|
|
1654
|
+
},
|
|
1655
|
+
assertGhAuthenticated: async () => {},
|
|
1656
|
+
assertRepoAccess: async () => {},
|
|
1657
|
+
listExistingGhNames: async () => [],
|
|
1658
|
+
createManagementApiKey: async () => {},
|
|
1659
|
+
deleteManagementApiKey: async () => {},
|
|
1660
|
+
applyGhValue: async () => {},
|
|
1661
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1662
|
+
},
|
|
1663
|
+
prompts: {
|
|
1664
|
+
promptValue: autoPromptValue,
|
|
1665
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1666
|
+
intro: () => {},
|
|
1667
|
+
note: () => {},
|
|
1668
|
+
outro: () => {},
|
|
1669
|
+
},
|
|
1670
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1671
|
+
validation: {
|
|
1672
|
+
assertProxyReachable: async () => {},
|
|
1673
|
+
assertProxyKeyWorks: async () => {},
|
|
1674
|
+
verifyModelsAvailable: async () => {},
|
|
1675
|
+
},
|
|
1676
|
+
},
|
|
1677
|
+
),
|
|
1678
|
+
).rejects.toThrow('mcp-error-marker')
|
|
1679
|
+
|
|
1680
|
+
// The injected ctx.console.error received the message.
|
|
1681
|
+
const errorTexts = errors.map(args => args.map(String).join(' '))
|
|
1682
|
+
expect(errorTexts.some(line => line.includes('mcp-error-marker'))).toBe(true)
|
|
1683
|
+
})
|
|
1684
|
+
|
|
1685
|
+
// ── Rollback regression tests ─────────────────────
|
|
1686
|
+
|
|
1687
|
+
it('Rollback: applyGhValue throws → deleteManagementApiKey called before error propagates', async () => {
|
|
1688
|
+
const {ctx} = makeCtx()
|
|
1689
|
+
|
|
1690
|
+
let deleteCalledWith: string | undefined
|
|
1691
|
+
let applyCallCount = 0
|
|
1692
|
+
|
|
1693
|
+
// Use interactive: true + harness: 'claude-code' (no provider prompts) so validateSetupOptions
|
|
1694
|
+
// doesn't require --key. No --key → createKey=true → createManagementApiKey is called → rollback path.
|
|
1695
|
+
// Inject resolveManagementKey so no env var needed.
|
|
1696
|
+
try {
|
|
1697
|
+
await runSetupCommand(
|
|
1698
|
+
{
|
|
1699
|
+
// No --key → createKey=true, exercises the rollback path
|
|
1700
|
+
repo: 'owner/repo',
|
|
1701
|
+
harness: 'claude-code',
|
|
1702
|
+
force: true,
|
|
1703
|
+
},
|
|
1704
|
+
{
|
|
1705
|
+
interactive: true,
|
|
1706
|
+
baseUrl: BASE_URL,
|
|
1707
|
+
ctx,
|
|
1708
|
+
resolveManagementKey: () => 'mgmt-test-key',
|
|
1709
|
+
gh: {
|
|
1710
|
+
assertGhInstalled: async () => {},
|
|
1711
|
+
assertGhAuthenticated: async () => {},
|
|
1712
|
+
assertRepoAccess: async () => {},
|
|
1713
|
+
listExistingGhNames: async () => [],
|
|
1714
|
+
createManagementApiKey: async (_baseUrl, _mgmtKey, _keyValue) => {
|
|
1715
|
+
// createManagementApiKey succeeds — key is now "live"
|
|
1716
|
+
},
|
|
1717
|
+
deleteManagementApiKey: async (_baseUrl, _mgmtKey, keyValue) => {
|
|
1718
|
+
deleteCalledWith = keyValue
|
|
1719
|
+
},
|
|
1720
|
+
applyGhValue: async () => {
|
|
1721
|
+
applyCallCount++
|
|
1722
|
+
throw new Error('GitHub API failure')
|
|
1723
|
+
},
|
|
1724
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1725
|
+
},
|
|
1726
|
+
prompts: {
|
|
1727
|
+
promptValue: autoPromptValue,
|
|
1728
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1729
|
+
intro: () => {},
|
|
1730
|
+
note: () => {},
|
|
1731
|
+
outro: () => {},
|
|
1732
|
+
},
|
|
1733
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1734
|
+
validation: {
|
|
1735
|
+
assertProxyReachable: async () => {},
|
|
1736
|
+
assertProxyKeyWorks: async () => {},
|
|
1737
|
+
verifyModelsAvailable: async () => {},
|
|
1738
|
+
},
|
|
1739
|
+
},
|
|
1740
|
+
)
|
|
1741
|
+
} catch {
|
|
1742
|
+
// Expected to throw
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// deleteManagementApiKey must have been called (rollback happened)
|
|
1746
|
+
expect(deleteCalledWith).toBeDefined()
|
|
1747
|
+
expect(applyCallCount).toBeGreaterThan(0)
|
|
1748
|
+
})
|
|
1749
|
+
|
|
1750
|
+
it('Rollback: assertProxyKeyWorks throws → deleteManagementApiKey called before error propagates', async () => {
|
|
1751
|
+
const {ctx} = makeCtx()
|
|
1752
|
+
|
|
1753
|
+
let deleteCalledWith: string | undefined
|
|
1754
|
+
|
|
1755
|
+
try {
|
|
1756
|
+
await runSetupCommand(
|
|
1757
|
+
{
|
|
1758
|
+
// No --key → createKey=true, exercises the rollback path
|
|
1759
|
+
repo: 'owner/repo',
|
|
1760
|
+
harness: 'claude-code',
|
|
1761
|
+
force: true,
|
|
1762
|
+
},
|
|
1763
|
+
{
|
|
1764
|
+
interactive: true,
|
|
1765
|
+
baseUrl: BASE_URL,
|
|
1766
|
+
ctx,
|
|
1767
|
+
resolveManagementKey: () => 'mgmt-test-key',
|
|
1768
|
+
gh: {
|
|
1769
|
+
assertGhInstalled: async () => {},
|
|
1770
|
+
assertGhAuthenticated: async () => {},
|
|
1771
|
+
assertRepoAccess: async () => {},
|
|
1772
|
+
listExistingGhNames: async () => [],
|
|
1773
|
+
createManagementApiKey: async () => {},
|
|
1774
|
+
deleteManagementApiKey: async (_baseUrl, _mgmtKey, keyValue) => {
|
|
1775
|
+
deleteCalledWith = keyValue
|
|
1776
|
+
},
|
|
1777
|
+
applyGhValue: async () => {},
|
|
1778
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1779
|
+
},
|
|
1780
|
+
prompts: {
|
|
1781
|
+
promptValue: autoPromptValue,
|
|
1782
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1783
|
+
intro: () => {},
|
|
1784
|
+
note: () => {},
|
|
1785
|
+
outro: () => {},
|
|
1786
|
+
},
|
|
1787
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1788
|
+
validation: {
|
|
1789
|
+
assertProxyReachable: async () => {},
|
|
1790
|
+
assertProxyKeyWorks: async () => {
|
|
1791
|
+
throw new Error('Proxy key verification failed')
|
|
1792
|
+
},
|
|
1793
|
+
verifyModelsAvailable: async () => {},
|
|
1794
|
+
},
|
|
1795
|
+
},
|
|
1796
|
+
)
|
|
1797
|
+
} catch {
|
|
1798
|
+
// Expected to throw
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// deleteManagementApiKey must have been called (rollback happened)
|
|
1802
|
+
expect(deleteCalledWith).toBeDefined()
|
|
1803
|
+
})
|
|
1804
|
+
|
|
1805
|
+
// ── F5: --dry-run with no --repo/--harness ─────────────────────────────────
|
|
1806
|
+
|
|
1807
|
+
it('F5: --dry-run with no --repo/--harness prints preview and does not throw', async () => {
|
|
1808
|
+
const {ctx, logs} = makeCtx()
|
|
1809
|
+
await runSetupCommand({dryRun: true}, {ctx})
|
|
1810
|
+
const output = logs.map(args => args.join(' ')).join('\n')
|
|
1811
|
+
expect(output).toContain('OPENCODE_AUTH_JSON')
|
|
1812
|
+
expect(output).toContain('No mutations will be performed.')
|
|
1813
|
+
})
|
|
1814
|
+
|
|
1815
|
+
it('F5: --dry-run does not call assertGhInstalled even with no flags', async () => {
|
|
1816
|
+
const {ctx} = makeCtx()
|
|
1817
|
+
let ghCalled = false
|
|
1818
|
+
await runSetupCommand(
|
|
1819
|
+
{dryRun: true},
|
|
1820
|
+
{
|
|
1821
|
+
ctx,
|
|
1822
|
+
gh: {
|
|
1823
|
+
assertGhInstalled: async () => {
|
|
1824
|
+
ghCalled = true
|
|
1825
|
+
},
|
|
1826
|
+
assertGhAuthenticated: async () => {},
|
|
1827
|
+
assertRepoAccess: async () => {},
|
|
1828
|
+
listExistingGhNames: async () => [],
|
|
1829
|
+
createManagementApiKey: async () => {},
|
|
1830
|
+
deleteManagementApiKey: async () => {},
|
|
1831
|
+
applyGhValue: async () => {},
|
|
1832
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1833
|
+
},
|
|
1834
|
+
},
|
|
1835
|
+
)
|
|
1836
|
+
expect(ghCalled).toBe(false)
|
|
1837
|
+
})
|
|
1838
|
+
|
|
1839
|
+
// ── F8: Rollback event-order assertions ────────────────────────────────────
|
|
1840
|
+
|
|
1841
|
+
it('F8: applyGhValue fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
|
|
1842
|
+
const {ctx} = makeCtx()
|
|
1843
|
+
const events: string[] = []
|
|
1844
|
+
|
|
1845
|
+
await expect(
|
|
1846
|
+
runSetupCommand(
|
|
1847
|
+
{repo: 'owner/repo', harness: 'claude-code', force: true},
|
|
1848
|
+
{
|
|
1849
|
+
interactive: true,
|
|
1850
|
+
baseUrl: BASE_URL,
|
|
1851
|
+
ctx,
|
|
1852
|
+
resolveManagementKey: () => 'mgmt-test-key',
|
|
1853
|
+
gh: {
|
|
1854
|
+
assertGhInstalled: async () => {},
|
|
1855
|
+
assertGhAuthenticated: async () => {},
|
|
1856
|
+
assertRepoAccess: async () => {},
|
|
1857
|
+
listExistingGhNames: async () => [],
|
|
1858
|
+
createManagementApiKey: async () => {
|
|
1859
|
+
events.push('create')
|
|
1860
|
+
},
|
|
1861
|
+
deleteManagementApiKey: async () => {
|
|
1862
|
+
events.push('delete')
|
|
1863
|
+
},
|
|
1864
|
+
applyGhValue: async () => {
|
|
1865
|
+
events.push('apply-fail')
|
|
1866
|
+
throw new Error('apply-fail')
|
|
1867
|
+
},
|
|
1868
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1869
|
+
},
|
|
1870
|
+
prompts: {
|
|
1871
|
+
promptValue: autoPromptValue,
|
|
1872
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1873
|
+
intro: () => {},
|
|
1874
|
+
note: () => {},
|
|
1875
|
+
outro: () => {},
|
|
1876
|
+
},
|
|
1877
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1878
|
+
validation: {
|
|
1879
|
+
assertProxyReachable: async () => {},
|
|
1880
|
+
assertProxyKeyWorks: async () => {},
|
|
1881
|
+
verifyModelsAvailable: async () => {},
|
|
1882
|
+
},
|
|
1883
|
+
},
|
|
1884
|
+
),
|
|
1885
|
+
).rejects.toThrow('apply-fail')
|
|
1886
|
+
|
|
1887
|
+
expect(events).toEqual(['create', 'apply-fail', 'delete'])
|
|
1888
|
+
})
|
|
1889
|
+
|
|
1890
|
+
it('F8: assertProxyKeyWorks fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
|
|
1891
|
+
const {ctx} = makeCtx()
|
|
1892
|
+
const events: string[] = []
|
|
1893
|
+
|
|
1894
|
+
await expect(
|
|
1895
|
+
runSetupCommand(
|
|
1896
|
+
{repo: 'owner/repo', harness: 'claude-code', force: true},
|
|
1897
|
+
{
|
|
1898
|
+
interactive: true,
|
|
1899
|
+
baseUrl: BASE_URL,
|
|
1900
|
+
ctx,
|
|
1901
|
+
resolveManagementKey: () => 'mgmt-test-key',
|
|
1902
|
+
gh: {
|
|
1903
|
+
assertGhInstalled: async () => {},
|
|
1904
|
+
assertGhAuthenticated: async () => {},
|
|
1905
|
+
assertRepoAccess: async () => {},
|
|
1906
|
+
listExistingGhNames: async () => [],
|
|
1907
|
+
createManagementApiKey: async () => {
|
|
1908
|
+
events.push('create')
|
|
1909
|
+
},
|
|
1910
|
+
deleteManagementApiKey: async () => {
|
|
1911
|
+
events.push('delete')
|
|
1912
|
+
},
|
|
1913
|
+
applyGhValue: async () => {
|
|
1914
|
+
events.push('apply-success')
|
|
1915
|
+
},
|
|
1916
|
+
withGhRetry: async (_label, fn) => fn(makeSpinner()),
|
|
1917
|
+
},
|
|
1918
|
+
prompts: {
|
|
1919
|
+
promptValue: autoPromptValue,
|
|
1920
|
+
confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
|
|
1921
|
+
intro: () => {},
|
|
1922
|
+
note: () => {},
|
|
1923
|
+
outro: () => {},
|
|
1924
|
+
},
|
|
1925
|
+
smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
|
|
1926
|
+
validation: {
|
|
1927
|
+
assertProxyReachable: async () => {},
|
|
1928
|
+
assertProxyKeyWorks: async () => {
|
|
1929
|
+
events.push('verify-fail')
|
|
1930
|
+
throw new Error('verify-fail')
|
|
1931
|
+
},
|
|
1932
|
+
verifyModelsAvailable: async () => {},
|
|
1933
|
+
},
|
|
1934
|
+
},
|
|
1935
|
+
),
|
|
1936
|
+
).rejects.toThrow('verify-fail')
|
|
1937
|
+
|
|
1938
|
+
expect(events).toEqual(['create', 'apply-success', 'verify-fail', 'delete'])
|
|
1939
|
+
})
|
|
1940
|
+
|
|
1941
|
+
// ── F9: --force pre-gate fires before verifyModelsAvailable ────────────────
|
|
1942
|
+
|
|
1943
|
+
it('F9: missing --force on provider change does not call fetch (verifyModelsAvailable skipped)', async () => {
|
|
1944
|
+
let fetchCalled = false
|
|
1945
|
+
const originalFetch = globalThis.fetch
|
|
1946
|
+
globalThis.fetch = mock(async () => {
|
|
1947
|
+
fetchCalled = true
|
|
1948
|
+
return new Response('{}')
|
|
1949
|
+
}) as unknown as typeof fetch
|
|
1950
|
+
|
|
1951
|
+
try {
|
|
1952
|
+
await expect(
|
|
1953
|
+
buildNonInteractivePlan({key: KEY, repo: 'owner/repo', harness: 'opencode', providers: 'openai'}, BASE_URL),
|
|
1954
|
+
).rejects.toThrow('--force')
|
|
1955
|
+
} finally {
|
|
1956
|
+
globalThis.fetch = originalFetch
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
expect(fetchCalled).toBe(false)
|
|
340
1960
|
})
|
|
341
1961
|
})
|