@marcusrbrown/infra 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__snapshots__/cli.test.ts.snap +6 -0
- package/src/commands/cliproxy/config.ts +2 -26
- package/src/commands/cliproxy/keys.ts +8 -43
- package/src/commands/cliproxy/setup/gh.test.ts +218 -0
- package/src/commands/cliproxy/setup/gh.ts +250 -0
- package/src/commands/cliproxy/setup/preview.test.ts +159 -0
- package/src/commands/cliproxy/setup/preview.ts +41 -0
- package/src/commands/cliproxy/setup/prompts.test.ts +58 -0
- package/src/commands/cliproxy/setup/prompts.ts +99 -0
- package/src/commands/cliproxy/setup/providers.test.ts +228 -0
- package/src/commands/cliproxy/setup/providers.ts +136 -0
- package/src/commands/cliproxy/setup/smoke-test.test.ts +643 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +205 -0
- package/src/commands/cliproxy/setup/templates.test.ts +358 -0
- package/src/commands/cliproxy/setup/templates.ts +158 -0
- package/src/commands/cliproxy/setup/validation.test.ts +399 -0
- package/src/commands/cliproxy/setup/validation.ts +182 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +341 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +137 -0
- package/src/commands/cliproxy/setup.test.ts +1867 -247
- package/src/commands/cliproxy/setup.ts +544 -831
- package/src/commands/cliproxy/shared.test.ts +118 -0
- package/src/commands/cliproxy/shared.ts +84 -0
- package/src/commands/cliproxy/status.ts +2 -7
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
export type SmokeResult =
|
|
4
|
+
| {kind: 'pass'; message: string; runUrl: string}
|
|
5
|
+
| {kind: 'fail'; message: string; runUrl: string}
|
|
6
|
+
| {kind: 'unverified'; message: string; runUrl?: string}
|
|
7
|
+
|
|
8
|
+
// Exported for tests only.
|
|
9
|
+
export interface GhRunEntry {
|
|
10
|
+
databaseId: number
|
|
11
|
+
status: string
|
|
12
|
+
conclusion: string | null
|
|
13
|
+
url: string
|
|
14
|
+
createdAt: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Exported for tests only. Override poll delays and trigger time.
|
|
18
|
+
export interface SmokeTestInternals {
|
|
19
|
+
/** Override per-poll delay in ms (default: real backoff schedule). */
|
|
20
|
+
_testDelayMs?: number
|
|
21
|
+
/** Override the trigger timestamp used for createdAt heuristic. */
|
|
22
|
+
_testTriggerTime?: Date
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run an optional post-mutation smoke test by triggering `fro-bot.yaml` and
|
|
27
|
+
* polling for completion. Returns a non-blocking SmokeResult — never throws.
|
|
28
|
+
*
|
|
29
|
+
* Race-safe: captures the highest existing run ID before triggering, then
|
|
30
|
+
* filters poll results to runs with databaseId > baselineId. When no prior
|
|
31
|
+
* runs exist (baselineId=null), falls back to createdAt > triggerTime.
|
|
32
|
+
*
|
|
33
|
+
* Known edge case: if a concurrent contributor's run appears before ours,
|
|
34
|
+
* we pick the highest databaseId above baseline — this may misattribute
|
|
35
|
+
* the concurrent run as ours. This is the best heuristic available without
|
|
36
|
+
* a run-specific correlation ID from `gh workflow run`.
|
|
37
|
+
*/
|
|
38
|
+
export async function runSmokeTest(
|
|
39
|
+
repo: string,
|
|
40
|
+
_model: string,
|
|
41
|
+
internals: SmokeTestInternals = {},
|
|
42
|
+
): Promise<SmokeResult> {
|
|
43
|
+
const BACKOFF_MS = [5_000, 15_000, 30_000, 60_000, 60_000]
|
|
44
|
+
const delayFn = async (ms: number): Promise<void> => {
|
|
45
|
+
if (internals._testDelayMs !== undefined) {
|
|
46
|
+
if (internals._testDelayMs > 0) {
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, internals._testDelayMs))
|
|
48
|
+
}
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
await new Promise(resolve => setTimeout(resolve, ms))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const repoUrl = `https://github.com/${repo}`
|
|
55
|
+
|
|
56
|
+
// ── Step 1: Capture baseline run ID ──────────────────────────────────────
|
|
57
|
+
let baselineId: number | null = null
|
|
58
|
+
try {
|
|
59
|
+
const baselineChild = Bun.spawn(
|
|
60
|
+
['gh', 'run', 'list', '--workflow=fro-bot.yaml', '--repo', repo, '--limit', '1', '--json', 'databaseId'],
|
|
61
|
+
{stdout: 'pipe', stderr: 'pipe', env: process.env},
|
|
62
|
+
)
|
|
63
|
+
const [baselineStdout, , baselineExit] = await Promise.all([
|
|
64
|
+
new Response(baselineChild.stdout).text(),
|
|
65
|
+
new Response(baselineChild.stderr).text(),
|
|
66
|
+
baselineChild.exited,
|
|
67
|
+
])
|
|
68
|
+
if (baselineExit === 0) {
|
|
69
|
+
const parsed = JSON.parse(baselineStdout) as {databaseId: number}[]
|
|
70
|
+
if (parsed.length > 0 && parsed[0]) {
|
|
71
|
+
baselineId = parsed[0].databaseId
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// If baseline call fails, baselineId stays null — we'll use createdAt heuristic
|
|
75
|
+
} catch {
|
|
76
|
+
// Network/parse error — continue with null baseline
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Step 2: Trigger the workflow ──────────────────────────────────────────
|
|
80
|
+
const triggerTime = internals._testTriggerTime ?? new Date()
|
|
81
|
+
|
|
82
|
+
const triggerChild = Bun.spawn(
|
|
83
|
+
['gh', 'workflow', 'run', 'fro-bot.yaml', '--repo', repo, '-f', 'prompt=reply with exactly: ack'],
|
|
84
|
+
{stdout: 'pipe', stderr: 'pipe', env: process.env},
|
|
85
|
+
)
|
|
86
|
+
const [, triggerStderr, triggerExit] = await Promise.all([
|
|
87
|
+
new Response(triggerChild.stdout).text(),
|
|
88
|
+
new Response(triggerChild.stderr).text(),
|
|
89
|
+
triggerChild.exited,
|
|
90
|
+
])
|
|
91
|
+
|
|
92
|
+
if (triggerExit !== 0) {
|
|
93
|
+
const redacted = triggerStderr.slice(0, 200)
|
|
94
|
+
return {kind: 'unverified', message: `gh workflow run failed: ${redacted}`}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Step 3: Poll for the new run ──────────────────────────────────────────
|
|
98
|
+
let latestMatchedRun: GhRunEntry | undefined
|
|
99
|
+
|
|
100
|
+
for (const BACKOFF_M of BACKOFF_MS) {
|
|
101
|
+
await delayFn(BACKOFF_M ?? 60_000)
|
|
102
|
+
|
|
103
|
+
let pollRuns: GhRunEntry[] = []
|
|
104
|
+
try {
|
|
105
|
+
const pollChild = Bun.spawn(
|
|
106
|
+
[
|
|
107
|
+
'gh',
|
|
108
|
+
'run',
|
|
109
|
+
'list',
|
|
110
|
+
'--workflow=fro-bot.yaml',
|
|
111
|
+
'--repo',
|
|
112
|
+
repo,
|
|
113
|
+
'--limit',
|
|
114
|
+
'5',
|
|
115
|
+
'--json',
|
|
116
|
+
'databaseId,status,conclusion,url,createdAt',
|
|
117
|
+
],
|
|
118
|
+
{stdout: 'pipe', stderr: 'pipe', env: process.env},
|
|
119
|
+
)
|
|
120
|
+
const [pollStdout, , pollExit] = await Promise.all([
|
|
121
|
+
new Response(pollChild.stdout).text(),
|
|
122
|
+
new Response(pollChild.stderr).text(),
|
|
123
|
+
pollChild.exited,
|
|
124
|
+
])
|
|
125
|
+
if (pollExit === 0) {
|
|
126
|
+
pollRuns = JSON.parse(pollStdout) as GhRunEntry[]
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// Parse/network error — retry on next poll
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Filter to runs triggered after our baseline
|
|
134
|
+
const candidates = pollRuns.filter(run => {
|
|
135
|
+
if (baselineId !== null) {
|
|
136
|
+
return run.databaseId > baselineId
|
|
137
|
+
}
|
|
138
|
+
// No baseline: use createdAt heuristic
|
|
139
|
+
return new Date(run.createdAt) > triggerTime
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
if (candidates.length === 0) {
|
|
143
|
+
// Our run not visible yet — keep polling
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Pick the highest databaseId from candidates (most likely ours)
|
|
148
|
+
const matched = candidates.reduce((best, run) => (run.databaseId > best.databaseId ? run : best))
|
|
149
|
+
latestMatchedRun = matched
|
|
150
|
+
|
|
151
|
+
const {status, conclusion, url: runUrl} = matched
|
|
152
|
+
|
|
153
|
+
// Environment approval gate (simplified — the pending+approval branch was dead).
|
|
154
|
+
// When status=pending, gh returns conclusion=null, so /approval/i.test('') = false.
|
|
155
|
+
// Only status=waiting triggers the env-approval gate.
|
|
156
|
+
if (status === 'waiting') {
|
|
157
|
+
return {kind: 'unverified', message: `Workflow requires environment approval at ${runUrl}`, runUrl}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (status === 'completed') {
|
|
161
|
+
if (conclusion === 'success') {
|
|
162
|
+
// Best-effort log grep for "ack"
|
|
163
|
+
let logNote = ''
|
|
164
|
+
try {
|
|
165
|
+
const logChild = Bun.spawn(['gh', 'run', 'view', String(matched.databaseId), '--log', '--repo', repo], {
|
|
166
|
+
stdout: 'pipe',
|
|
167
|
+
stderr: 'pipe',
|
|
168
|
+
env: process.env,
|
|
169
|
+
})
|
|
170
|
+
const [logStdout, , logExit] = await Promise.all([
|
|
171
|
+
new Response(logChild.stdout).text(),
|
|
172
|
+
new Response(logChild.stderr).text(),
|
|
173
|
+
logChild.exited,
|
|
174
|
+
])
|
|
175
|
+
if (logExit !== 0) {
|
|
176
|
+
logNote = ' (log fetch failed, but run conclusion is success)'
|
|
177
|
+
} else if (!/\back\b/i.test(logStdout)) {
|
|
178
|
+
logNote = ' (log fetch succeeded but "ack" not found in output)'
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
logNote = ' (log fetch failed, but run conclusion is success)'
|
|
182
|
+
}
|
|
183
|
+
return {kind: 'pass', message: `Smoke test passed${logNote}`, runUrl}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {kind: 'fail', message: `Run completed with conclusion=${conclusion ?? 'unknown'}`, runUrl}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Still in progress (queued, in_progress, pending) — continue polling
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// All polls exhausted
|
|
193
|
+
if (latestMatchedRun) {
|
|
194
|
+
return {
|
|
195
|
+
kind: 'unverified',
|
|
196
|
+
message: `Smoke test did not complete in 5 minutes; check ${latestMatchedRun.url}`,
|
|
197
|
+
runUrl: latestMatchedRun.url,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
kind: 'unverified',
|
|
203
|
+
message: `Smoke test trigger not yet visible; check ${repoUrl}/actions`,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
collectCollisions,
|
|
7
|
+
formatTemplateSummary,
|
|
8
|
+
getHarnessTemplate,
|
|
9
|
+
harnessSchema,
|
|
10
|
+
stripTrailingSlash,
|
|
11
|
+
type HarnessTemplate,
|
|
12
|
+
type SecretAssignment,
|
|
13
|
+
type VariableAssignment,
|
|
14
|
+
} from './templates'
|
|
15
|
+
|
|
16
|
+
// ── stripTrailingSlash (new unit tests — RED first) ──────────────────────────
|
|
17
|
+
|
|
18
|
+
describe('stripTrailingSlash', () => {
|
|
19
|
+
it('removes a trailing slash', () => {
|
|
20
|
+
expect(stripTrailingSlash('https://example.com/')).toBe('https://example.com')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is a no-op when there is no trailing slash', () => {
|
|
24
|
+
expect(stripTrailingSlash('https://example.com')).toBe('https://example.com')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns empty string for empty input', () => {
|
|
28
|
+
expect(stripTrailingSlash('')).toBe('')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('removes only one trailing slash', () => {
|
|
32
|
+
expect(stripTrailingSlash('https://example.com//')).toBe('https://example.com/')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// ── harnessSchema (new unit test) ────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe('harnessSchema', () => {
|
|
39
|
+
it('parses valid harness values', () => {
|
|
40
|
+
expect(harnessSchema.parse('opencode')).toBe('opencode')
|
|
41
|
+
expect(harnessSchema.parse('claude-code')).toBe('claude-code')
|
|
42
|
+
expect(harnessSchema.parse('generic')).toBe('generic')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('throws on invalid harness value', () => {
|
|
46
|
+
expect(() => harnessSchema.parse('unknown')).toThrow()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// ── collectCollisions (new unit tests — RED first) ───────────────────────────
|
|
51
|
+
|
|
52
|
+
describe('collectCollisions', () => {
|
|
53
|
+
const template: HarnessTemplate = {
|
|
54
|
+
secrets: [
|
|
55
|
+
{name: 'OPENCODE_AUTH_JSON', value: 'x'},
|
|
56
|
+
{name: 'OPENCODE_CONFIG', value: 'y'},
|
|
57
|
+
],
|
|
58
|
+
variables: [{name: 'FRO_BOT_MODEL', value: 'z'}],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('returns empty array when no collisions', () => {
|
|
62
|
+
expect(collectCollisions(template, [], [])).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('detects a colliding secret', () => {
|
|
66
|
+
const result = collectCollisions(template, ['OPENCODE_AUTH_JSON'], [])
|
|
67
|
+
expect(result).toContain('secret OPENCODE_AUTH_JSON')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('detects a colliding variable', () => {
|
|
71
|
+
const result = collectCollisions(template, [], ['FRO_BOT_MODEL'])
|
|
72
|
+
expect(result).toContain('variable FRO_BOT_MODEL')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('detects multiple collisions', () => {
|
|
76
|
+
const result = collectCollisions(template, ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG'], ['FRO_BOT_MODEL'])
|
|
77
|
+
expect(result).toHaveLength(3)
|
|
78
|
+
expect(result).toContain('secret OPENCODE_AUTH_JSON')
|
|
79
|
+
expect(result).toContain('secret OPENCODE_CONFIG')
|
|
80
|
+
expect(result).toContain('variable FRO_BOT_MODEL')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('ignores non-colliding existing names', () => {
|
|
84
|
+
const result = collectCollisions(template, ['SOME_OTHER_SECRET'], ['SOME_OTHER_VAR'])
|
|
85
|
+
expect(result).toEqual([])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ── formatTemplateSummary (new unit tests — RED first) ───────────────────────
|
|
90
|
+
|
|
91
|
+
describe('formatTemplateSummary', () => {
|
|
92
|
+
it('lists secrets and variables with their prefixes', () => {
|
|
93
|
+
const template: HarnessTemplate = {
|
|
94
|
+
secrets: [{name: 'OPENCODE_AUTH_JSON', value: 'x'}],
|
|
95
|
+
variables: [{name: 'FRO_BOT_MODEL', value: 'y'}],
|
|
96
|
+
}
|
|
97
|
+
const summary = formatTemplateSummary(template)
|
|
98
|
+
expect(summary).toContain('secret OPENCODE_AUTH_JSON')
|
|
99
|
+
expect(summary).toContain('variable FRO_BOT_MODEL')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns only secret lines when no variables', () => {
|
|
103
|
+
const template: HarnessTemplate = {
|
|
104
|
+
secrets: [{name: 'ANTHROPIC_API_KEY', value: 'x'}],
|
|
105
|
+
variables: [],
|
|
106
|
+
}
|
|
107
|
+
const summary = formatTemplateSummary(template)
|
|
108
|
+
expect(summary).toBe('- secret ANTHROPIC_API_KEY')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns empty string for empty template', () => {
|
|
112
|
+
const template: HarnessTemplate = {secrets: [], variables: []}
|
|
113
|
+
expect(formatTemplateSummary(template)).toBe('')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ── getHarnessTemplate (pure-move from setup.test.ts L162) ───────────────────
|
|
118
|
+
|
|
119
|
+
describe('getHarnessTemplate', () => {
|
|
120
|
+
it('returns the expected OpenCode secret and variable names', () => {
|
|
121
|
+
const template = getHarnessTemplate('opencode')
|
|
122
|
+
|
|
123
|
+
expect(template.secrets.map((entry: SecretAssignment) => entry.name)).toEqual([
|
|
124
|
+
'OPENCODE_AUTH_JSON',
|
|
125
|
+
'OPENCODE_CONFIG',
|
|
126
|
+
'OMO_PROVIDERS',
|
|
127
|
+
])
|
|
128
|
+
expect(template.variables.map((entry: VariableAssignment) => entry.name)).toEqual(['FRO_BOT_MODEL'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('uses a provider-prefixed FRO_BOT_MODEL default value', () => {
|
|
132
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
|
|
133
|
+
const modelEntry = template.variables.find((entry: VariableAssignment) => entry.name === 'FRO_BOT_MODEL')
|
|
134
|
+
|
|
135
|
+
expect(modelEntry?.value).toMatch(/^anthropic\//)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('uses the expected OMO_PROVIDERS default value', () => {
|
|
139
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
|
|
140
|
+
const providersEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OMO_PROVIDERS')
|
|
141
|
+
|
|
142
|
+
expect(providersEntry?.value).toBe('claude-max20')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('writes an OPENCODE_CONFIG baseURL with the /v1 suffix', () => {
|
|
146
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
|
|
147
|
+
const configEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_CONFIG')
|
|
148
|
+
const parsed = JSON.parse(configEntry?.value ?? '{}')
|
|
149
|
+
|
|
150
|
+
expect(parsed.provider.anthropic.options.baseURL).toMatch(/\/v1$/)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('writes OPENCODE_AUTH_JSON with type=api and the supplied key', () => {
|
|
154
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'sk-test-key'})
|
|
155
|
+
const authEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_AUTH_JSON')
|
|
156
|
+
const parsed = JSON.parse(authEntry?.value ?? '{}')
|
|
157
|
+
|
|
158
|
+
expect(parsed.anthropic).toEqual({type: 'api', key: 'sk-test-key'})
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ── getHarnessTemplate provider-aware (pure-move from setup.test.ts L508) ────
|
|
163
|
+
|
|
164
|
+
describe('getHarnessTemplate provider-aware', () => {
|
|
165
|
+
// Frozen byte-identical string for the anthropic-only regression test.
|
|
166
|
+
// This is the EXACT output of getHarnessTemplate('opencode', {keyValue: 'test-key'})
|
|
167
|
+
// baseline anthropic-only output. Any change to this string is a breaking regression.
|
|
168
|
+
const ANTHROPIC_ONLY_AUTH_JSON = '{"anthropic":{"type":"api","key":"test-key"}}'
|
|
169
|
+
const ANTHROPIC_ONLY_CONFIG = '{"provider":{"anthropic":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}'
|
|
170
|
+
|
|
171
|
+
describe('regression — anthropic-only (byte-identical)', () => {
|
|
172
|
+
it('no providers/model args → OPENCODE_AUTH_JSON is byte-identical to baseline', () => {
|
|
173
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
|
|
174
|
+
const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
|
|
175
|
+
|
|
176
|
+
expect(authEntry?.value).toBe(ANTHROPIC_ONLY_AUTH_JSON)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('no providers/model args → OPENCODE_CONFIG is byte-identical to baseline', () => {
|
|
180
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
|
|
181
|
+
const configEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
|
|
182
|
+
|
|
183
|
+
expect(configEntry?.value).toBe(ANTHROPIC_ONLY_CONFIG)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('no providers/model args → OMO_PROVIDERS is claude-max20', () => {
|
|
187
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
|
|
188
|
+
const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
|
|
189
|
+
|
|
190
|
+
expect(entry?.value).toBe('claude-max20')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('no providers/model args → FRO_BOT_MODEL is anthropic/claude-sonnet-4-6', () => {
|
|
194
|
+
const template = getHarnessTemplate('opencode', {keyValue: 'test-key'})
|
|
195
|
+
const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
|
|
196
|
+
|
|
197
|
+
expect(entry?.value).toBe('anthropic/claude-sonnet-4-6')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("explicit providers: ['anthropic'] → byte-identical to no-providers output", () => {
|
|
201
|
+
const baseline = getHarnessTemplate('opencode', {keyValue: 'test-key'})
|
|
202
|
+
const explicit = getHarnessTemplate('opencode', {keyValue: 'test-key', providers: ['anthropic']})
|
|
203
|
+
|
|
204
|
+
const baselineAuth = baseline.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
|
|
205
|
+
const explicitAuth = explicit.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
|
|
206
|
+
expect(explicitAuth?.value).toBe(baselineAuth?.value)
|
|
207
|
+
|
|
208
|
+
const baselineConfig = baseline.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
|
|
209
|
+
const explicitConfig = explicit.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
|
|
210
|
+
expect(explicitConfig?.value).toBe(baselineConfig?.value)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('openai-only provider', () => {
|
|
215
|
+
it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → correct OPENCODE_AUTH_JSON", () => {
|
|
216
|
+
const template = getHarnessTemplate('opencode', {
|
|
217
|
+
keyValue: 'sk-openai-key',
|
|
218
|
+
providers: ['openai'],
|
|
219
|
+
model: 'openai/gpt-5.4-mini',
|
|
220
|
+
})
|
|
221
|
+
const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
|
|
222
|
+
|
|
223
|
+
expect(authEntry?.value).toBe('{"openai":{"type":"api","key":"sk-openai-key"}}')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → correct OPENCODE_CONFIG", () => {
|
|
227
|
+
const template = getHarnessTemplate('opencode', {
|
|
228
|
+
keyValue: 'sk-openai-key',
|
|
229
|
+
providers: ['openai'],
|
|
230
|
+
model: 'openai/gpt-5.4-mini',
|
|
231
|
+
})
|
|
232
|
+
const configEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
|
|
233
|
+
|
|
234
|
+
expect(configEntry?.value).toBe('{"provider":{"openai":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → OMO_PROVIDERS is openai", () => {
|
|
238
|
+
const template = getHarnessTemplate('opencode', {
|
|
239
|
+
keyValue: 'sk-openai-key',
|
|
240
|
+
providers: ['openai'],
|
|
241
|
+
model: 'openai/gpt-5.4-mini',
|
|
242
|
+
})
|
|
243
|
+
const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
|
|
244
|
+
|
|
245
|
+
expect(entry?.value).toBe('openai')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it("providers: ['openai'], model: 'openai/gpt-5.4-mini' → FRO_BOT_MODEL is openai/gpt-5.4-mini", () => {
|
|
249
|
+
const template = getHarnessTemplate('opencode', {
|
|
250
|
+
keyValue: 'sk-openai-key',
|
|
251
|
+
providers: ['openai'],
|
|
252
|
+
model: 'openai/gpt-5.4-mini',
|
|
253
|
+
})
|
|
254
|
+
const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
|
|
255
|
+
|
|
256
|
+
expect(entry?.value).toBe('openai/gpt-5.4-mini')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("providers: ['openai'] with no model → uses PROVIDER_DEFAULTS openai/gpt-5.4-mini", () => {
|
|
260
|
+
const template = getHarnessTemplate('opencode', {
|
|
261
|
+
keyValue: 'sk-openai-key',
|
|
262
|
+
providers: ['openai'],
|
|
263
|
+
})
|
|
264
|
+
const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
|
|
265
|
+
|
|
266
|
+
expect(entry?.value).toBe('openai/gpt-5.4-mini')
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('dual-provider (anthropic + openai)', () => {
|
|
271
|
+
it("providers: ['anthropic', 'openai'] → OPENCODE_AUTH_JSON has anthropic-first key order", () => {
|
|
272
|
+
const template = getHarnessTemplate('opencode', {
|
|
273
|
+
keyValue: 'sk-dual',
|
|
274
|
+
providers: ['anthropic', 'openai'],
|
|
275
|
+
model: 'openai/gpt-5.4-mini',
|
|
276
|
+
})
|
|
277
|
+
const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
|
|
278
|
+
|
|
279
|
+
expect(authEntry?.value).toBe(
|
|
280
|
+
'{"anthropic":{"type":"api","key":"sk-dual"},"openai":{"type":"api","key":"sk-dual"}}',
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("providers: ['anthropic', 'openai'] → OPENCODE_CONFIG has anthropic-first key order", () => {
|
|
285
|
+
const template = getHarnessTemplate('opencode', {
|
|
286
|
+
keyValue: 'sk-dual',
|
|
287
|
+
providers: ['anthropic', 'openai'],
|
|
288
|
+
model: 'openai/gpt-5.4-mini',
|
|
289
|
+
})
|
|
290
|
+
const configEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_CONFIG')
|
|
291
|
+
|
|
292
|
+
expect(configEntry?.value).toBe(
|
|
293
|
+
'{"provider":{"anthropic":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}},"openai":{"options":{"baseURL":"https://cliproxy.fro.bot/v1"}}}}',
|
|
294
|
+
)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it("providers: ['anthropic', 'openai'] → OMO_PROVIDERS is claude-max20,openai", () => {
|
|
298
|
+
const template = getHarnessTemplate('opencode', {
|
|
299
|
+
keyValue: 'sk-dual',
|
|
300
|
+
providers: ['anthropic', 'openai'],
|
|
301
|
+
model: 'openai/gpt-5.4-mini',
|
|
302
|
+
})
|
|
303
|
+
const entry = template.secrets.find((e: SecretAssignment) => e.name === 'OMO_PROVIDERS')
|
|
304
|
+
|
|
305
|
+
expect(entry?.value).toBe('claude-max20,openai')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("providers: ['anthropic', 'openai'] → FRO_BOT_MODEL is the supplied model", () => {
|
|
309
|
+
const template = getHarnessTemplate('opencode', {
|
|
310
|
+
keyValue: 'sk-dual',
|
|
311
|
+
providers: ['anthropic', 'openai'],
|
|
312
|
+
model: 'openai/gpt-5.4-mini',
|
|
313
|
+
})
|
|
314
|
+
const entry = template.variables.find((e: VariableAssignment) => e.name === 'FRO_BOT_MODEL')
|
|
315
|
+
|
|
316
|
+
expect(entry?.value).toBe('openai/gpt-5.4-mini')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it("providers: ['openai', 'anthropic'] (openai first) → output is still anthropic-first in JSON", () => {
|
|
320
|
+
const template = getHarnessTemplate('opencode', {
|
|
321
|
+
keyValue: 'sk-dual',
|
|
322
|
+
providers: ['openai', 'anthropic'],
|
|
323
|
+
model: 'openai/gpt-5.4-mini',
|
|
324
|
+
})
|
|
325
|
+
const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
|
|
326
|
+
|
|
327
|
+
expect(authEntry?.value).toBe(
|
|
328
|
+
'{"anthropic":{"type":"api","key":"sk-dual"},"openai":{"type":"api","key":"sk-dual"}}',
|
|
329
|
+
)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('multiple providers with no model → throws "model required when multiple providers selected"', () => {
|
|
333
|
+
expect(() =>
|
|
334
|
+
getHarnessTemplate('opencode', {
|
|
335
|
+
keyValue: 'sk-dual',
|
|
336
|
+
providers: ['anthropic', 'openai'],
|
|
337
|
+
}),
|
|
338
|
+
).toThrow('model required when multiple providers selected')
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe('edge cases', () => {
|
|
343
|
+
it('keyValue: undefined → auth-json key is sk-placeholder', () => {
|
|
344
|
+
const template = getHarnessTemplate('opencode', {providers: ['anthropic']})
|
|
345
|
+
const authEntry = template.secrets.find((e: SecretAssignment) => e.name === 'OPENCODE_AUTH_JSON')
|
|
346
|
+
const parsed = JSON.parse(authEntry?.value ?? '{}')
|
|
347
|
+
|
|
348
|
+
expect(parsed.anthropic.key).toBe('sk-placeholder')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('claude-code harness is unaffected by providers/model args', () => {
|
|
352
|
+
const template = getHarnessTemplate('claude-code', {keyValue: 'sk-cc'})
|
|
353
|
+
|
|
354
|
+
expect(template.secrets).toHaveLength(1)
|
|
355
|
+
expect(template.secrets[0]?.name).toBe('ANTHROPIC_API_KEY')
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
})
|