@marcusrbrown/infra 0.4.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -4,13 +4,94 @@ import {describe, expect, it} from 'bun:test'
4
4
  import {goke} from 'goke'
5
5
 
6
6
  import {
7
+ analyzeFroBotWorkflow,
8
+ formatWorkflowSnippet,
7
9
  getHarnessTemplate,
10
+ interpretGhContentResult,
8
11
  registerCliproxySetup,
9
12
  validateSetupOptions,
10
13
  type SecretAssignment,
11
14
  type VariableAssignment,
12
15
  } from './setup'
13
16
 
17
+ const COMPLETE_WORKFLOW = ` - uses: fro-bot/agent@abc123
18
+ with:
19
+ github-token: \${{ secrets.FRO_BOT_PAT }}
20
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
21
+ model: \${{ vars.FRO_BOT_MODEL }}
22
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
23
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
24
+ prompt: \${{ env.PROMPT }}
25
+ `
26
+
27
+ const MISSING_OPENCODE_CONFIG_WORKFLOW = ` - uses: fro-bot/agent@abc123
28
+ with:
29
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
30
+ github-token: \${{ secrets.FRO_BOT_PAT }}
31
+ model: \${{ vars.FRO_BOT_MODEL }}
32
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
33
+ prompt: \${{ env.PROMPT }}
34
+ `
35
+
36
+ // Regression fixture for PR #125 review: a sibling step has `model:` as an input,
37
+ // but the fro-bot/agent step is missing it. The step-scoped scan must still flag
38
+ // `model` as missing, otherwise the diagnostic is silently suppressed.
39
+ const SIBLING_STEP_SHADOWS_MODEL_INPUT = `name: ci
40
+ on: [push]
41
+ jobs:
42
+ run:
43
+ runs-on: ubuntu-latest
44
+ strategy:
45
+ matrix:
46
+ model: [opus, sonnet]
47
+ steps:
48
+ - uses: actions/some-ai-step@abc
49
+ with:
50
+ model: \${{ matrix.model }}
51
+ - name: Run Fro Bot
52
+ uses: fro-bot/agent@def
53
+ with:
54
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
55
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
56
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
57
+ `
58
+
59
+ const WORKFLOW_WITHOUT_FRO_BOT_AGENT = `name: ci
60
+ on: [push]
61
+ jobs:
62
+ build:
63
+ runs-on: ubuntu-latest
64
+ steps:
65
+ - uses: actions/checkout@v4
66
+ - run: echo hello
67
+ `
68
+
69
+ // Follow-up from PR #125 second review: the matchAll refactor must report gaps
70
+ // in any fro-bot/agent step, not just the first. Step #1 is complete, step #2 is
71
+ // missing opencode-config and model — the analyzer should flag only step #2.
72
+ const TWO_AGENT_STEPS_SECOND_BROKEN = `name: fro-bot
73
+ on: [pull_request, schedule]
74
+ jobs:
75
+ review:
76
+ runs-on: ubuntu-latest
77
+ steps:
78
+ - name: Run Fro Bot review
79
+ uses: fro-bot/agent@abc123
80
+ with:
81
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
82
+ opencode-config: \${{ secrets.OPENCODE_CONFIG }}
83
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
84
+ model: \${{ vars.FRO_BOT_MODEL }}
85
+ dispatch:
86
+ runs-on: ubuntu-latest
87
+ steps:
88
+ - name: Run Fro Bot dispatch
89
+ uses: fro-bot/agent@abc123
90
+ with:
91
+ auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
92
+ omo-providers: \${{ secrets.OMO_PROVIDERS }}
93
+ `
94
+
14
95
  describe('cliproxy setup helpers', () => {
15
96
  describe('validateSetupOptions', () => {
16
97
  it('requires --key in non-interactive mode', () => {
@@ -43,6 +124,146 @@ describe('cliproxy setup helpers', () => {
43
124
  ])
44
125
  expect(template.variables.map((entry: VariableAssignment) => entry.name)).toEqual(['FRO_BOT_MODEL'])
45
126
  })
127
+
128
+ it('uses a provider-prefixed FRO_BOT_MODEL default value', () => {
129
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
130
+ const modelEntry = template.variables.find((entry: VariableAssignment) => entry.name === 'FRO_BOT_MODEL')
131
+
132
+ expect(modelEntry?.value).toMatch(/^anthropic\//)
133
+ })
134
+
135
+ it('uses the expected OMO_PROVIDERS default value', () => {
136
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
137
+ const providersEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OMO_PROVIDERS')
138
+
139
+ expect(providersEntry?.value).toBe('claude-max20')
140
+ })
141
+
142
+ it('writes an OPENCODE_CONFIG baseURL with the /v1 suffix', () => {
143
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test'})
144
+ const configEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_CONFIG')
145
+ const parsed = JSON.parse(configEntry?.value ?? '{}')
146
+
147
+ expect(parsed.provider.anthropic.options.baseURL).toMatch(/\/v1$/)
148
+ })
149
+
150
+ it('writes OPENCODE_AUTH_JSON with type=api and the supplied key', () => {
151
+ const template = getHarnessTemplate('opencode', {keyValue: 'sk-test-key'})
152
+ const authEntry = template.secrets.find((entry: SecretAssignment) => entry.name === 'OPENCODE_AUTH_JSON')
153
+ const parsed = JSON.parse(authEntry?.value ?? '{}')
154
+
155
+ expect(parsed.anthropic).toEqual({type: 'api', key: 'sk-test-key'})
156
+ })
157
+ })
158
+
159
+ describe('analyzeFroBotWorkflow', () => {
160
+ it('returns empty stepsWithGaps when all four inputs are wired', () => {
161
+ const result = analyzeFroBotWorkflow(COMPLETE_WORKFLOW)
162
+
163
+ expect(result.kind).toBe('analyzed')
164
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
165
+ expect(result.stepsWithGaps).toEqual([])
166
+ })
167
+
168
+ it('detects a missing opencode-config input on step #1', () => {
169
+ const result = analyzeFroBotWorkflow(MISSING_OPENCODE_CONFIG_WORKFLOW)
170
+
171
+ expect(result.kind).toBe('analyzed')
172
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
173
+ expect(result.stepsWithGaps).toHaveLength(1)
174
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
175
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config'])
176
+ })
177
+
178
+ it('flags model as missing even when a sibling step uses model: as an input', () => {
179
+ const result = analyzeFroBotWorkflow(SIBLING_STEP_SHADOWS_MODEL_INPUT)
180
+
181
+ expect(result.kind).toBe('analyzed')
182
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
183
+ expect(result.stepsWithGaps).toHaveLength(1)
184
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(1)
185
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['model'])
186
+ })
187
+
188
+ it('returns kind no-agent-step when the workflow has no fro-bot/agent step', () => {
189
+ const result = analyzeFroBotWorkflow(WORKFLOW_WITHOUT_FRO_BOT_AGENT)
190
+
191
+ expect(result.kind).toBe('no-agent-step')
192
+ })
193
+
194
+ it('returns kind no-agent-step for empty content', () => {
195
+ const result = analyzeFroBotWorkflow('')
196
+
197
+ expect(result.kind).toBe('no-agent-step')
198
+ })
199
+
200
+ it('reports only the broken step when a workflow has two fro-bot/agent steps and one is complete', () => {
201
+ const result = analyzeFroBotWorkflow(TWO_AGENT_STEPS_SECOND_BROKEN)
202
+
203
+ expect(result.kind).toBe('analyzed')
204
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
205
+ expect(result.stepsWithGaps).toHaveLength(1)
206
+ expect(result.stepsWithGaps[0]?.stepOrdinal).toBe(2)
207
+ expect([...(result.stepsWithGaps[0]?.missingInputs ?? [])]).toEqual(['opencode-config', 'model'])
208
+ })
209
+ })
210
+
211
+ describe('interpretGhContentResult', () => {
212
+ it('returns kind missing when stderr contains HTTP 404', () => {
213
+ const result = interpretGhContentResult({
214
+ exitCode: 1,
215
+ stdout: '',
216
+ stderr: 'gh: Not Found (HTTP 404)',
217
+ })
218
+
219
+ expect(result.kind).toBe('missing')
220
+ })
221
+
222
+ it('returns kind unreachable with the stderr reason on non-404 failures', () => {
223
+ const result = interpretGhContentResult({
224
+ exitCode: 1,
225
+ stdout: '',
226
+ stderr: 'gh: API rate limit exceeded',
227
+ })
228
+
229
+ expect(result.kind).toBe('unreachable')
230
+ if (result.kind !== 'unreachable') throw new Error('unreachable')
231
+ expect(result.reason).toBe('gh: API rate limit exceeded')
232
+ })
233
+
234
+ it('falls back to the exit code when stderr is empty on a non-404 failure', () => {
235
+ const result = interpretGhContentResult({exitCode: 2, stdout: '', stderr: ''})
236
+
237
+ expect(result.kind).toBe('unreachable')
238
+ if (result.kind !== 'unreachable') throw new Error('unreachable')
239
+ expect(result.reason).toBe('gh api exited with code 2')
240
+ })
241
+
242
+ it('delegates to analyzeFroBotWorkflow on a successful response', () => {
243
+ const result = interpretGhContentResult({
244
+ exitCode: 0,
245
+ stdout: COMPLETE_WORKFLOW,
246
+ stderr: '',
247
+ })
248
+
249
+ expect(result.kind).toBe('analyzed')
250
+ if (result.kind !== 'analyzed') throw new Error('unreachable')
251
+ expect(result.stepsWithGaps).toEqual([])
252
+ })
253
+ })
254
+
255
+ describe('formatWorkflowSnippet', () => {
256
+ it('renders snippet lines at 10-space indent so they can be pasted directly under with:', () => {
257
+ const snippet = formatWorkflowSnippet(['opencode-config', 'model'])
258
+
259
+ /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
260
+ const expected = [
261
+ ' opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
262
+ ' model: ${{ vars.FRO_BOT_MODEL }}',
263
+ ].join('\n')
264
+ /* eslint-enable no-template-curly-in-string */
265
+ expect(snippet).toBe(expected)
266
+ })
46
267
  })
47
268
 
48
269
  describe('help output', () => {
@@ -12,7 +12,7 @@ import {toStringArray} from './keys'
12
12
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
13
13
  const HTTP_TIMEOUT_MS = 10_000
14
14
  const DEFAULT_OMO_PROVIDERS = 'claude-max20'
15
- const DEFAULT_FRO_BOT_MODEL = 'claude-sonnet-4-6'
15
+ const DEFAULT_FRO_BOT_MODEL = 'anthropic/claude-sonnet-4-6'
16
16
 
17
17
  const harnessSchema = z.enum(['opencode', 'claude-code', 'generic'])
18
18
  const ghRepoViewSchema = z.object({
@@ -263,6 +263,126 @@ async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): P
263
263
  return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
264
264
  }
265
265
 
266
+ export type FroBotWorkflowCheck =
267
+ | {kind: 'missing'}
268
+ | {kind: 'unreachable'; reason: string}
269
+ | {kind: 'no-agent-step'}
270
+ | {
271
+ kind: 'analyzed'
272
+ stepsWithGaps: readonly {stepOrdinal: number; missingInputs: readonly string[]}[]
273
+ }
274
+
275
+ // github-token and prompt are intentionally excluded from this check:
276
+ // github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
277
+ // workflow-defined (the user's prompt body, not a harness default).
278
+ const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
279
+
280
+ /**
281
+ * Slice the workflow content into one entry per `fro-bot/agent` step. Handles
282
+ * both the `- name:\n uses: ...` and `- uses: ...` step shapes. Returns an
283
+ * empty array if no fro-bot/agent step is present.
284
+ *
285
+ * Step-scoped slicing prevents false-passes where a same-named input key in a
286
+ * sibling step (strategy.matrix, custom actions, reusable workflow with:
287
+ * blocks) could mask a genuine gap in fro-bot/agent's wiring.
288
+ */
289
+ function findFroBotAgentStepBodies(content: string): {stepOrdinal: number; body: string}[] {
290
+ const bodies: {stepOrdinal: number; body: string}[] = []
291
+ const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm
292
+
293
+ for (const match of content.matchAll(pattern)) {
294
+ if (match.index === undefined || match[1] === undefined) continue
295
+
296
+ const stepBodyIndent = match[1].length
297
+ const dashIndent = Math.max(0, stepBodyIndent - 2)
298
+ const lines = content.slice(match.index).split('\n')
299
+ const stepLines: string[] = [lines[0] ?? '']
300
+
301
+ for (let index = 1; index < lines.length; index += 1) {
302
+ const line = lines[index] ?? ''
303
+ if (!line.trim()) {
304
+ stepLines.push(line)
305
+ continue
306
+ }
307
+ const firstNonSpace = line.search(/\S/)
308
+ if (firstNonSpace === dashIndent && line.trimStart().startsWith('-')) break
309
+ if (firstNonSpace < dashIndent) break
310
+ stepLines.push(line)
311
+ }
312
+
313
+ bodies.push({stepOrdinal: bodies.length + 1, body: stepLines.join('\n')})
314
+ }
315
+
316
+ return bodies
317
+ }
318
+
319
+ export function analyzeFroBotWorkflow(workflowContent: string): FroBotWorkflowCheck {
320
+ const steps = findFroBotAgentStepBodies(workflowContent)
321
+
322
+ if (steps.length === 0) {
323
+ return {kind: 'no-agent-step'}
324
+ }
325
+
326
+ const stepsWithGaps = steps
327
+ .map(step => ({
328
+ stepOrdinal: step.stepOrdinal,
329
+ missingInputs: REQUIRED_OPENCODE_INPUTS.filter(input => {
330
+ const inputPattern = new RegExp(String.raw`^\s+${input}:`, 'm')
331
+ return !inputPattern.test(step.body)
332
+ }),
333
+ }))
334
+ .filter(step => step.missingInputs.length > 0)
335
+
336
+ return {kind: 'analyzed', stepsWithGaps}
337
+ }
338
+
339
+ /**
340
+ * Extracted pure helper: turn a `gh api /repos/.../contents/<file>` result into
341
+ * a FroBotWorkflowCheck. Separated from checkFroBotWorkflow so tests can exercise
342
+ * the 404-vs-transport-error logic without mocking Bun.spawn.
343
+ */
344
+ export function interpretGhContentResult(result: CommandResult): FroBotWorkflowCheck {
345
+ if (result.exitCode === 0) {
346
+ return analyzeFroBotWorkflow(result.stdout)
347
+ }
348
+
349
+ // gh prints `gh: Not Found (HTTP 404)` on 404; anything else is auth/network/5xx.
350
+ if (/HTTP 404/.test(result.stderr)) {
351
+ return {kind: 'missing'}
352
+ }
353
+
354
+ return {
355
+ kind: 'unreachable',
356
+ reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`,
357
+ }
358
+ }
359
+
360
+ async function checkFroBotWorkflow(repo: string): Promise<FroBotWorkflowCheck> {
361
+ const result = await runGh([
362
+ 'api',
363
+ '--header',
364
+ 'Accept: application/vnd.github.raw',
365
+ `/repos/${repo}/contents/.github/workflows/fro-bot.yaml`,
366
+ ])
367
+
368
+ return interpretGhContentResult(result)
369
+ }
370
+
371
+ // Snippet uses 10-space indent to match the canonical `with:` block depth
372
+ // in marcusrbrown/infra/.github/workflows/fro-bot.yaml, so users can paste
373
+ // directly under their step's `with:` key without re-indenting.
374
+ export function formatWorkflowSnippet(missingInputs: readonly string[]): string {
375
+ /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
376
+ const inputMap: Record<string, string> = {
377
+ 'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
378
+ 'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
379
+ 'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
380
+ model: 'model: ${{ vars.FRO_BOT_MODEL }}',
381
+ }
382
+ /* eslint-enable no-template-curly-in-string */
383
+ return missingInputs.map(input => ` ${inputMap[input]}`).join('\n')
384
+ }
385
+
266
386
  async function assertProxyReachable(baseUrl: string): Promise<void> {
267
387
  try {
268
388
  const response = await fetch(baseUrl, {
@@ -691,6 +811,54 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
691
811
  await withSpinner('Verifying the new key through the proxy', async () => {
692
812
  await assertProxyKeyWorks(baseUrl, plan.keyValue)
693
813
  })
814
+
815
+ if (plan.harness === 'opencode') {
816
+ const workflow = await withSpinner(`Checking ${plan.repo} fro-bot.yaml wiring`, async () => {
817
+ return checkFroBotWorkflow(plan.repo)
818
+ })
819
+
820
+ switch (workflow.kind) {
821
+ case 'missing': {
822
+ log.warn(
823
+ `No .github/workflows/fro-bot.yaml found in ${plan.repo}. The secrets and variables are set, but Fro Bot won't run until the workflow exists and passes them as inputs. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`,
824
+ )
825
+ break
826
+ }
827
+ case 'unreachable': {
828
+ log.warn(
829
+ `Could not check .github/workflows/fro-bot.yaml in ${plan.repo}: ${workflow.reason}. The secrets and variables are set, but the workflow wiring was not verified. Re-run 'infra cliproxy setup' later to confirm, or inspect the file directly.`,
830
+ )
831
+ break
832
+ }
833
+ case 'no-agent-step': {
834
+ log.warn(
835
+ `${plan.repo} .github/workflows/fro-bot.yaml exists but has no 'fro-bot/agent' step. Add one that passes the secrets and variables just written. See marcusrbrown/infra/.github/workflows/fro-bot.yaml for a reference.`,
836
+ )
837
+ break
838
+ }
839
+ case 'analyzed': {
840
+ for (const step of workflow.stepsWithGaps) {
841
+ const missing = [...step.missingInputs]
842
+ log.warn(
843
+ [
844
+ `${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
845
+ missing.length > 1 ? 's' : ''
846
+ } (${missing.join(', ')}).`,
847
+ `Without ${missing.includes('opencode-config') ? 'opencode-config, the baseURL override is ignored and Fro Bot hits api.anthropic.com with the proxy key, which fails with 401' : 'these, the secrets you just wrote will not reach OpenCode'}.`,
848
+ '',
849
+ `Add under the 'with:' block of the 'fro-bot/agent' step:`,
850
+ formatWorkflowSnippet(missing),
851
+ ].join('\n'),
852
+ )
853
+ }
854
+ break
855
+ }
856
+ default: {
857
+ const _exhaustive: never = workflow
858
+ throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
859
+ }
860
+ }
861
+ }
694
862
  } catch (mutationError) {
695
863
  if (keyCreatedByThisRun && managementKey) {
696
864
  try {