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