@marcusrbrown/infra 0.7.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 +3 -2
- 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 +1581 -1983
- package/src/commands/cliproxy/setup.ts +440 -1374
- 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,182 @@
|
|
|
1
|
+
import type {SetupOptions} from '../setup'
|
|
2
|
+
|
|
3
|
+
import {z} from 'zod'
|
|
4
|
+
|
|
5
|
+
import {parseProviders, type ProviderId} from './providers'
|
|
6
|
+
|
|
7
|
+
// Permissive schema: unknown fields preserved via passthrough()
|
|
8
|
+
const modelEntrySchema = z
|
|
9
|
+
.object({
|
|
10
|
+
id: z.string(),
|
|
11
|
+
owned_by: z.string(),
|
|
12
|
+
})
|
|
13
|
+
.passthrough()
|
|
14
|
+
|
|
15
|
+
const modelsResponseSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
data: z.array(modelEntrySchema),
|
|
18
|
+
})
|
|
19
|
+
.passthrough()
|
|
20
|
+
|
|
21
|
+
export const MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
|
|
22
|
+
|
|
23
|
+
const HTTP_TIMEOUT_MS = 10_000
|
|
24
|
+
|
|
25
|
+
/** Local copy — avoids a circular import with setup.ts (validation → setup → validation). */
|
|
26
|
+
function extractErrorMessage(error: unknown): string {
|
|
27
|
+
return error instanceof Error ? error.message : String(error)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateSetupOptions(options: SetupOptions, isInteractive: boolean): void {
|
|
31
|
+
if (isInteractive) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate providers/model first (independent of key/repo/harness)
|
|
36
|
+
if (options.providers) {
|
|
37
|
+
const providers = parseProviders(options.providers)
|
|
38
|
+
|
|
39
|
+
if (providers.length > 1 && !options.model) {
|
|
40
|
+
throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (options.model) {
|
|
44
|
+
const slashIndex = options.model.indexOf('/')
|
|
45
|
+
const prefix = slashIndex === -1 ? options.model : options.model.slice(0, slashIndex)
|
|
46
|
+
if (!providers.includes(prefix as ProviderId)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Model prefix ${prefix} does not match selected providers (${providers.join(', ')}). Valid prefixes: ${providers.join(', ')}/`,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!options.dryRun && !options.key) {
|
|
55
|
+
throw new Error('--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!options.repo) {
|
|
59
|
+
throw new Error('--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!options.harness) {
|
|
63
|
+
throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.harness === 'generic') {
|
|
67
|
+
throw new Error('--harness generic is interactive-only because it requires custom secret names.')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verifies that the required models are available on the proxy.
|
|
73
|
+
*
|
|
74
|
+
* Short-circuits immediately for anthropic-only setups — no fetch is made.
|
|
75
|
+
* Never echoes the Authorization header in any error message.
|
|
76
|
+
*/
|
|
77
|
+
export async function verifyModelsAvailable(
|
|
78
|
+
baseUrl: string,
|
|
79
|
+
key: string,
|
|
80
|
+
providers: ProviderId[],
|
|
81
|
+
model: string,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
// Anthropic-only: no fetch needed
|
|
84
|
+
if (providers.length === 1 && providers[0] === 'anthropic') {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const endpoint = `${baseUrl}/v1/models`
|
|
89
|
+
const response = await fetch(endpoint, {
|
|
90
|
+
headers: {Authorization: `Bearer ${key}`},
|
|
91
|
+
signal: AbortSignal.timeout(10_000),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (response.status === 401 || response.status === 403) {
|
|
95
|
+
throw new Error('Proxy key rejected. Verify with `cliproxy keys list` or rerun setup to create a new one.')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const rawBody = await response.text()
|
|
100
|
+
// Redact any Authorization headers or sk-* token-shaped strings that the server might echo
|
|
101
|
+
const redacted = rawBody
|
|
102
|
+
.replaceAll(/Bearer\s+[^\s"]+/g, 'Bearer <redacted>')
|
|
103
|
+
.replaceAll(/sk-[\w.-]{8,}/g, 'sk-<redacted>')
|
|
104
|
+
const excerpt = redacted.slice(0, 200)
|
|
105
|
+
throw new Error(`/v1/models returned HTTP ${response.status}: ${excerpt}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const json = (await response.json()) as unknown
|
|
109
|
+
|
|
110
|
+
let parsed: z.infer<typeof modelsResponseSchema>
|
|
111
|
+
try {
|
|
112
|
+
parsed = modelsResponseSchema.parse(json)
|
|
113
|
+
} catch (error) {
|
|
114
|
+
const message =
|
|
115
|
+
error instanceof z.ZodError
|
|
116
|
+
? error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')
|
|
117
|
+
: extractErrorMessage(error)
|
|
118
|
+
throw new Error(`Malformed /v1/models response: ${message}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const entries = parsed.data
|
|
122
|
+
|
|
123
|
+
// OpenAI presence check
|
|
124
|
+
if (providers.includes('openai')) {
|
|
125
|
+
const hasOpenAi = entries.some(e => e.owned_by === 'openai')
|
|
126
|
+
if (!hasOpenAi) {
|
|
127
|
+
throw new Error('No OpenAI models on proxy — is the Codex token loaded? Try `cliproxy login codex`.')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Model presence check: strip provider prefix to get bare id
|
|
132
|
+
const slashIndex = model.indexOf('/')
|
|
133
|
+
const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1)
|
|
134
|
+
const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined
|
|
135
|
+
|
|
136
|
+
const modelPresent = entries.some(e => e.id === bareId)
|
|
137
|
+
if (!modelPresent) {
|
|
138
|
+
// List available ids for the matching provider only
|
|
139
|
+
const matchingIds = providerPrefix
|
|
140
|
+
? entries.filter(e => e.owned_by === providerPrefix).map(e => e.id)
|
|
141
|
+
: entries.map(e => e.id)
|
|
142
|
+
const available = matchingIds.length > 0 ? matchingIds.join(', ') : '(none)'
|
|
143
|
+
throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? 'models'}: ${available}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function assertProxyReachable(baseUrl: string): Promise<void> {
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(baseUrl, {
|
|
150
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`)
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error instanceof Error && error.message.startsWith('Proxy check failed')) {
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function assertProxyKeyWorks(baseUrl: string, keyValue: string): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
const response = await fetch(`${baseUrl}/v1/models`, {
|
|
167
|
+
headers: {
|
|
168
|
+
authorization: `Bearer ${keyValue}`,
|
|
169
|
+
},
|
|
170
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(`Proxy key verification failed with HTTP ${response.status}`)
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof Error && error.message.startsWith('Proxy key verification')) {
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage(error)}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
analyzeFroBotWorkflow,
|
|
7
|
+
findFroBotAgentStepBodies,
|
|
8
|
+
formatWorkflowSnippet,
|
|
9
|
+
interpretGhContentResult,
|
|
10
|
+
} from './workflow-analyzer'
|
|
11
|
+
|
|
12
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const COMPLETE_WORKFLOW = ` - uses: fro-bot/agent@abc123
|
|
15
|
+
with:
|
|
16
|
+
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
17
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
18
|
+
model: \${{ vars.FRO_BOT_MODEL }}
|
|
19
|
+
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
20
|
+
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
21
|
+
prompt: \${{ env.PROMPT }}
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const MISSING_OPENCODE_CONFIG_WORKFLOW = ` - uses: fro-bot/agent@abc123
|
|
25
|
+
with:
|
|
26
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
27
|
+
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
28
|
+
model: \${{ vars.FRO_BOT_MODEL }}
|
|
29
|
+
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
30
|
+
prompt: \${{ env.PROMPT }}
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
// Regression fixture for PR #125 review: a sibling step has `model:` as an input,
|
|
34
|
+
// but the fro-bot/agent step is missing it. The step-scoped scan must still flag
|
|
35
|
+
// `model` as missing, otherwise the diagnostic is silently suppressed.
|
|
36
|
+
const SIBLING_STEP_SHADOWS_MODEL_INPUT = `name: ci
|
|
37
|
+
on: [push]
|
|
38
|
+
jobs:
|
|
39
|
+
run:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
strategy:
|
|
42
|
+
matrix:
|
|
43
|
+
model: [opus, sonnet]
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/some-ai-step@abc
|
|
46
|
+
with:
|
|
47
|
+
model: \${{ matrix.model }}
|
|
48
|
+
- name: Run Fro Bot
|
|
49
|
+
uses: fro-bot/agent@def
|
|
50
|
+
with:
|
|
51
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
52
|
+
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
53
|
+
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
const WORKFLOW_WITHOUT_FRO_BOT_AGENT = `name: ci
|
|
57
|
+
on: [push]
|
|
58
|
+
jobs:
|
|
59
|
+
build:
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
steps:
|
|
62
|
+
- uses: actions/checkout@v4
|
|
63
|
+
- run: echo hello
|
|
64
|
+
`
|
|
65
|
+
|
|
66
|
+
// Follow-up from PR #125 second review: the matchAll refactor must report gaps
|
|
67
|
+
// in any fro-bot/agent step, not just the first. Step #1 is complete, step #2 is
|
|
68
|
+
// missing opencode-config and model — the analyzer should flag only step #2.
|
|
69
|
+
const TWO_AGENT_STEPS_SECOND_BROKEN = `name: fro-bot
|
|
70
|
+
on: [pull_request, schedule]
|
|
71
|
+
jobs:
|
|
72
|
+
review:
|
|
73
|
+
runs-on: ubuntu-latest
|
|
74
|
+
steps:
|
|
75
|
+
- name: Run Fro Bot review
|
|
76
|
+
uses: fro-bot/agent@abc123
|
|
77
|
+
with:
|
|
78
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
79
|
+
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
80
|
+
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
81
|
+
model: \${{ vars.FRO_BOT_MODEL }}
|
|
82
|
+
dispatch:
|
|
83
|
+
runs-on: ubuntu-latest
|
|
84
|
+
steps:
|
|
85
|
+
- name: Run Fro Bot dispatch
|
|
86
|
+
uses: fro-bot/agent@abc123
|
|
87
|
+
with:
|
|
88
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
89
|
+
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
90
|
+
`
|
|
91
|
+
|
|
92
|
+
// openai model prefix regression fixtures
|
|
93
|
+
const WORKFLOW_WITH_OPENAI_MODEL = ` - uses: fro-bot/agent@abc123
|
|
94
|
+
with:
|
|
95
|
+
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
96
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
97
|
+
model: openai/gpt-5.4-mini
|
|
98
|
+
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
99
|
+
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
100
|
+
prompt: \${{ env.PROMPT }}
|
|
101
|
+
`
|
|
102
|
+
|
|
103
|
+
// Dual-provider hints: omo-providers value contains "openai", model is openai/...
|
|
104
|
+
const WORKFLOW_WITH_DUAL_PROVIDER_HINTS = `name: fro-bot
|
|
105
|
+
on: [pull_request]
|
|
106
|
+
jobs:
|
|
107
|
+
review:
|
|
108
|
+
runs-on: ubuntu-latest
|
|
109
|
+
steps:
|
|
110
|
+
- name: Run Fro Bot
|
|
111
|
+
uses: fro-bot/agent@abc123
|
|
112
|
+
with:
|
|
113
|
+
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
114
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
115
|
+
model: openai/gpt-5.4-mini
|
|
116
|
+
omo-providers: anthropic,openai
|
|
117
|
+
opencode-config: \${{ secrets.OPENCODE_CONFIG }}
|
|
118
|
+
prompt: \${{ env.PROMPT }}
|
|
119
|
+
`
|
|
120
|
+
|
|
121
|
+
// Missing opencode-config but with openai model prefix — gap detection must still fire
|
|
122
|
+
const MISSING_OPENCODE_CONFIG_OPENAI_MODEL_WORKFLOW = ` - uses: fro-bot/agent@abc123
|
|
123
|
+
with:
|
|
124
|
+
auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
|
|
125
|
+
github-token: \${{ secrets.FRO_BOT_PAT }}
|
|
126
|
+
model: openai/gpt-5.4-mini
|
|
127
|
+
omo-providers: \${{ secrets.OMO_PROVIDERS }}
|
|
128
|
+
prompt: \${{ env.PROMPT }}
|
|
129
|
+
`
|
|
130
|
+
|
|
131
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
describe('cliproxy setup helpers', () => {
|
|
134
|
+
describe('analyzeFroBotWorkflow', () => {
|
|
135
|
+
it('returns empty stepsWithGaps when all four inputs are wired', () => {
|
|
136
|
+
const result = analyzeFroBotWorkflow(COMPLETE_WORKFLOW)
|
|
137
|
+
|
|
138
|
+
expect(result.kind).toBe('analyzed')
|
|
139
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
140
|
+
expect(result.stepsWithGaps).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('detects a missing opencode-config input on step #1', () => {
|
|
144
|
+
const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_WORKFLOW)
|
|
145
|
+
|
|
146
|
+
expect(result.kind).toBe('analyzed')
|
|
147
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
148
|
+
expect(result.stepsWithGaps).toHaveLength(1)
|
|
149
|
+
expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
|
|
150
|
+
expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('flags model as missing even when a sibling step uses model: as an input', () => {
|
|
154
|
+
const result = analyzeFroBotWorkflow(SIBLING_STEP_SHADOWS_MODEL_INPUT)
|
|
155
|
+
|
|
156
|
+
expect(result.kind).toBe('analyzed')
|
|
157
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
158
|
+
expect(result.stepsWithGaps).toHaveLength(1)
|
|
159
|
+
expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
|
|
160
|
+
expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['model'])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('returns kind no-agent-step when the workflow has no fro-bot/agent step', () => {
|
|
164
|
+
const result = analyzeFroBotWorkflow(WORKFLOW_WITHOUT_FRO_BOT_AGENT)
|
|
165
|
+
|
|
166
|
+
expect(result.kind).toBe('no-agent-step')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns kind no-agent-step for empty content', () => {
|
|
170
|
+
const result = analyzeFroBotWorkflow('')
|
|
171
|
+
|
|
172
|
+
expect(result.kind).toBe('no-agent-step')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('reports only the broken step when a workflow has two fro-bot/agent steps and one is complete', () => {
|
|
176
|
+
const result = analyzeFroBotWorkflow(TWO_AGENT_STEPS_SECOND_BROKEN)
|
|
177
|
+
|
|
178
|
+
expect(result.kind).toBe('analyzed')
|
|
179
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
180
|
+
expect(result.stepsWithGaps).toHaveLength(1)
|
|
181
|
+
expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(2)
|
|
182
|
+
expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config', 'model'])
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('analyzer regression for openai model prefix', () => {
|
|
187
|
+
it('returns empty stepsWithGaps for a workflow with openai/... model and all four inputs', () => {
|
|
188
|
+
const result = analyzeFroBotWorkflow(WORKFLOW_WITH_OPENAI_MODEL)
|
|
189
|
+
|
|
190
|
+
expect(result.kind).toBe('analyzed')
|
|
191
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
192
|
+
expect(result.stepsWithGaps).toEqual([])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('returns empty stepsWithGaps for a dual-provider workflow with openai/... model', () => {
|
|
196
|
+
const result = analyzeFroBotWorkflow(WORKFLOW_WITH_DUAL_PROVIDER_HINTS)
|
|
197
|
+
|
|
198
|
+
expect(result.kind).toBe('analyzed')
|
|
199
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
200
|
+
expect(result.stepsWithGaps).toEqual([])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('detects missing opencode-config even when model is openai/...', () => {
|
|
204
|
+
const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_OPENAI_MODEL_WORKFLOW)
|
|
205
|
+
|
|
206
|
+
expect(result.kind).toBe('analyzed')
|
|
207
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
208
|
+
expect(result.stepsWithGaps).toHaveLength(1)
|
|
209
|
+
expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
|
|
210
|
+
expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('detects missing opencode-config when model is anthropic/... (sanity regression)', () => {
|
|
214
|
+
const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_WORKFLOW)
|
|
215
|
+
|
|
216
|
+
expect(result.kind).toBe('analyzed')
|
|
217
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
218
|
+
expect(result.stepsWithGaps).toHaveLength(1)
|
|
219
|
+
expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
|
|
220
|
+
expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('does not emit any enable-omo warning for openai model workflows', () => {
|
|
224
|
+
const openaiResult = analyzeFroBotWorkflow(WORKFLOW_WITH_OPENAI_MODEL)
|
|
225
|
+
const dualResult = analyzeFroBotWorkflow(WORKFLOW_WITH_DUAL_PROVIDER_HINTS)
|
|
226
|
+
|
|
227
|
+
// The analyzer result shape has no warning category — only stepsWithGaps.
|
|
228
|
+
// Verify the result object has exactly the expected keys (kind + stepsWithGaps).
|
|
229
|
+
expect(Object.keys(openaiResult)).toEqual(['kind', 'stepsWithGaps'])
|
|
230
|
+
expect(Object.keys(dualResult)).toEqual(['kind', 'stepsWithGaps'])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('REQUIRED_OPENCODE_INPUTS covers exactly auth-json, opencode-config, omo-providers, model (no enable-omo)', () => {
|
|
234
|
+
// Infer the required inputs from fixture-based testing: a workflow with exactly
|
|
235
|
+
// these four inputs and no others (besides github-token and prompt) passes with zero gaps.
|
|
236
|
+
const result = analyzeFroBotWorkflow(WORKFLOW_WITH_OPENAI_MODEL)
|
|
237
|
+
|
|
238
|
+
expect(result.kind).toBe('analyzed')
|
|
239
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
240
|
+
// Zero gaps confirms the four inputs in the fixture are sufficient — enable-omo is NOT required.
|
|
241
|
+
expect(result.stepsWithGaps).toEqual([])
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('interpretGhContentResult', () => {
|
|
246
|
+
it('returns kind missing when stderr contains HTTP 404', () => {
|
|
247
|
+
const result = interpretGhContentResult({
|
|
248
|
+
exitCode: 1,
|
|
249
|
+
stdout: '',
|
|
250
|
+
stderr: 'gh: Not Found (HTTP 404)',
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
expect(result.kind).toBe('missing')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('returns kind unreachable with the stderr reason on non-404 failures', () => {
|
|
257
|
+
const result = interpretGhContentResult({
|
|
258
|
+
exitCode: 1,
|
|
259
|
+
stdout: '',
|
|
260
|
+
stderr: 'gh: API rate limit exceeded',
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
expect(result.kind).toBe('unreachable')
|
|
264
|
+
if (result.kind !== 'unreachable') throw new Error('unreachable')
|
|
265
|
+
expect(result.reason).toBe('gh: API rate limit exceeded')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('falls back to the exit code when stderr is empty on a non-404 failure', () => {
|
|
269
|
+
const result = interpretGhContentResult({exitCode: 2, stdout: '', stderr: ''})
|
|
270
|
+
|
|
271
|
+
expect(result.kind).toBe('unreachable')
|
|
272
|
+
if (result.kind !== 'unreachable') throw new Error('unreachable')
|
|
273
|
+
expect(result.reason).toBe('gh api exited with code 2')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('delegates to analyzeFroBotWorkflow on a successful response', () => {
|
|
277
|
+
const result = interpretGhContentResult({
|
|
278
|
+
exitCode: 0,
|
|
279
|
+
stdout: COMPLETE_WORKFLOW,
|
|
280
|
+
stderr: '',
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
expect(result.kind).toBe('analyzed')
|
|
284
|
+
if (result.kind !== 'analyzed') throw new Error('unreachable')
|
|
285
|
+
expect(result.stepsWithGaps).toEqual([])
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('formatWorkflowSnippet', () => {
|
|
290
|
+
it('renders snippet lines at 10-space indent so they can be pasted directly under with:', () => {
|
|
291
|
+
const snippet = formatWorkflowSnippet(['opencode-config', 'model'])
|
|
292
|
+
|
|
293
|
+
/* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
|
|
294
|
+
const expected = [
|
|
295
|
+
' opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
|
|
296
|
+
' model: ${{ vars.FRO_BOT_MODEL }}',
|
|
297
|
+
].join('\n')
|
|
298
|
+
/* eslint-enable no-template-curly-in-string */
|
|
299
|
+
expect(snippet).toBe(expected)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('findFroBotAgentStepBodies', () => {
|
|
305
|
+
it('returns empty array for content with no fro-bot/agent step', () => {
|
|
306
|
+
const bodies = findFroBotAgentStepBodies(WORKFLOW_WITHOUT_FRO_BOT_AGENT)
|
|
307
|
+
|
|
308
|
+
expect(bodies).toEqual([])
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('returns empty array for empty content', () => {
|
|
312
|
+
const bodies = findFroBotAgentStepBodies('')
|
|
313
|
+
|
|
314
|
+
expect(bodies).toEqual([])
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('returns one entry for a single fro-bot/agent step', () => {
|
|
318
|
+
const bodies = findFroBotAgentStepBodies(COMPLETE_WORKFLOW)
|
|
319
|
+
|
|
320
|
+
expect(bodies).toHaveLength(1)
|
|
321
|
+
expect(bodies[0]?.stepOrdinal).toBe(1)
|
|
322
|
+
expect(bodies[0]?.body).toContain('fro-bot/agent@')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('returns two entries for a workflow with two fro-bot/agent steps', () => {
|
|
326
|
+
const bodies = findFroBotAgentStepBodies(TWO_AGENT_STEPS_SECOND_BROKEN)
|
|
327
|
+
|
|
328
|
+
expect(bodies).toHaveLength(2)
|
|
329
|
+
expect(bodies[0]?.stepOrdinal).toBe(1)
|
|
330
|
+
expect(bodies[1]?.stepOrdinal).toBe(2)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('scopes each body to only its own step — sibling step with: block is excluded', () => {
|
|
334
|
+
const bodies = findFroBotAgentStepBodies(SIBLING_STEP_SHADOWS_MODEL_INPUT)
|
|
335
|
+
|
|
336
|
+
// Only one fro-bot/agent step in this fixture
|
|
337
|
+
expect(bodies).toHaveLength(1)
|
|
338
|
+
// The sibling step's `model:` key must NOT appear in the fro-bot/agent step body
|
|
339
|
+
expect(bodies[0]?.body).not.toContain('matrix.model')
|
|
340
|
+
})
|
|
341
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type {CommandResult} from './gh'
|
|
2
|
+
|
|
3
|
+
import {runGh} from './gh'
|
|
4
|
+
|
|
5
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type FroBotWorkflowCheck =
|
|
8
|
+
| {kind: 'missing'}
|
|
9
|
+
| {kind: 'unreachable'; reason: string}
|
|
10
|
+
| {kind: 'no-agent-step'}
|
|
11
|
+
| {
|
|
12
|
+
kind: 'analyzed'
|
|
13
|
+
stepsWithGaps: readonly {stepOrdinal: number; missingInputs: readonly string[]}[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
// github-token and prompt are intentionally excluded from this check:
|
|
19
|
+
// github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
|
|
20
|
+
// workflow-defined (the user's prompt body, not a harness default).
|
|
21
|
+
//
|
|
22
|
+
// NOTE: `enable-omo: true` is NOT a required input.
|
|
23
|
+
// For proxy-routed providers configured via OPENCODE_CONFIG.provider.<name>.options.baseURL,
|
|
24
|
+
// the fro-bot/agent action honors auth.json directly (regardless of oMo state).
|
|
25
|
+
// Source: fro-bot/agent@v0.44.3+ action.yaml lines 99-104; verified by librarian 2026-05-25.
|
|
26
|
+
const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
|
|
27
|
+
type RequiredOpencodeInput = (typeof REQUIRED_OPENCODE_INPUTS)[number]
|
|
28
|
+
|
|
29
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Slice the workflow content into one entry per `fro-bot/agent` step. Handles
|
|
33
|
+
* both the `- name:\n uses: ...` and `- uses: ...` step shapes. Returns an
|
|
34
|
+
* empty array if no fro-bot/agent step is present.
|
|
35
|
+
*
|
|
36
|
+
* Step-scoped slicing prevents false-passes where a same-named input key in a
|
|
37
|
+
* sibling step (strategy.matrix, custom actions, reusable workflow with:
|
|
38
|
+
* blocks) could mask a genuine gap in fro-bot/agent's wiring.
|
|
39
|
+
*/
|
|
40
|
+
export function findFroBotAgentStepBodies(content: string): {stepOrdinal: number; body: string}[] {
|
|
41
|
+
const bodies: {stepOrdinal: number; body: string}[] = []
|
|
42
|
+
const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm
|
|
43
|
+
|
|
44
|
+
for (const match of content.matchAll(pattern)) {
|
|
45
|
+
if (match.index === undefined || match[1] === undefined) continue
|
|
46
|
+
|
|
47
|
+
const stepBodyIndent = match[1].length
|
|
48
|
+
const dashIndent = Math.max(0, stepBodyIndent - 2)
|
|
49
|
+
const lines = content.slice(match.index).split('\n')
|
|
50
|
+
const stepLines: string[] = [lines[0] ?? '']
|
|
51
|
+
|
|
52
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
53
|
+
const line = lines[index] ?? ''
|
|
54
|
+
if (!line.trim()) {
|
|
55
|
+
stepLines.push(line)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const firstNonSpace = line.search(/\S/)
|
|
59
|
+
if (firstNonSpace === dashIndent && line.trimStart().startsWith('-')) break
|
|
60
|
+
if (firstNonSpace < dashIndent) break
|
|
61
|
+
stepLines.push(line)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
bodies.push({stepOrdinal: bodies.length + 1, body: stepLines.join('\n')})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return bodies
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Exports ──────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export function analyzeFroBotWorkflow(workflowContent: string): FroBotWorkflowCheck {
|
|
73
|
+
const steps = findFroBotAgentStepBodies(workflowContent)
|
|
74
|
+
|
|
75
|
+
if (steps.length === 0) {
|
|
76
|
+
return {kind: 'no-agent-step'}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const stepsWithGaps = steps
|
|
80
|
+
.map(step => ({
|
|
81
|
+
stepOrdinal: step.stepOrdinal,
|
|
82
|
+
missingInputs: REQUIRED_OPENCODE_INPUTS.filter(input => {
|
|
83
|
+
const inputPattern = new RegExp(String.raw`^\s+${input}:`, 'm')
|
|
84
|
+
return !inputPattern.test(step.body)
|
|
85
|
+
}),
|
|
86
|
+
}))
|
|
87
|
+
.filter(step => step.missingInputs.length > 0)
|
|
88
|
+
|
|
89
|
+
return {kind: 'analyzed', stepsWithGaps}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extracted pure helper: turn a `gh api /repos/.../contents/<file>` result into
|
|
94
|
+
* a FroBotWorkflowCheck. Separated from checkFroBotWorkflow so tests can exercise
|
|
95
|
+
* the 404-vs-transport-error logic without mocking Bun.spawn.
|
|
96
|
+
*/
|
|
97
|
+
export function interpretGhContentResult(result: CommandResult): FroBotWorkflowCheck {
|
|
98
|
+
if (result.exitCode === 0) {
|
|
99
|
+
return analyzeFroBotWorkflow(result.stdout)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// gh prints `gh: Not Found (HTTP 404)` on 404; anything else is auth/network/5xx.
|
|
103
|
+
if (/HTTP 404/.test(result.stderr)) {
|
|
104
|
+
return {kind: 'missing'}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
kind: 'unreachable',
|
|
109
|
+
reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function checkFroBotWorkflow(repo: string): Promise<FroBotWorkflowCheck> {
|
|
114
|
+
const result = await runGh([
|
|
115
|
+
'api',
|
|
116
|
+
'--header',
|
|
117
|
+
'Accept: application/vnd.github.raw',
|
|
118
|
+
`/repos/${repo}/contents/.github/workflows/fro-bot.yaml`,
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
return interpretGhContentResult(result)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Snippet uses 10-space indent to match the canonical `with:` block depth
|
|
125
|
+
// in marcusrbrown/infra/.github/workflows/fro-bot.yaml, so users can paste
|
|
126
|
+
// directly under their step's `with:` key without re-indenting.
|
|
127
|
+
export function formatWorkflowSnippet(missingInputs: readonly string[]): string {
|
|
128
|
+
/* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
|
|
129
|
+
const inputMap = {
|
|
130
|
+
'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
|
|
131
|
+
'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
|
|
132
|
+
'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
|
|
133
|
+
model: 'model: ${{ vars.FRO_BOT_MODEL }}',
|
|
134
|
+
} satisfies Record<RequiredOpencodeInput, string>
|
|
135
|
+
/* eslint-enable no-template-curly-in-string */
|
|
136
|
+
return missingInputs.map(input => ` ${(inputMap as Record<string, string>)[input]}`).join('\n')
|
|
137
|
+
}
|