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