@marcusrbrown/infra 0.13.1 → 0.13.2

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 +4977 -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,209 +0,0 @@
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
- // owned_by is optional — CLIProxyAPI v7 may omit it; provider is inferred from id instead.
9
- const modelEntrySchema = z
10
- .object({
11
- id: z.string(),
12
- owned_by: z.string().optional(),
13
- })
14
- .passthrough()
15
-
16
- const modelsResponseSchema = z
17
- .object({
18
- data: z.array(modelEntrySchema),
19
- })
20
- .passthrough()
21
-
22
- export const MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
23
-
24
- const HTTP_TIMEOUT_MS = 10_000
25
-
26
- type ModelEntry = z.infer<typeof modelEntrySchema>
27
-
28
- // Known bare-id patterns per provider, used only when owned_by is absent
29
- // (e.g. CLIProxyAPI v7 omits owned_by from /v1/models entries).
30
- const PROVIDER_ID_PATTERNS: Record<string, RegExp> = {
31
- openai: /^(?:gpt-|o1-|o3-|codex)/i,
32
- anthropic: /^claude-/i,
33
- }
34
-
35
- /**
36
- * Decides whether a /v1/models entry belongs to a provider.
37
- * Prefers owned_by when present; falls back to id prefix / known bare-id
38
- * patterns when absent. Display/validation-only — never an auth or trust signal.
39
- */
40
- function entryMatchesProvider(entry: ModelEntry, provider: string): boolean {
41
- if (entry.owned_by !== undefined && entry.owned_by.trim() !== '') {
42
- return entry.owned_by === provider
43
- }
44
- const id = entry.id ?? ''
45
- if (id.startsWith(`${provider}/`)) {
46
- return true
47
- }
48
- const pattern = PROVIDER_ID_PATTERNS[provider]
49
- return pattern === undefined ? false : pattern.test(id)
50
- }
51
-
52
- /** Local copy — avoids a circular import with setup.ts (validation → setup → validation). */
53
- function extractErrorMessage(error: unknown): string {
54
- return error instanceof Error ? error.message : String(error)
55
- }
56
-
57
- export function validateSetupOptions(options: SetupOptions, isInteractive: boolean): void {
58
- if (isInteractive) {
59
- return
60
- }
61
-
62
- // Validate providers/model first (independent of key/repo/harness)
63
- if (options.providers) {
64
- const providers = parseProviders(options.providers)
65
-
66
- if (providers.length > 1 && !options.model) {
67
- throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
68
- }
69
-
70
- if (options.model) {
71
- const slashIndex = options.model.indexOf('/')
72
- const prefix = slashIndex === -1 ? options.model : options.model.slice(0, slashIndex)
73
- if (!providers.includes(prefix as ProviderId)) {
74
- throw new Error(
75
- `Model prefix ${prefix} does not match selected providers (${providers.join(', ')}). Valid prefixes: ${providers.join(', ')}/`,
76
- )
77
- }
78
- }
79
- }
80
-
81
- if (!options.dryRun && !options.key) {
82
- throw new Error('--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.')
83
- }
84
-
85
- if (!options.repo) {
86
- throw new Error('--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.')
87
- }
88
-
89
- if (!options.harness) {
90
- throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
91
- }
92
-
93
- if (options.harness === 'generic') {
94
- throw new Error('--harness generic is interactive-only because it requires custom secret names.')
95
- }
96
- }
97
-
98
- /**
99
- * Verifies that the required models are available on the proxy.
100
- *
101
- * Short-circuits immediately for anthropic-only setups — no fetch is made.
102
- * Never echoes the Authorization header in any error message.
103
- */
104
- export async function verifyModelsAvailable(
105
- baseUrl: string,
106
- key: string,
107
- providers: ProviderId[],
108
- model: string,
109
- ): Promise<void> {
110
- // Anthropic-only: no fetch needed
111
- if (providers.length === 1 && providers[0] === 'anthropic') {
112
- return
113
- }
114
-
115
- const endpoint = `${baseUrl}/v1/models`
116
- const response = await fetch(endpoint, {
117
- headers: {Authorization: `Bearer ${key}`},
118
- signal: AbortSignal.timeout(10_000),
119
- })
120
-
121
- if (response.status === 401 || response.status === 403) {
122
- throw new Error('Proxy key rejected. Verify with `cliproxy keys list` or rerun setup to create a new one.')
123
- }
124
-
125
- if (!response.ok) {
126
- const rawBody = await response.text()
127
- // Redact the literal key value, Authorization headers, and sk-* shaped tokens
128
- const redacted = (key.length > 0 ? rawBody.replaceAll(key, '<redacted>') : rawBody)
129
- .replaceAll(/Bearer\s+[^\s"]+/g, 'Bearer <redacted>')
130
- .replaceAll(/sk-[\w.-]{8,}/g, 'sk-<redacted>')
131
- const excerpt = redacted.slice(0, 200)
132
- throw new Error(`/v1/models returned HTTP ${response.status}: ${excerpt}`)
133
- }
134
-
135
- const json = (await response.json()) as unknown
136
-
137
- let parsed: z.infer<typeof modelsResponseSchema>
138
- try {
139
- parsed = modelsResponseSchema.parse(json)
140
- } catch (error) {
141
- const message =
142
- error instanceof z.ZodError
143
- ? error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')
144
- : extractErrorMessage(error)
145
- throw new Error(`Malformed /v1/models response: ${message}`)
146
- }
147
-
148
- const entries = parsed.data
149
-
150
- // OpenAI presence check.
151
- if (providers.includes('openai')) {
152
- const hasOpenAi = entries.some(e => entryMatchesProvider(e, 'openai'))
153
- if (!hasOpenAi) {
154
- throw new Error('No OpenAI models on proxy — is the Codex token loaded? Try `cliproxy login codex`.')
155
- }
156
- }
157
-
158
- // Model presence check: strip provider prefix to get bare id
159
- const slashIndex = model.indexOf('/')
160
- const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1)
161
- const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined
162
-
163
- const modelPresent = entries.some(e => e.id === bareId || e.id === model)
164
- if (!modelPresent) {
165
- // List available ids for the matching provider only
166
- const matchingIds = providerPrefix
167
- ? entries.filter(e => entryMatchesProvider(e, providerPrefix)).map(e => e.id)
168
- : entries.map(e => e.id)
169
- const available = matchingIds.length > 0 ? matchingIds.join(', ') : '(none)'
170
- throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? 'models'}: ${available}`)
171
- }
172
- }
173
-
174
- export async function assertProxyReachable(baseUrl: string): Promise<void> {
175
- try {
176
- const response = await fetch(`${baseUrl}/healthz`, {
177
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
178
- })
179
-
180
- if (!response.ok) {
181
- throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`)
182
- }
183
- } catch (error) {
184
- if (error instanceof Error && error.message.startsWith('Proxy check failed')) {
185
- throw error
186
- }
187
- throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
188
- }
189
- }
190
-
191
- export async function assertProxyKeyWorks(baseUrl: string, keyValue: string): Promise<void> {
192
- try {
193
- const response = await fetch(`${baseUrl}/v1/models`, {
194
- headers: {
195
- authorization: `Bearer ${keyValue}`,
196
- },
197
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
198
- })
199
-
200
- if (!response.ok) {
201
- throw new Error(`Proxy key verification failed with HTTP ${response.status}`)
202
- }
203
- } catch (error) {
204
- if (error instanceof Error && error.message.startsWith('Proxy key verification')) {
205
- throw error
206
- }
207
- throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage(error)}`)
208
- }
209
- }
@@ -1,363 +0,0 @@
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, model (no omo-providers, no enable-omo)', () => {
234
- // A workflow that wires auth-json, opencode-config, model (but NOT omo-providers) must
235
- // pass with zero gaps — omo-providers is vestigial and must not be required.
236
- const workflowWithoutOmo = ` - uses: fro-bot/agent@abc123
237
- with:
238
- github-token: \${{ secrets.FRO_BOT_PAT }}
239
- auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
240
- model: \${{ vars.FRO_BOT_MODEL }}
241
- opencode-config: \${{ secrets.OPENCODE_CONFIG }}
242
- prompt: \${{ env.PROMPT }}
243
- `
244
- const result = analyzeFroBotWorkflow(workflowWithoutOmo)
245
-
246
- expect(result.kind).toBe('analyzed')
247
- if (result.kind !== 'analyzed') throw new Error('unreachable')
248
- // Zero gaps confirms omo-providers is NOT required.
249
- expect(result.stepsWithGaps).toEqual([])
250
- })
251
-
252
- it('a workflow missing only omo-providers (but with auth-json, opencode-config, model) is not flagged', () => {
253
- const workflowWithoutOmo = ` - uses: fro-bot/agent@abc123
254
- with:
255
- auth-json: \${{ secrets.OPENCODE_AUTH_JSON }}
256
- opencode-config: \${{ secrets.OPENCODE_CONFIG }}
257
- model: \${{ vars.FRO_BOT_MODEL }}
258
- `
259
- const result = analyzeFroBotWorkflow(workflowWithoutOmo)
260
-
261
- expect(result.kind).toBe('analyzed')
262
- if (result.kind !== 'analyzed') throw new Error('unreachable')
263
- expect(result.stepsWithGaps).toEqual([])
264
- })
265
- })
266
-
267
- describe('interpretGhContentResult', () => {
268
- it('returns kind missing when stderr contains HTTP 404', () => {
269
- const result = interpretGhContentResult({
270
- exitCode: 1,
271
- stdout: '',
272
- stderr: 'gh: Not Found (HTTP 404)',
273
- })
274
-
275
- expect(result.kind).toBe('missing')
276
- })
277
-
278
- it('returns kind unreachable with the stderr reason on non-404 failures', () => {
279
- const result = interpretGhContentResult({
280
- exitCode: 1,
281
- stdout: '',
282
- stderr: 'gh: API rate limit exceeded',
283
- })
284
-
285
- expect(result.kind).toBe('unreachable')
286
- if (result.kind !== 'unreachable') throw new Error('unreachable')
287
- expect(result.reason).toBe('gh: API rate limit exceeded')
288
- })
289
-
290
- it('falls back to the exit code when stderr is empty on a non-404 failure', () => {
291
- const result = interpretGhContentResult({exitCode: 2, stdout: '', stderr: ''})
292
-
293
- expect(result.kind).toBe('unreachable')
294
- if (result.kind !== 'unreachable') throw new Error('unreachable')
295
- expect(result.reason).toBe('gh api exited with code 2')
296
- })
297
-
298
- it('delegates to analyzeFroBotWorkflow on a successful response', () => {
299
- const result = interpretGhContentResult({
300
- exitCode: 0,
301
- stdout: COMPLETE_WORKFLOW,
302
- stderr: '',
303
- })
304
-
305
- expect(result.kind).toBe('analyzed')
306
- if (result.kind !== 'analyzed') throw new Error('unreachable')
307
- expect(result.stepsWithGaps).toEqual([])
308
- })
309
- })
310
-
311
- describe('formatWorkflowSnippet', () => {
312
- it('renders snippet lines at 10-space indent so they can be pasted directly under with:', () => {
313
- const snippet = formatWorkflowSnippet(['opencode-config', 'model'])
314
-
315
- /* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
316
- const expected = [
317
- ' opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
318
- ' model: ${{ vars.FRO_BOT_MODEL }}',
319
- ].join('\n')
320
- /* eslint-enable no-template-curly-in-string */
321
- expect(snippet).toBe(expected)
322
- })
323
- })
324
- })
325
-
326
- describe('findFroBotAgentStepBodies', () => {
327
- it('returns empty array for content with no fro-bot/agent step', () => {
328
- const bodies = findFroBotAgentStepBodies(WORKFLOW_WITHOUT_FRO_BOT_AGENT)
329
-
330
- expect(bodies).toEqual([])
331
- })
332
-
333
- it('returns empty array for empty content', () => {
334
- const bodies = findFroBotAgentStepBodies('')
335
-
336
- expect(bodies).toEqual([])
337
- })
338
-
339
- it('returns one entry for a single fro-bot/agent step', () => {
340
- const bodies = findFroBotAgentStepBodies(COMPLETE_WORKFLOW)
341
-
342
- expect(bodies).toHaveLength(1)
343
- expect(bodies[0]?.stepOrdinal).toBe(1)
344
- expect(bodies[0]?.body).toContain('fro-bot/agent@')
345
- })
346
-
347
- it('returns two entries for a workflow with two fro-bot/agent steps', () => {
348
- const bodies = findFroBotAgentStepBodies(TWO_AGENT_STEPS_SECOND_BROKEN)
349
-
350
- expect(bodies).toHaveLength(2)
351
- expect(bodies[0]?.stepOrdinal).toBe(1)
352
- expect(bodies[1]?.stepOrdinal).toBe(2)
353
- })
354
-
355
- it('scopes each body to only its own step — sibling step with: block is excluded', () => {
356
- const bodies = findFroBotAgentStepBodies(SIBLING_STEP_SHADOWS_MODEL_INPUT)
357
-
358
- // Only one fro-bot/agent step in this fixture
359
- expect(bodies).toHaveLength(1)
360
- // The sibling step's `model:` key must NOT appear in the fro-bot/agent step body
361
- expect(bodies[0]?.body).not.toContain('matrix.model')
362
- })
363
- })