@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
|
@@ -1,155 +1,50 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
|
-
import type {SpinnerResult} from '@clack/prompts'
|
|
4
3
|
import type {goke} from 'goke'
|
|
5
4
|
|
|
6
|
-
import {
|
|
5
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
6
|
+
|
|
7
|
+
import {cancel, confirm, intro, log, note, outro, select, text} from '@clack/prompts'
|
|
7
8
|
import {z} from 'zod'
|
|
8
9
|
|
|
9
10
|
import {resolveManagementKey} from './config'
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
applyGhValue,
|
|
13
|
+
assertGhAuthenticated,
|
|
14
|
+
assertGhInstalled,
|
|
15
|
+
assertRepoAccess,
|
|
16
|
+
createManagementApiKey,
|
|
17
|
+
deleteManagementApiKey,
|
|
18
|
+
listExistingGhNames,
|
|
19
|
+
withGhRetry,
|
|
20
|
+
withSpinner,
|
|
21
|
+
} from './setup/gh'
|
|
22
|
+
import {formatDryRunPreview} from './setup/preview'
|
|
23
|
+
import {buildApiKeyValue, cancelAndExit, ensureRepoFormat, promptGenericSecretNames, promptValue} from './setup/prompts'
|
|
24
|
+
import {parseProviders, promptForModel, promptForProviders, PROVIDER_DEFAULTS, type ProviderId} from './setup/providers'
|
|
25
|
+
import {runSmokeTest} from './setup/smoke-test'
|
|
26
|
+
import {
|
|
27
|
+
collectCollisions,
|
|
28
|
+
formatTemplateSummary,
|
|
29
|
+
getHarnessTemplate,
|
|
30
|
+
harnessSchema,
|
|
31
|
+
stripTrailingSlash,
|
|
32
|
+
type Harness,
|
|
33
|
+
type HarnessTemplate,
|
|
34
|
+
} from './setup/templates'
|
|
35
|
+
import {
|
|
36
|
+
assertProxyKeyWorks,
|
|
37
|
+
assertProxyReachable,
|
|
38
|
+
MODEL_ID_RE,
|
|
39
|
+
validateSetupOptions,
|
|
40
|
+
verifyModelsAvailable,
|
|
41
|
+
} from './setup/validation'
|
|
42
|
+
import {checkFroBotWorkflow, formatWorkflowSnippet} from './setup/workflow-analyzer'
|
|
43
|
+
|
|
44
|
+
export {formatDryRunPreview, type DryRunPreviewOptions} from './setup/preview'
|
|
45
|
+
export {validateSetupOptions, verifyModelsAvailable} from './setup/validation'
|
|
11
46
|
|
|
12
47
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
13
|
-
const HTTP_TIMEOUT_MS = 10_000
|
|
14
|
-
|
|
15
|
-
const harnessSchema = z.enum(['opencode', 'claude-code', 'generic'])
|
|
16
|
-
const ghRepoViewSchema = z.object({
|
|
17
|
-
nameWithOwner: z.string(),
|
|
18
|
-
viewerPermission: z.string(),
|
|
19
|
-
})
|
|
20
|
-
const ghNameListSchema = z.array(z.object({name: z.string()}))
|
|
21
|
-
|
|
22
|
-
export type Harness = z.infer<typeof harnessSchema>
|
|
23
|
-
|
|
24
|
-
const providerIdSchema = z.enum(['anthropic', 'openai'])
|
|
25
|
-
export type ProviderId = z.infer<typeof providerIdSchema>
|
|
26
|
-
|
|
27
|
-
const MODEL_ID_RE = /^(?:anthropic|openai)\/[a-z\d](?:[a-z\d.\-]*[a-z\d])?$/
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Parse a comma-separated provider list string into a validated ProviderId array.
|
|
31
|
-
* Rejects empty input, unknown providers, and duplicates.
|
|
32
|
-
*/
|
|
33
|
-
export function parseProviders(input: string): ProviderId[] {
|
|
34
|
-
const parts = input
|
|
35
|
-
.split(',')
|
|
36
|
-
.map(p => p.trim())
|
|
37
|
-
.filter(Boolean)
|
|
38
|
-
|
|
39
|
-
if (parts.length === 0) {
|
|
40
|
-
throw new Error('--providers must not be empty. Supported values: anthropic, openai')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const parsed = parts.map(p => {
|
|
44
|
-
const result = providerIdSchema.safeParse(p)
|
|
45
|
-
if (!result.success) {
|
|
46
|
-
throw new Error(`Unknown provider "${p}". Supported values: anthropic, openai`)
|
|
47
|
-
}
|
|
48
|
-
return result.data
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
const deduped = new Set(parsed)
|
|
52
|
-
if (deduped.size < parsed.length) {
|
|
53
|
-
throw new Error(`--providers contains duplicate values: ${parsed.join(',')}`)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return parsed
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const PROVIDER_DEFAULTS: Record<ProviderId, string> = {
|
|
60
|
-
anthropic: 'anthropic/claude-sonnet-4-6',
|
|
61
|
-
openai: 'openai/gpt-5.4-mini',
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const CUSTOM_MODEL_SENTINEL = '__custom__'
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Interactively prompt the user to select one or more providers.
|
|
68
|
-
* Anthropic is pre-checked. Empty selection re-prompts.
|
|
69
|
-
*/
|
|
70
|
-
export async function promptForProviders(): Promise<ProviderId[]> {
|
|
71
|
-
let providers: ProviderId[] = []
|
|
72
|
-
|
|
73
|
-
do {
|
|
74
|
-
const result = await multiselect<ProviderId>({
|
|
75
|
-
message: 'Select providers to configure',
|
|
76
|
-
options: [
|
|
77
|
-
{value: 'anthropic', label: 'Anthropic'},
|
|
78
|
-
{value: 'openai', label: 'OpenAI'},
|
|
79
|
-
],
|
|
80
|
-
initialValues: ['anthropic'],
|
|
81
|
-
required: false,
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
if (isCancel(result)) {
|
|
85
|
-
cancelAndExit('Setup cancelled before selecting providers.')
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
providers = result as ProviderId[]
|
|
89
|
-
} while (providers.length === 0)
|
|
90
|
-
|
|
91
|
-
return providers
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Interactively prompt the user to select a default model.
|
|
96
|
-
* When only one provider is selected, returns that provider's default immediately.
|
|
97
|
-
* When multiple providers are selected, shows a select with preset options and a custom entry.
|
|
98
|
-
*/
|
|
99
|
-
export async function promptForModel(providers: ProviderId[]): Promise<string> {
|
|
100
|
-
if (providers.length === 1) {
|
|
101
|
-
return PROVIDER_DEFAULTS[providers[0] as ProviderId]
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const chosen = await select<string>({
|
|
105
|
-
message: 'Choose a default model',
|
|
106
|
-
options: [
|
|
107
|
-
{value: 'openai/gpt-5.4-mini', label: 'openai/gpt-5.4-mini'},
|
|
108
|
-
{value: 'anthropic/claude-sonnet-4-6', label: 'anthropic/claude-sonnet-4-6'},
|
|
109
|
-
{value: CUSTOM_MODEL_SENTINEL, label: 'Enter custom model ID...'},
|
|
110
|
-
],
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
if (isCancel(chosen)) {
|
|
114
|
-
cancelAndExit('Setup cancelled before selecting a model.')
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (chosen === CUSTOM_MODEL_SENTINEL) {
|
|
118
|
-
return promptForCustomModel()
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return chosen as string
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function promptForCustomModel(): Promise<string> {
|
|
125
|
-
let modelId: string | undefined
|
|
126
|
-
|
|
127
|
-
do {
|
|
128
|
-
const result = await text({
|
|
129
|
-
message: 'Enter a custom model ID (e.g. openai/gpt-5.4-mini)',
|
|
130
|
-
placeholder: 'provider/model-name',
|
|
131
|
-
validate: value => {
|
|
132
|
-
if (!MODEL_ID_RE.test(value ?? '')) {
|
|
133
|
-
return 'Model ID must match provider/model-name (lowercase, digits, dots, hyphens only)'
|
|
134
|
-
}
|
|
135
|
-
return undefined
|
|
136
|
-
},
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
if (isCancel(result)) {
|
|
140
|
-
cancelAndExit('Setup cancelled before entering a custom model ID.')
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const candidate = result as string
|
|
144
|
-
if (MODEL_ID_RE.test(candidate)) {
|
|
145
|
-
modelId = candidate
|
|
146
|
-
}
|
|
147
|
-
// If the mock bypasses clack's internal validate and returns a bad value,
|
|
148
|
-
// loop again to re-prompt.
|
|
149
|
-
} while (!modelId)
|
|
150
|
-
|
|
151
|
-
return modelId
|
|
152
|
-
}
|
|
153
48
|
|
|
154
49
|
export interface SetupOptions {
|
|
155
50
|
key?: string
|
|
@@ -161,26 +56,7 @@ export interface SetupOptions {
|
|
|
161
56
|
force?: boolean
|
|
162
57
|
dryRun?: boolean
|
|
163
58
|
verifySmoke?: boolean
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
export interface SecretAssignment {
|
|
167
|
-
name: string
|
|
168
|
-
value: string
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export interface VariableAssignment {
|
|
172
|
-
name: string
|
|
173
|
-
value: string
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export interface HarnessTemplate {
|
|
177
|
-
secrets: SecretAssignment[]
|
|
178
|
-
variables: VariableAssignment[]
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
interface GenericSecretNames {
|
|
182
|
-
apiKeySecretName: string
|
|
183
|
-
baseUrlSecretName: string
|
|
59
|
+
ackKeyReuse?: boolean
|
|
184
60
|
}
|
|
185
61
|
|
|
186
62
|
interface SetupPlan {
|
|
@@ -192,749 +68,63 @@ interface SetupPlan {
|
|
|
192
68
|
template: HarnessTemplate
|
|
193
69
|
}
|
|
194
70
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (isInteractive) {
|
|
211
|
-
return
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Validate providers/model first (independent of key/repo/harness)
|
|
215
|
-
if (options.providers) {
|
|
216
|
-
const providers = parseProviders(options.providers)
|
|
217
|
-
|
|
218
|
-
if (providers.length > 1 && !options.model) {
|
|
219
|
-
throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (options.model) {
|
|
223
|
-
const slashIndex = options.model.indexOf('/')
|
|
224
|
-
const prefix = slashIndex === -1 ? options.model : options.model.slice(0, slashIndex)
|
|
225
|
-
if (!providers.includes(prefix as ProviderId)) {
|
|
226
|
-
throw new Error(
|
|
227
|
-
`Model prefix ${prefix} does not match selected providers (${providers.join(', ')}). Valid prefixes: ${providers.join(', ')}/`,
|
|
228
|
-
)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (!options.dryRun && !options.key) {
|
|
234
|
-
throw new Error('--key is required when stdin is not a TTY. Provide an existing CLIProxyAPI key value.')
|
|
71
|
+
// Internal: test-only DI surface. Not part of the published API.
|
|
72
|
+
export interface RunSetupDeps {
|
|
73
|
+
interactive?: boolean
|
|
74
|
+
baseUrl?: string
|
|
75
|
+
ctx?: ActionCtx
|
|
76
|
+
resolveManagementKey?: typeof resolveManagementKey
|
|
77
|
+
gh?: {
|
|
78
|
+
assertGhInstalled: typeof assertGhInstalled
|
|
79
|
+
assertGhAuthenticated: typeof assertGhAuthenticated
|
|
80
|
+
assertRepoAccess: typeof assertRepoAccess
|
|
81
|
+
listExistingGhNames: typeof listExistingGhNames
|
|
82
|
+
createManagementApiKey: typeof createManagementApiKey
|
|
83
|
+
deleteManagementApiKey: typeof deleteManagementApiKey
|
|
84
|
+
applyGhValue: typeof applyGhValue
|
|
85
|
+
withGhRetry: typeof withGhRetry
|
|
235
86
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
87
|
+
prompts?: {
|
|
88
|
+
promptValue: typeof promptValue
|
|
89
|
+
confirm: typeof confirm
|
|
90
|
+
intro: typeof intro
|
|
91
|
+
note: typeof note
|
|
92
|
+
outro: typeof outro
|
|
239
93
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
|
|
94
|
+
smoke?: {
|
|
95
|
+
runSmokeTest: typeof runSmokeTest
|
|
243
96
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
97
|
+
validation?: {
|
|
98
|
+
assertProxyReachable: typeof assertProxyReachable
|
|
99
|
+
assertProxyKeyWorks: typeof assertProxyKeyWorks
|
|
100
|
+
verifyModelsAvailable: typeof verifyModelsAvailable
|
|
247
101
|
}
|
|
248
102
|
}
|
|
249
103
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
values: {
|
|
253
|
-
keyValue?: string
|
|
254
|
-
baseUrl?: string
|
|
255
|
-
genericSecretNames?: GenericSecretNames
|
|
256
|
-
providers?: ProviderId[]
|
|
257
|
-
model?: string
|
|
258
|
-
} = {},
|
|
259
|
-
): HarnessTemplate {
|
|
260
|
-
const keyValue = values.keyValue ?? 'sk-placeholder'
|
|
261
|
-
const baseUrl = stripTrailingSlash(values.baseUrl ?? DEFAULT_CLIPROXY_URL)
|
|
262
|
-
|
|
263
|
-
if (harness === 'opencode') {
|
|
264
|
-
// Normalize provider list: default to anthropic-only, always sort anthropic first
|
|
265
|
-
const rawProviders = values.providers ?? ['anthropic']
|
|
266
|
-
// Stable ordering: anthropic always before openai regardless of input order
|
|
267
|
-
const PROVIDER_ORDER: ProviderId[] = ['anthropic', 'openai']
|
|
268
|
-
const providers = PROVIDER_ORDER.filter(p => rawProviders.includes(p))
|
|
269
|
-
|
|
270
|
-
// Resolve model
|
|
271
|
-
let model: string
|
|
272
|
-
if (values.model) {
|
|
273
|
-
model = values.model
|
|
274
|
-
} else if (providers.length === 1) {
|
|
275
|
-
model = PROVIDER_DEFAULTS[providers[0] as ProviderId]
|
|
276
|
-
} else {
|
|
277
|
-
throw new Error('model required when multiple providers selected')
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// OMO_PROVIDERS token map
|
|
281
|
-
const OMO_TOKEN: Record<ProviderId, string> = {
|
|
282
|
-
anthropic: 'claude-max20',
|
|
283
|
-
openai: 'openai',
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Build auth JSON object (anthropic-first insertion order)
|
|
287
|
-
const authObj: Record<string, {type: string; key: string}> = {}
|
|
288
|
-
for (const p of providers) {
|
|
289
|
-
authObj[p] = {type: 'api', key: keyValue}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Build config JSON object (anthropic-first insertion order)
|
|
293
|
-
const providerConfig: Record<string, {options: {baseURL: string}}> = {}
|
|
294
|
-
for (const p of providers) {
|
|
295
|
-
providerConfig[p] = {options: {baseURL: `${baseUrl}/v1`}}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const omoProviders = providers.map(p => OMO_TOKEN[p]).join(',')
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
secrets: [
|
|
302
|
-
{
|
|
303
|
-
name: 'OPENCODE_AUTH_JSON',
|
|
304
|
-
value: JSON.stringify(authObj),
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
name: 'OPENCODE_CONFIG',
|
|
308
|
-
value: JSON.stringify({provider: providerConfig}),
|
|
309
|
-
},
|
|
310
|
-
{
|
|
311
|
-
name: 'OMO_PROVIDERS',
|
|
312
|
-
value: omoProviders,
|
|
313
|
-
},
|
|
314
|
-
],
|
|
315
|
-
variables: [
|
|
316
|
-
{
|
|
317
|
-
name: 'FRO_BOT_MODEL',
|
|
318
|
-
value: model,
|
|
319
|
-
},
|
|
320
|
-
],
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (harness === 'claude-code') {
|
|
325
|
-
return {
|
|
326
|
-
secrets: [
|
|
327
|
-
{
|
|
328
|
-
name: 'ANTHROPIC_API_KEY',
|
|
329
|
-
value: keyValue,
|
|
330
|
-
},
|
|
331
|
-
],
|
|
332
|
-
variables: [],
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (!values.genericSecretNames) {
|
|
337
|
-
throw new Error('Generic harness requires custom secret names.')
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return {
|
|
341
|
-
secrets: [
|
|
342
|
-
{name: values.genericSecretNames.apiKeySecretName, value: keyValue},
|
|
343
|
-
{name: values.genericSecretNames.baseUrlSecretName, value: `${baseUrl}/v1`},
|
|
344
|
-
],
|
|
345
|
-
variables: [],
|
|
346
|
-
}
|
|
104
|
+
function resolveBaseUrl(input?: string): string {
|
|
105
|
+
return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
347
106
|
}
|
|
348
107
|
|
|
349
108
|
function extractErrorMessage(error: unknown): string {
|
|
350
109
|
return error instanceof Error ? error.message : String(error)
|
|
351
110
|
}
|
|
352
111
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function ensureSecretName(value: string, label: string): string {
|
|
362
|
-
const trimmed = value.trim()
|
|
363
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
|
|
364
|
-
throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`)
|
|
365
|
-
}
|
|
366
|
-
return trimmed
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function cancelAndExit(message = 'Setup cancelled.'): never {
|
|
370
|
-
cancel(message)
|
|
371
|
-
process.exit(0)
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function promptValue<T extends string | boolean>(
|
|
375
|
-
promise: Promise<T | symbol>,
|
|
376
|
-
cancelMessage?: string,
|
|
377
|
-
): Promise<T> {
|
|
378
|
-
const value = await promise
|
|
379
|
-
if (isCancel(value)) {
|
|
380
|
-
cancelAndExit(cancelMessage)
|
|
381
|
-
}
|
|
382
|
-
return value
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
async function withSpinner<T>(message: string, run: (spinnerInstance: SpinnerResult) => Promise<T>): Promise<T> {
|
|
386
|
-
const spinnerInstance = spinner()
|
|
387
|
-
spinnerInstance.start(message)
|
|
388
|
-
|
|
389
|
-
try {
|
|
390
|
-
const result = await run(spinnerInstance)
|
|
391
|
-
spinnerInstance.stop(message)
|
|
392
|
-
return result
|
|
393
|
-
} catch (error) {
|
|
394
|
-
spinnerInstance.error(`${message} failed`)
|
|
395
|
-
throw error
|
|
396
|
-
}
|
|
112
|
+
// Redact a bearer token for display in interactive prompts — never show raw key values.
|
|
113
|
+
// Exported for direct unit testing of the redaction contract. The redacted form is
|
|
114
|
+
// what gets shown in the interactive R8 prompt; the raw key must never reach the prompt UI.
|
|
115
|
+
export function redactKey(key: string): string {
|
|
116
|
+
if (key.length < 12) return 'sk-***'
|
|
117
|
+
return `${key.slice(0, 3)}***${key.slice(-4)}`
|
|
397
118
|
}
|
|
398
119
|
|
|
399
|
-
async function
|
|
400
|
-
|
|
401
|
-
stdout: 'pipe',
|
|
402
|
-
stderr: 'pipe',
|
|
403
|
-
env: process.env,
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
407
|
-
new Response(child.stdout).text(),
|
|
408
|
-
new Response(child.stderr).text(),
|
|
409
|
-
child.exited,
|
|
410
|
-
])
|
|
411
|
-
|
|
412
|
-
return {stdout, stderr, exitCode}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
async function runGh(args: string[]): Promise<CommandResult> {
|
|
416
|
-
return runCommand('gh', args)
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function isGhRateLimitError(text: string): boolean {
|
|
420
|
-
return /rate limit/i.test(text)
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
|
|
425
|
-
* exempt from rate limiting itself, so this should succeed even when the
|
|
426
|
-
* primary GraphQL limit is exhausted. Returns a formatted local time string
|
|
427
|
-
* or a fallback phrase when the endpoint is unreachable.
|
|
428
|
-
*/
|
|
429
|
-
async function queryRateLimitReset(): Promise<string> {
|
|
430
|
-
try {
|
|
431
|
-
const result = await runGh(['api', 'rate_limit'])
|
|
432
|
-
if (result.exitCode === 0) {
|
|
433
|
-
const parsed = JSON.parse(result.stdout) as {
|
|
434
|
-
resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
|
|
435
|
-
}
|
|
436
|
-
const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
|
|
437
|
-
if (reset) {
|
|
438
|
-
return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
} catch {
|
|
442
|
-
// Fall through to generic phrase
|
|
443
|
-
}
|
|
444
|
-
return 'an unknown time'
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
|
|
449
|
-
* rate-limit errors when in interactive mode. In non-interactive mode the
|
|
450
|
-
* error is re-thrown with the reset time appended so the caller can surface
|
|
451
|
-
* it without prompting.
|
|
452
|
-
*/
|
|
453
|
-
export async function withGhRetry<T>(
|
|
454
|
-
label: string,
|
|
455
|
-
fn: (spinnerInstance: SpinnerResult) => Promise<T>,
|
|
456
|
-
interactive: boolean,
|
|
457
|
-
queryReset: () => Promise<string> = queryRateLimitReset,
|
|
458
|
-
): Promise<T> {
|
|
459
|
-
for (;;) {
|
|
460
|
-
try {
|
|
461
|
-
return await withSpinner(label, fn)
|
|
462
|
-
} catch (error) {
|
|
463
|
-
const message = extractErrorMessage(error)
|
|
464
|
-
if (!isGhRateLimitError(message)) {
|
|
465
|
-
throw error
|
|
466
|
-
}
|
|
467
|
-
const reset = await queryReset()
|
|
468
|
-
if (!interactive) {
|
|
469
|
-
throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
|
|
470
|
-
}
|
|
471
|
-
log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
|
|
472
|
-
const retry = await promptValue(
|
|
473
|
-
confirm({
|
|
474
|
-
message: 'Retry this step when ready?',
|
|
475
|
-
active: 'retry',
|
|
476
|
-
inactive: 'abort',
|
|
477
|
-
initialValue: true,
|
|
478
|
-
}),
|
|
479
|
-
'Setup aborted after rate limit.',
|
|
480
|
-
)
|
|
481
|
-
if (!retry) {
|
|
482
|
-
cancelAndExit('Setup aborted after GitHub API rate limit.')
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
async function assertGhInstalled(): Promise<void> {
|
|
489
|
-
if (!Bun.which('gh')) {
|
|
490
|
-
throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
async function assertGhAuthenticated(): Promise<void> {
|
|
495
|
-
const result = await runGh(['auth', 'status'])
|
|
496
|
-
if (result.exitCode !== 0) {
|
|
497
|
-
throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim())
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
async function assertRepoAccess(repo: string): Promise<void> {
|
|
502
|
-
const result = await runGh(['repo', 'view', repo, '--json', 'nameWithOwner,viewerPermission'])
|
|
503
|
-
if (result.exitCode !== 0) {
|
|
504
|
-
throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim())
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout))
|
|
508
|
-
const writePermissions = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
|
|
509
|
-
|
|
510
|
-
if (!writePermissions.has(parsed.viewerPermission)) {
|
|
511
|
-
throw new Error(
|
|
512
|
-
`GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`,
|
|
513
|
-
)
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): Promise<string[]> {
|
|
518
|
-
const result = await runGh([kind, 'list', '--repo', repo, '--json', 'name'])
|
|
519
|
-
if (result.exitCode !== 0) {
|
|
520
|
-
throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim())
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
export type FroBotWorkflowCheck =
|
|
527
|
-
| {kind: 'missing'}
|
|
528
|
-
| {kind: 'unreachable'; reason: string}
|
|
529
|
-
| {kind: 'no-agent-step'}
|
|
530
|
-
| {
|
|
531
|
-
kind: 'analyzed'
|
|
532
|
-
stepsWithGaps: readonly {stepOrdinal: number; missingInputs: readonly string[]}[]
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// github-token and prompt are intentionally excluded from this check:
|
|
536
|
-
// github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
|
|
537
|
-
// workflow-defined (the user's prompt body, not a harness default).
|
|
538
|
-
//
|
|
539
|
-
// NOTE: `enable-omo: true` is NOT a required input.
|
|
540
|
-
// For proxy-routed providers configured via OPENCODE_CONFIG.provider.<name>.options.baseURL,
|
|
541
|
-
// the fro-bot/agent action honors auth.json directly (regardless of oMo state).
|
|
542
|
-
// Source: fro-bot/agent@v0.44.3+ action.yaml lines 99-104; verified by librarian 2026-05-25.
|
|
543
|
-
const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Slice the workflow content into one entry per `fro-bot/agent` step. Handles
|
|
547
|
-
* both the `- name:\n uses: ...` and `- uses: ...` step shapes. Returns an
|
|
548
|
-
* empty array if no fro-bot/agent step is present.
|
|
549
|
-
*
|
|
550
|
-
* Step-scoped slicing prevents false-passes where a same-named input key in a
|
|
551
|
-
* sibling step (strategy.matrix, custom actions, reusable workflow with:
|
|
552
|
-
* blocks) could mask a genuine gap in fro-bot/agent's wiring.
|
|
553
|
-
*/
|
|
554
|
-
function findFroBotAgentStepBodies(content: string): {stepOrdinal: number; body: string}[] {
|
|
555
|
-
const bodies: {stepOrdinal: number; body: string}[] = []
|
|
556
|
-
const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm
|
|
557
|
-
|
|
558
|
-
for (const match of content.matchAll(pattern)) {
|
|
559
|
-
if (match.index === undefined || match[1] === undefined) continue
|
|
560
|
-
|
|
561
|
-
const stepBodyIndent = match[1].length
|
|
562
|
-
const dashIndent = Math.max(0, stepBodyIndent - 2)
|
|
563
|
-
const lines = content.slice(match.index).split('\n')
|
|
564
|
-
const stepLines: string[] = [lines[0] ?? '']
|
|
565
|
-
|
|
566
|
-
for (let index = 1; index < lines.length; index += 1) {
|
|
567
|
-
const line = lines[index] ?? ''
|
|
568
|
-
if (!line.trim()) {
|
|
569
|
-
stepLines.push(line)
|
|
570
|
-
continue
|
|
571
|
-
}
|
|
572
|
-
const firstNonSpace = line.search(/\S/)
|
|
573
|
-
if (firstNonSpace === dashIndent && line.trimStart().startsWith('-')) break
|
|
574
|
-
if (firstNonSpace < dashIndent) break
|
|
575
|
-
stepLines.push(line)
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
bodies.push({stepOrdinal: bodies.length + 1, body: stepLines.join('\n')})
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
return bodies
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
export function analyzeFroBotWorkflow(workflowContent: string): FroBotWorkflowCheck {
|
|
585
|
-
const steps = findFroBotAgentStepBodies(workflowContent)
|
|
586
|
-
|
|
587
|
-
if (steps.length === 0) {
|
|
588
|
-
return {kind: 'no-agent-step'}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const stepsWithGaps = steps
|
|
592
|
-
.map(step => ({
|
|
593
|
-
stepOrdinal: step.stepOrdinal,
|
|
594
|
-
missingInputs: REQUIRED_OPENCODE_INPUTS.filter(input => {
|
|
595
|
-
const inputPattern = new RegExp(String.raw`^\s+${input}:`, 'm')
|
|
596
|
-
return !inputPattern.test(step.body)
|
|
597
|
-
}),
|
|
598
|
-
}))
|
|
599
|
-
.filter(step => step.missingInputs.length > 0)
|
|
600
|
-
|
|
601
|
-
return {kind: 'analyzed', stepsWithGaps}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* Extracted pure helper: turn a `gh api /repos/.../contents/<file>` result into
|
|
606
|
-
* a FroBotWorkflowCheck. Separated from checkFroBotWorkflow so tests can exercise
|
|
607
|
-
* the 404-vs-transport-error logic without mocking Bun.spawn.
|
|
608
|
-
*/
|
|
609
|
-
export function interpretGhContentResult(result: CommandResult): FroBotWorkflowCheck {
|
|
610
|
-
if (result.exitCode === 0) {
|
|
611
|
-
return analyzeFroBotWorkflow(result.stdout)
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// gh prints `gh: Not Found (HTTP 404)` on 404; anything else is auth/network/5xx.
|
|
615
|
-
if (/HTTP 404/.test(result.stderr)) {
|
|
616
|
-
return {kind: 'missing'}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
return {
|
|
620
|
-
kind: 'unreachable',
|
|
621
|
-
reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`,
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
async function checkFroBotWorkflow(repo: string): Promise<FroBotWorkflowCheck> {
|
|
626
|
-
const result = await runGh([
|
|
627
|
-
'api',
|
|
628
|
-
'--header',
|
|
629
|
-
'Accept: application/vnd.github.raw',
|
|
630
|
-
`/repos/${repo}/contents/.github/workflows/fro-bot.yaml`,
|
|
631
|
-
])
|
|
632
|
-
|
|
633
|
-
return interpretGhContentResult(result)
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Snippet uses 10-space indent to match the canonical `with:` block depth
|
|
637
|
-
// in marcusrbrown/infra/.github/workflows/fro-bot.yaml, so users can paste
|
|
638
|
-
// directly under their step's `with:` key without re-indenting.
|
|
639
|
-
export function formatWorkflowSnippet(missingInputs: readonly string[]): string {
|
|
640
|
-
/* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
|
|
641
|
-
const inputMap: Record<string, string> = {
|
|
642
|
-
'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
|
|
643
|
-
'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
|
|
644
|
-
'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
|
|
645
|
-
model: 'model: ${{ vars.FRO_BOT_MODEL }}',
|
|
646
|
-
}
|
|
647
|
-
/* eslint-enable no-template-curly-in-string */
|
|
648
|
-
return missingInputs.map(input => ` ${inputMap[input]}`).join('\n')
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/**
|
|
652
|
-
* Pre-mutation validator: probes /v1/models to assert the resolved model is
|
|
653
|
-
* available and (when providers includes openai) that at least one OpenAI model
|
|
654
|
-
* is present on the proxy.
|
|
655
|
-
*
|
|
656
|
-
* Short-circuits immediately for anthropic-only setups — no fetch is made.
|
|
657
|
-
* Never echoes the Authorization header in any error message.
|
|
658
|
-
*/
|
|
659
|
-
export async function verifyModelsAvailable(
|
|
120
|
+
async function buildInteractivePlan(
|
|
121
|
+
options: SetupOptions,
|
|
660
122
|
baseUrl: string,
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
model: string,
|
|
664
|
-
): Promise<void> {
|
|
665
|
-
// Anthropic-only: no fetch needed
|
|
666
|
-
if (providers.length === 1 && providers[0] === 'anthropic') {
|
|
667
|
-
return
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const endpoint = `${baseUrl}/v1/models`
|
|
671
|
-
const response = await fetch(endpoint, {
|
|
672
|
-
headers: {Authorization: `Bearer ${key}`},
|
|
673
|
-
signal: AbortSignal.timeout(10_000),
|
|
674
|
-
})
|
|
675
|
-
|
|
676
|
-
if (response.status === 401 || response.status === 403) {
|
|
677
|
-
throw new Error('Proxy key rejected. Verify with `cliproxy keys list` or rerun setup to create a new one.')
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
if (!response.ok) {
|
|
681
|
-
const rawBody = await response.text()
|
|
682
|
-
// Redact any Authorization headers or sk-* token-shaped strings that the server might echo
|
|
683
|
-
const redacted = rawBody
|
|
684
|
-
.replaceAll(/Bearer\s+[^\s"]+/g, 'Bearer <redacted>')
|
|
685
|
-
.replaceAll(/sk-[\w.-]{8,}/g, 'sk-<redacted>')
|
|
686
|
-
const excerpt = redacted.slice(0, 200)
|
|
687
|
-
throw new Error(`/v1/models returned HTTP ${response.status}: ${excerpt}`)
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const json = (await response.json()) as unknown
|
|
691
|
-
const data = (json as Record<string, unknown>)?.data
|
|
692
|
-
|
|
693
|
-
if (!Array.isArray(data)) {
|
|
694
|
-
throw new TypeError('Unexpected response from /v1/models: data is not an array.')
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
interface ModelEntry {
|
|
698
|
-
id: string
|
|
699
|
-
owned_by: string
|
|
700
|
-
}
|
|
701
|
-
const entries = data as ModelEntry[]
|
|
702
|
-
|
|
703
|
-
// OpenAI presence check
|
|
704
|
-
if (providers.includes('openai')) {
|
|
705
|
-
const hasOpenAi = entries.some(e => e.owned_by === 'openai')
|
|
706
|
-
if (!hasOpenAi) {
|
|
707
|
-
throw new Error('No OpenAI models on proxy — is the Codex token loaded? Try `cliproxy login codex`.')
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// Model presence check: strip provider prefix to get bare id
|
|
712
|
-
const slashIndex = model.indexOf('/')
|
|
713
|
-
const bareId = slashIndex === -1 ? model : model.slice(slashIndex + 1)
|
|
714
|
-
const providerPrefix = slashIndex >= 0 ? model.slice(0, slashIndex) : undefined
|
|
715
|
-
|
|
716
|
-
const modelPresent = entries.some(e => e.id === bareId)
|
|
717
|
-
if (!modelPresent) {
|
|
718
|
-
// List available ids for the matching provider only
|
|
719
|
-
const matchingIds = providerPrefix
|
|
720
|
-
? entries.filter(e => e.owned_by === providerPrefix).map(e => e.id)
|
|
721
|
-
: entries.map(e => e.id)
|
|
722
|
-
const available = matchingIds.length > 0 ? matchingIds.join(', ') : '(none)'
|
|
723
|
-
throw new Error(`Model "${bareId}" not found on proxy. Available ${providerPrefix ?? 'models'}: ${available}`)
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
async function assertProxyReachable(baseUrl: string): Promise<void> {
|
|
728
|
-
try {
|
|
729
|
-
const response = await fetch(baseUrl, {
|
|
730
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
731
|
-
})
|
|
732
|
-
|
|
733
|
-
if (!response.ok) {
|
|
734
|
-
throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`)
|
|
735
|
-
}
|
|
736
|
-
} catch (error) {
|
|
737
|
-
if (error instanceof Error && error.message.startsWith('Proxy check failed')) {
|
|
738
|
-
throw error
|
|
739
|
-
}
|
|
740
|
-
throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
async function assertProxyKeyWorks(baseUrl: string, keyValue: string): Promise<void> {
|
|
745
|
-
try {
|
|
746
|
-
const response = await fetch(`${baseUrl}/v1/models`, {
|
|
747
|
-
headers: {
|
|
748
|
-
authorization: `Bearer ${keyValue}`,
|
|
749
|
-
},
|
|
750
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
751
|
-
})
|
|
752
|
-
|
|
753
|
-
if (!response.ok) {
|
|
754
|
-
throw new Error(`Proxy key verification failed with HTTP ${response.status}`)
|
|
755
|
-
}
|
|
756
|
-
} catch (error) {
|
|
757
|
-
if (error instanceof Error && error.message.startsWith('Proxy key verification')) {
|
|
758
|
-
throw error
|
|
759
|
-
}
|
|
760
|
-
throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage(error)}`)
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
|
|
765
|
-
const response = await fetch(endpoint, {
|
|
766
|
-
...init,
|
|
767
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
768
|
-
})
|
|
769
|
-
|
|
770
|
-
if (!response.ok) {
|
|
771
|
-
const body = await response.text()
|
|
772
|
-
throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
try {
|
|
776
|
-
return await response.json()
|
|
777
|
-
} catch {
|
|
778
|
-
return null
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function managementHeaders(key: string): Headers {
|
|
783
|
-
const headers = new Headers()
|
|
784
|
-
headers.set('x-management-key', key)
|
|
785
|
-
headers.set('content-type', 'application/json')
|
|
786
|
-
return headers
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
function buildApiKeyValue(keyName: string): string {
|
|
790
|
-
const slug = (
|
|
791
|
-
keyName
|
|
792
|
-
.trim()
|
|
793
|
-
.toLowerCase()
|
|
794
|
-
.match(/[a-z0-9]+/g) ?? []
|
|
795
|
-
)
|
|
796
|
-
.join('-')
|
|
797
|
-
.slice(0, 24)
|
|
798
|
-
const random = crypto.randomUUID().split('-').join('')
|
|
799
|
-
return `sk-${slug || 'cliproxy'}-${random}`
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
async function createManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
|
|
803
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
804
|
-
const currentPayload = await requestJson(endpoint, {
|
|
805
|
-
method: 'GET',
|
|
806
|
-
headers: managementHeaders(managementKey),
|
|
807
|
-
})
|
|
808
|
-
const currentKeys = toStringArray(currentPayload)
|
|
809
|
-
|
|
810
|
-
if (currentKeys.includes(keyValue)) {
|
|
811
|
-
return
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
await requestJson(endpoint, {
|
|
815
|
-
method: 'PUT',
|
|
816
|
-
headers: managementHeaders(managementKey),
|
|
817
|
-
body: JSON.stringify([...currentKeys, keyValue]),
|
|
818
|
-
})
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
async function deleteManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
|
|
822
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
823
|
-
const currentPayload = await requestJson(endpoint, {
|
|
824
|
-
method: 'GET',
|
|
825
|
-
headers: managementHeaders(managementKey),
|
|
826
|
-
})
|
|
827
|
-
const currentKeys = toStringArray(currentPayload)
|
|
828
|
-
const filtered = currentKeys.filter(k => k !== keyValue)
|
|
829
|
-
|
|
830
|
-
if (filtered.length === currentKeys.length) {
|
|
831
|
-
return
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
await requestJson(endpoint, {
|
|
835
|
-
method: 'PUT',
|
|
836
|
-
headers: managementHeaders(managementKey),
|
|
837
|
-
body: JSON.stringify(filtered),
|
|
838
|
-
})
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
async function applyGhValue(kind: 'secret' | 'variable', name: string, repo: string, value: string): Promise<void> {
|
|
842
|
-
if (kind === 'secret') {
|
|
843
|
-
const child = Bun.spawn(['gh', 'secret', 'set', name, '--repo', repo], {
|
|
844
|
-
stdin: new Blob([value]).stream(),
|
|
845
|
-
stdout: 'pipe',
|
|
846
|
-
stderr: 'pipe',
|
|
847
|
-
env: process.env,
|
|
848
|
-
})
|
|
849
|
-
|
|
850
|
-
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
|
|
851
|
-
|
|
852
|
-
if (exitCode !== 0) {
|
|
853
|
-
throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim())
|
|
854
|
-
}
|
|
855
|
-
return
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
const result = await runGh([kind, 'set', name, '--repo', repo, '--body', value])
|
|
859
|
-
if (result.exitCode !== 0) {
|
|
860
|
-
throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function formatTemplateSummary(template: HarnessTemplate): string {
|
|
865
|
-
const secretLines = template.secrets.map(secret => `- secret ${secret.name}`)
|
|
866
|
-
const variableLines = template.variables.map(variable => `- variable ${variable.name}`)
|
|
867
|
-
return [...secretLines, ...variableLines].join('\n')
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function collectCollisions(
|
|
871
|
-
template: HarnessTemplate,
|
|
872
|
-
existingSecrets: string[],
|
|
873
|
-
existingVariables: string[],
|
|
874
|
-
): string[] {
|
|
875
|
-
const collisions: string[] = []
|
|
876
|
-
|
|
877
|
-
for (const secret of template.secrets) {
|
|
878
|
-
if (existingSecrets.includes(secret.name)) {
|
|
879
|
-
collisions.push(`secret ${secret.name}`)
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
for (const variable of template.variables) {
|
|
884
|
-
if (existingVariables.includes(variable.name)) {
|
|
885
|
-
collisions.push(`variable ${variable.name}`)
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
return collisions
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
async function promptGenericSecretNames(): Promise<GenericSecretNames> {
|
|
893
|
-
const apiKeySecretName = ensureSecretName(
|
|
894
|
-
await promptValue(
|
|
895
|
-
text({
|
|
896
|
-
message: 'Name for the API key secret',
|
|
897
|
-
placeholder: 'CLIPROXY_API_KEY',
|
|
898
|
-
validate: value => {
|
|
899
|
-
try {
|
|
900
|
-
ensureSecretName(value ?? '', 'API key secret name')
|
|
901
|
-
return undefined
|
|
902
|
-
} catch (error) {
|
|
903
|
-
return extractErrorMessage(error)
|
|
904
|
-
}
|
|
905
|
-
},
|
|
906
|
-
}),
|
|
907
|
-
'Setup cancelled before choosing the generic API key secret name.',
|
|
908
|
-
),
|
|
909
|
-
'API key secret name',
|
|
910
|
-
)
|
|
911
|
-
|
|
912
|
-
const baseUrlSecretName = ensureSecretName(
|
|
913
|
-
await promptValue(
|
|
914
|
-
text({
|
|
915
|
-
message: 'Name for the proxy base URL secret',
|
|
916
|
-
placeholder: 'CLIPROXY_BASE_URL',
|
|
917
|
-
validate: value => {
|
|
918
|
-
try {
|
|
919
|
-
ensureSecretName(value ?? '', 'Base URL secret name')
|
|
920
|
-
return undefined
|
|
921
|
-
} catch (error) {
|
|
922
|
-
return extractErrorMessage(error)
|
|
923
|
-
}
|
|
924
|
-
},
|
|
925
|
-
}),
|
|
926
|
-
'Setup cancelled before choosing the generic base URL secret name.',
|
|
927
|
-
),
|
|
928
|
-
'Base URL secret name',
|
|
929
|
-
)
|
|
930
|
-
|
|
931
|
-
return {apiKeySecretName, baseUrlSecretName}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Promise<SetupPlan> {
|
|
123
|
+
promptsImpl: Required<RunSetupDeps>['prompts'],
|
|
124
|
+
): Promise<SetupPlan> {
|
|
935
125
|
const createKey = !options.key
|
|
936
126
|
const keyName = createKey
|
|
937
|
-
? await promptValue(
|
|
127
|
+
? await promptsImpl.promptValue(
|
|
938
128
|
text({
|
|
939
129
|
message: 'Name this new CLIProxyAPI key',
|
|
940
130
|
placeholder: 'my-repo-ci',
|
|
@@ -946,7 +136,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
946
136
|
|
|
947
137
|
const harness =
|
|
948
138
|
options.harness ??
|
|
949
|
-
(await promptValue(
|
|
139
|
+
(await promptsImpl.promptValue(
|
|
950
140
|
select<Harness>({
|
|
951
141
|
message: 'Choose the harness to configure',
|
|
952
142
|
options: [
|
|
@@ -961,7 +151,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
961
151
|
const repo = options.repo
|
|
962
152
|
? ensureRepoFormat(options.repo)
|
|
963
153
|
: ensureRepoFormat(
|
|
964
|
-
await promptValue(
|
|
154
|
+
await promptsImpl.promptValue(
|
|
965
155
|
text({
|
|
966
156
|
message: 'Target GitHub repository',
|
|
967
157
|
placeholder: 'owner/repo',
|
|
@@ -1003,50 +193,18 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
1003
193
|
* Returns true when the provider list includes anything beyond anthropic-only.
|
|
1004
194
|
* Anthropic-only repos see no behavior change (G7 invariant).
|
|
1005
195
|
*/
|
|
1006
|
-
export function
|
|
196
|
+
export function requiresDestructiveProviderChangeConfirmation(providers: ProviderId[]): boolean {
|
|
1007
197
|
return !(providers.length === 1 && providers[0] === 'anthropic')
|
|
1008
198
|
}
|
|
1009
199
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
harness: Harness
|
|
1013
|
-
providers: ProviderId[]
|
|
1014
|
-
model: string
|
|
1015
|
-
template: HarnessTemplate
|
|
1016
|
-
}
|
|
200
|
+
// Deprecated: use requiresDestructiveProviderChangeConfirmation. Will be removed in a future major.
|
|
201
|
+
export const mustConfirmDestructive = requiresDestructiveProviderChangeConfirmation
|
|
1017
202
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const {repo, harness, providers, model, template} = opts
|
|
1024
|
-
|
|
1025
|
-
const lines: string[] = [
|
|
1026
|
-
`Dry run: cliproxy setup --harness ${harness}`,
|
|
1027
|
-
`Repository: ${repo}`,
|
|
1028
|
-
`Providers: ${providers.join(', ')}`,
|
|
1029
|
-
`Model: ${model}`,
|
|
1030
|
-
'Planned secrets:',
|
|
1031
|
-
]
|
|
1032
|
-
|
|
1033
|
-
for (const secret of template.secrets) {
|
|
1034
|
-
const size = new TextEncoder().encode(secret.value).byteLength
|
|
1035
|
-
lines.push(` - ${secret.name} (${size} bytes)`)
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
lines.push('Planned variables:')
|
|
1039
|
-
for (const variable of template.variables) {
|
|
1040
|
-
lines.push(` - ${variable.name} = ${variable.value}`)
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
lines.push('Proxy key (redacted): <proxy-key>')
|
|
1044
|
-
lines.push('No mutations will be performed.')
|
|
1045
|
-
|
|
1046
|
-
return lines.join('\n')
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: string): Promise<SetupPlan> {
|
|
203
|
+
export async function buildNonInteractivePlan(
|
|
204
|
+
options: SetupOptions,
|
|
205
|
+
baseUrl: string,
|
|
206
|
+
deps?: Pick<RunSetupDeps, 'validation'>,
|
|
207
|
+
): Promise<SetupPlan> {
|
|
1050
208
|
const harness = harnessSchema.parse(options.harness)
|
|
1051
209
|
const repo = ensureRepoFormat(options.repo ?? '')
|
|
1052
210
|
const keyValue = options.key ?? ''
|
|
@@ -1074,15 +232,18 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
|
|
|
1074
232
|
}
|
|
1075
233
|
}
|
|
1076
234
|
|
|
1077
|
-
|
|
235
|
+
const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable
|
|
1078
236
|
|
|
1079
|
-
// Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode
|
|
1080
|
-
|
|
237
|
+
// Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode.
|
|
238
|
+
// Check BEFORE verifyModelsAvailable to avoid a network call when the gate will reject anyway.
|
|
239
|
+
if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
|
|
1081
240
|
throw new Error(
|
|
1082
|
-
'
|
|
241
|
+
`Refusing destructive provider change on ${options.repo ?? ''} without --force. Selected providers ${providers.join(', ')} would overwrite existing GitHub secret values (OPENCODE_AUTH_JSON, OPENCODE_CONFIG, OMO_PROVIDERS, FRO_BOT_MODEL). Note: --force authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`,
|
|
1083
242
|
)
|
|
1084
243
|
}
|
|
1085
244
|
|
|
245
|
+
await verifyModels(baseUrl, keyValue, providers, model)
|
|
246
|
+
|
|
1086
247
|
return {
|
|
1087
248
|
repo,
|
|
1088
249
|
harness,
|
|
@@ -1092,206 +253,360 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
|
|
|
1092
253
|
}
|
|
1093
254
|
}
|
|
1094
255
|
|
|
1095
|
-
//
|
|
256
|
+
// Default real implementations for DI
|
|
257
|
+
const realGh: Required<RunSetupDeps>['gh'] = {
|
|
258
|
+
assertGhInstalled,
|
|
259
|
+
assertGhAuthenticated,
|
|
260
|
+
assertRepoAccess,
|
|
261
|
+
listExistingGhNames,
|
|
262
|
+
createManagementApiKey,
|
|
263
|
+
deleteManagementApiKey,
|
|
264
|
+
applyGhValue,
|
|
265
|
+
withGhRetry,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const realPrompts: Required<RunSetupDeps>['prompts'] = {
|
|
269
|
+
promptValue,
|
|
270
|
+
confirm,
|
|
271
|
+
intro,
|
|
272
|
+
note,
|
|
273
|
+
outro,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const realSmoke: Required<RunSetupDeps>['smoke'] = {
|
|
277
|
+
runSmokeTest,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const realValidation: Required<RunSetupDeps>['validation'] = {
|
|
281
|
+
assertProxyReachable,
|
|
282
|
+
assertProxyKeyWorks,
|
|
283
|
+
verifyModelsAvailable,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const realCtx: ActionCtx = {
|
|
287
|
+
console: {
|
|
288
|
+
log: (...args: unknown[]) => {
|
|
289
|
+
console.log(...args)
|
|
290
|
+
},
|
|
291
|
+
error: (...args: unknown[]) => {
|
|
292
|
+
console.error(...args)
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
process: {
|
|
296
|
+
stdout: {write: (chunk: string) => process.stdout.write(chunk)},
|
|
297
|
+
stderr: {write: (chunk: string) => process.stderr.write(chunk)},
|
|
298
|
+
exit: (code: number) => process.exit(code),
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Internal: test-only DI surface. Not part of the published API.
|
|
303
|
+
export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps = {}): Promise<void> {
|
|
304
|
+
const interactive = deps.interactive ?? Boolean(process.stdin.isTTY)
|
|
305
|
+
const baseUrl = deps.baseUrl ?? resolveBaseUrl()
|
|
306
|
+
// ctxInjected distinguishes MCP-mounted callers (which need ctx.console.error to surface errors
|
|
307
|
+
// through the MCP transport) from bare CLI callers (where cli.ts top-level catch already logs).
|
|
308
|
+
// Without this distinction, CLI users see the error twice.
|
|
309
|
+
const ctxInjected = deps.ctx !== undefined
|
|
310
|
+
const ctx = deps.ctx ?? realCtx
|
|
311
|
+
const gh = deps.gh ?? realGh
|
|
312
|
+
const prompts = deps.prompts ?? realPrompts
|
|
313
|
+
const smoke = deps.smoke ?? realSmoke
|
|
314
|
+
const validation = deps.validation ?? realValidation
|
|
315
|
+
const mgmtKeyResolver = deps.resolveManagementKey ?? resolveManagementKey
|
|
316
|
+
|
|
317
|
+
// --dry-run: short-circuit before validation so it works with no flags.
|
|
318
|
+
// Never blocks on stdin — safe to run anywhere.
|
|
319
|
+
if (options.dryRun) {
|
|
320
|
+
const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
|
|
321
|
+
const model = options.model ?? PROVIDER_DEFAULTS[providers[0] as ProviderId]
|
|
322
|
+
const harness = options.harness ?? 'opencode'
|
|
323
|
+
const repo = options.repo ?? '<repo not specified>'
|
|
324
|
+
const keyValue = options.key ?? 'sk-placeholder'
|
|
325
|
+
ctx.console.log(
|
|
326
|
+
formatDryRunPreview({
|
|
327
|
+
repo,
|
|
328
|
+
harness,
|
|
329
|
+
providers,
|
|
330
|
+
model,
|
|
331
|
+
template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
1096
336
|
|
|
1097
|
-
|
|
1098
|
-
| {kind: 'pass'; message: string; runUrl: string}
|
|
1099
|
-
| {kind: 'fail'; message: string; runUrl: string}
|
|
1100
|
-
| {kind: 'unverified'; message: string; runUrl?: string}
|
|
337
|
+
validateSetupOptions(options, interactive)
|
|
1101
338
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
conclusion: string | null
|
|
1106
|
-
url: string
|
|
1107
|
-
createdAt: string
|
|
1108
|
-
}
|
|
339
|
+
if (interactive) {
|
|
340
|
+
prompts.intro('CLIProxyAPI setup wizard')
|
|
341
|
+
}
|
|
1109
342
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
_testTriggerTime?: Date
|
|
1116
|
-
}
|
|
343
|
+
try {
|
|
344
|
+
await withSpinner('Checking GitHub CLI availability', async () => {
|
|
345
|
+
await gh.assertGhInstalled()
|
|
346
|
+
await gh.assertGhAuthenticated()
|
|
347
|
+
})
|
|
1117
348
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
* the concurrent run as ours. This is the best heuristic available without
|
|
1129
|
-
* a run-specific correlation ID from `gh workflow run`.
|
|
1130
|
-
*/
|
|
1131
|
-
export async function runSmokeTest(
|
|
1132
|
-
repo: string,
|
|
1133
|
-
_model: string,
|
|
1134
|
-
internals: SmokeTestInternals = {},
|
|
1135
|
-
): Promise<SmokeResult> {
|
|
1136
|
-
const BACKOFF_MS = [5_000, 15_000, 30_000, 60_000, 60_000]
|
|
1137
|
-
const delayFn = async (ms: number): Promise<void> => {
|
|
1138
|
-
if (internals._testDelayMs !== undefined) {
|
|
1139
|
-
if (internals._testDelayMs > 0) {
|
|
1140
|
-
await new Promise(resolve => setTimeout(resolve, internals._testDelayMs))
|
|
1141
|
-
}
|
|
1142
|
-
return
|
|
349
|
+
await withSpinner('Checking CLIProxyAPI reachability', async () => {
|
|
350
|
+
await validation.assertProxyReachable(baseUrl)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const plan = interactive
|
|
354
|
+
? await buildInteractivePlan(options, baseUrl, prompts)
|
|
355
|
+
: await buildNonInteractivePlan(options, baseUrl, {validation})
|
|
356
|
+
|
|
357
|
+
if (plan.createKey) {
|
|
358
|
+
mgmtKeyResolver()
|
|
1143
359
|
}
|
|
1144
|
-
await new Promise(resolve => setTimeout(resolve, ms))
|
|
1145
|
-
}
|
|
1146
360
|
|
|
1147
|
-
|
|
361
|
+
await gh.withGhRetry(
|
|
362
|
+
`Checking GitHub access for ${plan.repo}`,
|
|
363
|
+
async () => {
|
|
364
|
+
await gh.assertRepoAccess(plan.repo)
|
|
365
|
+
},
|
|
366
|
+
interactive,
|
|
367
|
+
)
|
|
1148
368
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
369
|
+
if (options.key) {
|
|
370
|
+
log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (interactive) {
|
|
374
|
+
prompts.note(
|
|
375
|
+
[
|
|
376
|
+
`Proxy: ${baseUrl}`,
|
|
377
|
+
`Repository: ${plan.repo}`,
|
|
378
|
+
`Harness: ${plan.harness}`,
|
|
379
|
+
plan.createKey ? `New key name: ${plan.keyName}` : 'Using existing key value',
|
|
380
|
+
'GitHub values to write:',
|
|
381
|
+
formatTemplateSummary(plan.template),
|
|
382
|
+
].join('\n'),
|
|
383
|
+
'Setup summary',
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
const shouldContinue = await prompts.promptValue(
|
|
387
|
+
prompts.confirm({
|
|
388
|
+
message: 'Proceed with GitHub secret and variable updates?',
|
|
389
|
+
active: 'yes',
|
|
390
|
+
inactive: 'no',
|
|
391
|
+
initialValue: true,
|
|
392
|
+
}),
|
|
393
|
+
'Setup cancelled before applying GitHub values.',
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if (!shouldContinue) {
|
|
397
|
+
cancelAndExit('No changes applied.')
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const [existingSecrets, existingVariables] = await gh.withGhRetry(
|
|
402
|
+
'Checking existing GitHub secrets and variables',
|
|
403
|
+
async () =>
|
|
404
|
+
Promise.all([gh.listExistingGhNames(plan.repo, 'secret'), gh.listExistingGhNames(plan.repo, 'variable')]),
|
|
405
|
+
interactive,
|
|
1155
406
|
)
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
407
|
+
|
|
408
|
+
// Key-reuse acknowledgment guard: bearer token must not appear in prompt text
|
|
409
|
+
if (options.key && existingSecrets.includes('OPENCODE_AUTH_JSON')) {
|
|
410
|
+
if (interactive) {
|
|
411
|
+
const proceed = await prompts.promptValue(
|
|
412
|
+
prompts.confirm({
|
|
413
|
+
message: `You supplied --key ${redactKey(options.key)}. Verify it matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. Continue?`,
|
|
414
|
+
active: 'yes',
|
|
415
|
+
inactive: 'no',
|
|
416
|
+
initialValue: false,
|
|
417
|
+
}),
|
|
418
|
+
'Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.',
|
|
419
|
+
)
|
|
420
|
+
if (!proceed) {
|
|
421
|
+
cancelAndExit('Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.')
|
|
422
|
+
}
|
|
423
|
+
} else if (!options.ackKeyReuse) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`Refusing key-reuse without explicit acknowledgment. Pass --ack-key-reuse to confirm that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. (The CLI cannot verify this because GitHub secrets are write-only.)`,
|
|
426
|
+
)
|
|
1165
427
|
}
|
|
1166
428
|
}
|
|
1167
|
-
// If baseline call fails, baselineId stays null — we'll use createdAt heuristic
|
|
1168
|
-
} catch {
|
|
1169
|
-
// Network/parse error — continue with null baseline
|
|
1170
|
-
}
|
|
1171
429
|
|
|
1172
|
-
|
|
1173
|
-
const triggerTime = internals._testTriggerTime ?? new Date()
|
|
1174
|
-
|
|
1175
|
-
const triggerChild = Bun.spawn(
|
|
1176
|
-
['gh', 'workflow', 'run', 'fro-bot.yaml', '--repo', repo, '-f', 'prompt=reply with exactly: ack'],
|
|
1177
|
-
{stdout: 'pipe', stderr: 'pipe', env: process.env},
|
|
1178
|
-
)
|
|
1179
|
-
const [, triggerStderr, triggerExit] = await Promise.all([
|
|
1180
|
-
new Response(triggerChild.stdout).text(),
|
|
1181
|
-
new Response(triggerChild.stderr).text(),
|
|
1182
|
-
triggerChild.exited,
|
|
1183
|
-
])
|
|
1184
|
-
|
|
1185
|
-
if (triggerExit !== 0) {
|
|
1186
|
-
const redacted = triggerStderr.slice(0, 200)
|
|
1187
|
-
return {kind: 'unverified', message: `gh workflow run failed: ${redacted}`}
|
|
1188
|
-
}
|
|
430
|
+
const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
|
|
1189
431
|
|
|
1190
|
-
|
|
1191
|
-
|
|
432
|
+
if (collisions.length > 0) {
|
|
433
|
+
if (!interactive && !options.force) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`Refusing to overwrite existing GitHub values in ${plan.repo}: ${collisions.join(', ')}. Pass --force to confirm. Note: --force only authorizes overwriting these GitHub secret values; it does NOT rotate the underlying CLIProxyAPI proxy bearer token (which is preserved byte-for-byte when --key is supplied).`,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
1192
438
|
|
|
1193
|
-
|
|
1194
|
-
|
|
439
|
+
if (!interactive && options.force) {
|
|
440
|
+
log.warn(`Overwriting existing GitHub values: ${collisions.join(', ')}`)
|
|
441
|
+
// proceed
|
|
442
|
+
}
|
|
1195
443
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
'
|
|
1210
|
-
|
|
1211
|
-
{stdout: 'pipe', stderr: 'pipe', env: process.env},
|
|
1212
|
-
)
|
|
1213
|
-
const [pollStdout, , pollExit] = await Promise.all([
|
|
1214
|
-
new Response(pollChild.stdout).text(),
|
|
1215
|
-
new Response(pollChild.stderr).text(),
|
|
1216
|
-
pollChild.exited,
|
|
1217
|
-
])
|
|
1218
|
-
if (pollExit === 0) {
|
|
1219
|
-
pollRuns = JSON.parse(pollStdout) as GhRunEntry[]
|
|
444
|
+
if (interactive) {
|
|
445
|
+
log.warn(`Existing GitHub values will be overwritten: ${collisions.join(', ')}`)
|
|
446
|
+
const overwrite = await prompts.promptValue(
|
|
447
|
+
prompts.confirm({
|
|
448
|
+
message: 'Overwrite the existing GitHub values?',
|
|
449
|
+
active: 'overwrite',
|
|
450
|
+
inactive: 'cancel',
|
|
451
|
+
initialValue: false,
|
|
452
|
+
}),
|
|
453
|
+
'Setup cancelled instead of overwriting existing values.',
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if (!overwrite) {
|
|
457
|
+
cancelAndExit('Existing GitHub values left unchanged.')
|
|
458
|
+
}
|
|
1220
459
|
}
|
|
1221
|
-
} catch {
|
|
1222
|
-
// Parse/network error — retry on next poll
|
|
1223
|
-
continue
|
|
1224
460
|
}
|
|
1225
461
|
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
if (baselineId !== null) {
|
|
1229
|
-
return run.databaseId > baselineId
|
|
1230
|
-
}
|
|
1231
|
-
// No baseline: use createdAt heuristic
|
|
1232
|
-
return new Date(run.createdAt) > triggerTime
|
|
1233
|
-
})
|
|
462
|
+
let keyCreatedByThisRun = false
|
|
463
|
+
const managementKey = plan.createKey ? mgmtKeyResolver() : undefined
|
|
1234
464
|
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1237
|
-
|
|
465
|
+
if (plan.createKey && managementKey) {
|
|
466
|
+
await withSpinner('Creating a new CLIProxyAPI key', async () => {
|
|
467
|
+
await gh.createManagementApiKey(baseUrl, managementKey, plan.keyValue)
|
|
468
|
+
keyCreatedByThisRun = true
|
|
469
|
+
})
|
|
1238
470
|
}
|
|
1239
471
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
472
|
+
try {
|
|
473
|
+
await gh.withGhRetry(
|
|
474
|
+
'Writing GitHub secrets and variables',
|
|
475
|
+
async spinnerInstance => {
|
|
476
|
+
for (const secret of plan.template.secrets) {
|
|
477
|
+
spinnerInstance.message(`Setting secret ${secret.name}`)
|
|
478
|
+
await gh.applyGhValue('secret', secret.name, plan.repo, secret.value)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const variable of plan.template.variables) {
|
|
482
|
+
spinnerInstance.message(`Setting variable ${variable.name}`)
|
|
483
|
+
await gh.applyGhValue('variable', variable.name, plan.repo, variable.value)
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
interactive,
|
|
487
|
+
)
|
|
1243
488
|
|
|
1244
|
-
|
|
489
|
+
await withSpinner('Verifying the new key through the proxy', async () => {
|
|
490
|
+
await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
|
|
491
|
+
})
|
|
1245
492
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
493
|
+
if (plan.harness === 'opencode') {
|
|
494
|
+
const workflow = await gh.withGhRetry(
|
|
495
|
+
`Checking ${plan.repo} fro-bot.yaml wiring`,
|
|
496
|
+
async () => {
|
|
497
|
+
return checkFroBotWorkflow(plan.repo)
|
|
498
|
+
},
|
|
499
|
+
interactive,
|
|
500
|
+
)
|
|
1250
501
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
502
|
+
switch (workflow.kind) {
|
|
503
|
+
case 'missing': {
|
|
504
|
+
log.warn(
|
|
505
|
+
`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.`,
|
|
506
|
+
)
|
|
507
|
+
break
|
|
508
|
+
}
|
|
509
|
+
case 'unreachable': {
|
|
510
|
+
log.warn(
|
|
511
|
+
`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.`,
|
|
512
|
+
)
|
|
513
|
+
break
|
|
514
|
+
}
|
|
515
|
+
case 'no-agent-step': {
|
|
516
|
+
log.warn(
|
|
517
|
+
`${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.`,
|
|
518
|
+
)
|
|
519
|
+
break
|
|
520
|
+
}
|
|
521
|
+
case 'analyzed': {
|
|
522
|
+
for (const step of workflow.stepsWithGaps) {
|
|
523
|
+
const missing = [...step.missingInputs]
|
|
524
|
+
log.warn(
|
|
525
|
+
[
|
|
526
|
+
`${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
|
|
527
|
+
missing.length > 1 ? 's' : ''
|
|
528
|
+
} (${missing.join(', ')}).`,
|
|
529
|
+
`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'}.`,
|
|
530
|
+
'',
|
|
531
|
+
`Add under the 'with:' block of the 'fro-bot/agent' step:`,
|
|
532
|
+
formatWorkflowSnippet(missing),
|
|
533
|
+
].join('\n'),
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
break
|
|
1270
537
|
}
|
|
538
|
+
default: {
|
|
539
|
+
const _exhaustive: never = workflow
|
|
540
|
+
throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
} catch (mutationError) {
|
|
545
|
+
if (keyCreatedByThisRun && managementKey) {
|
|
546
|
+
try {
|
|
547
|
+
await gh.deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
|
|
548
|
+
log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
|
|
1271
549
|
} catch {
|
|
1272
|
-
|
|
550
|
+
log.warn(
|
|
551
|
+
'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
|
|
552
|
+
)
|
|
1273
553
|
}
|
|
1274
|
-
return {kind: 'pass', message: `Smoke test passed${logNote}`, runUrl}
|
|
1275
554
|
}
|
|
1276
|
-
|
|
1277
|
-
return {kind: 'fail', message: `Run completed with conclusion=${conclusion ?? 'unknown'}`, runUrl}
|
|
555
|
+
throw mutationError
|
|
1278
556
|
}
|
|
1279
557
|
|
|
1280
|
-
|
|
1281
|
-
|
|
558
|
+
if (interactive) {
|
|
559
|
+
prompts.outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`)
|
|
560
|
+
} else {
|
|
561
|
+
log.success(`Setup complete for ${plan.repo}.`)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Smoke test (opt-in, non-blocking) ──────────────────────────────
|
|
565
|
+
if (options.verifySmoke) {
|
|
566
|
+
const smokeResult = await withSpinner('Running smoke test', async () =>
|
|
567
|
+
smoke.runSmokeTest(plan.repo, plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')?.value ?? ''),
|
|
568
|
+
).catch(async error => {
|
|
569
|
+
// withSpinner re-throws; catch here so smoke test never gates setup
|
|
570
|
+
return {
|
|
571
|
+
kind: 'unverified' as const,
|
|
572
|
+
message: `Smoke test error: ${extractErrorMessage(error)}`,
|
|
573
|
+
runUrl: undefined,
|
|
574
|
+
}
|
|
575
|
+
})
|
|
1282
576
|
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
return {
|
|
1286
|
-
kind: 'unverified',
|
|
1287
|
-
message: `Smoke test did not complete in 5 minutes; check ${latestMatchedRun.url}`,
|
|
1288
|
-
runUrl: latestMatchedRun.url,
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
577
|
+
// Machine-parseable hook for MCP/agent consumers
|
|
578
|
+
ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`)
|
|
1291
579
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
580
|
+
switch (smokeResult.kind) {
|
|
581
|
+
case 'pass': {
|
|
582
|
+
log.success(`✓ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
583
|
+
break
|
|
584
|
+
}
|
|
585
|
+
case 'fail': {
|
|
586
|
+
log.warn(`✗ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
587
|
+
break
|
|
588
|
+
}
|
|
589
|
+
case 'unverified': {
|
|
590
|
+
log.warn(`⚠ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
591
|
+
break
|
|
592
|
+
}
|
|
593
|
+
default: {
|
|
594
|
+
const _exhaustive: never = smokeResult
|
|
595
|
+
throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
} catch (error) {
|
|
600
|
+
const message = extractErrorMessage(error)
|
|
601
|
+
// MCP-mounted callers need ctx.console.error to surface the message through the MCP transport.
|
|
602
|
+
// Bare CLI callers leave it to cli.ts's top-level catch — emitting here too would double-log.
|
|
603
|
+
if (ctxInjected) {
|
|
604
|
+
ctx.console.error(message)
|
|
605
|
+
}
|
|
606
|
+
if (interactive) {
|
|
607
|
+
cancel(message)
|
|
608
|
+
}
|
|
609
|
+
throw error
|
|
1295
610
|
}
|
|
1296
611
|
}
|
|
1297
612
|
|
|
@@ -1324,7 +639,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
1324
639
|
z
|
|
1325
640
|
.string()
|
|
1326
641
|
.describe(
|
|
1327
|
-
'Comma-separated list of providers to enable. Supported values: anthropic, openai. Example: --providers anthropic,openai',
|
|
642
|
+
'Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai',
|
|
1328
643
|
),
|
|
1329
644
|
)
|
|
1330
645
|
.option(
|
|
@@ -1333,7 +648,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
1333
648
|
.string()
|
|
1334
649
|
.regex(MODEL_ID_RE)
|
|
1335
650
|
.describe(
|
|
1336
|
-
'Override the default model. Must be provider-prefixed and lowercase. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o',
|
|
651
|
+
'Override the default model. Must be provider-prefixed and lowercase. Required when multiple providers selected. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o',
|
|
1337
652
|
),
|
|
1338
653
|
)
|
|
1339
654
|
.option(
|
|
@@ -1345,275 +660,26 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
1345
660
|
'--verify-smoke',
|
|
1346
661
|
z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
|
|
1347
662
|
)
|
|
663
|
+
.option(
|
|
664
|
+
'--ack-key-reuse',
|
|
665
|
+
z
|
|
666
|
+
.boolean()
|
|
667
|
+
.default(false)
|
|
668
|
+
.describe(
|
|
669
|
+
'Acknowledge that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON. Required in non-interactive mode when --key is supplied for a repo with existing OPENCODE_AUTH_JSON.',
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
.example('# Preview planned actions without applying any changes (no flags required)')
|
|
673
|
+
.example('infra cliproxy setup --dry-run')
|
|
1348
674
|
.example('# Run the interactive onboarding wizard')
|
|
1349
675
|
.example('infra cliproxy setup')
|
|
1350
|
-
.example('# Run non-interactively with an existing key')
|
|
1351
|
-
.example('infra cliproxy setup --
|
|
1352
|
-
.example('# Enable both providers non-interactively')
|
|
1353
|
-
.example(
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
validateSetupOptions(options, interactive)
|
|
1359
|
-
|
|
1360
|
-
if (interactive) {
|
|
1361
|
-
intro('CLIProxyAPI setup wizard')
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
try {
|
|
1365
|
-
if (!options.dryRun) {
|
|
1366
|
-
await withSpinner('Checking GitHub CLI availability', async () => {
|
|
1367
|
-
await assertGhInstalled()
|
|
1368
|
-
await assertGhAuthenticated()
|
|
1369
|
-
})
|
|
1370
|
-
|
|
1371
|
-
await withSpinner('Checking CLIProxyAPI reachability', async () => {
|
|
1372
|
-
await assertProxyReachable(baseUrl)
|
|
1373
|
-
})
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
const plan = interactive
|
|
1377
|
-
? await buildInteractivePlan(options, baseUrl)
|
|
1378
|
-
: await buildNonInteractivePlan(options, baseUrl)
|
|
1379
|
-
|
|
1380
|
-
if (options.dryRun) {
|
|
1381
|
-
const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
|
|
1382
|
-
const model = options.model ?? PROVIDER_DEFAULTS[providers[0] as ProviderId]
|
|
1383
|
-
console.log(
|
|
1384
|
-
formatDryRunPreview({
|
|
1385
|
-
repo: plan.repo,
|
|
1386
|
-
harness: plan.harness,
|
|
1387
|
-
providers,
|
|
1388
|
-
model,
|
|
1389
|
-
template: plan.template,
|
|
1390
|
-
}),
|
|
1391
|
-
)
|
|
1392
|
-
return
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
if (plan.createKey) {
|
|
1396
|
-
resolveManagementKey()
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
await withGhRetry(
|
|
1400
|
-
`Checking GitHub access for ${plan.repo}`,
|
|
1401
|
-
async () => {
|
|
1402
|
-
await assertRepoAccess(plan.repo)
|
|
1403
|
-
},
|
|
1404
|
-
interactive,
|
|
1405
|
-
)
|
|
1406
|
-
|
|
1407
|
-
if (options.key) {
|
|
1408
|
-
log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
if (interactive) {
|
|
1412
|
-
note(
|
|
1413
|
-
[
|
|
1414
|
-
`Proxy: ${baseUrl}`,
|
|
1415
|
-
`Repository: ${plan.repo}`,
|
|
1416
|
-
`Harness: ${plan.harness}`,
|
|
1417
|
-
plan.createKey ? `New key name: ${plan.keyName}` : 'Using existing key value',
|
|
1418
|
-
'GitHub values to write:',
|
|
1419
|
-
formatTemplateSummary(plan.template),
|
|
1420
|
-
].join('\n'),
|
|
1421
|
-
'Setup summary',
|
|
1422
|
-
)
|
|
1423
|
-
|
|
1424
|
-
const shouldContinue = await promptValue(
|
|
1425
|
-
confirm({
|
|
1426
|
-
message: 'Proceed with GitHub secret and variable updates?',
|
|
1427
|
-
active: 'yes',
|
|
1428
|
-
inactive: 'no',
|
|
1429
|
-
initialValue: true,
|
|
1430
|
-
}),
|
|
1431
|
-
'Setup cancelled before applying GitHub values.',
|
|
1432
|
-
)
|
|
1433
|
-
|
|
1434
|
-
if (!shouldContinue) {
|
|
1435
|
-
cancelAndExit('No changes applied.')
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
const [existingSecrets, existingVariables] = await withGhRetry(
|
|
1440
|
-
'Checking existing GitHub secrets and variables',
|
|
1441
|
-
async () =>
|
|
1442
|
-
Promise.all([listExistingGhNames(plan.repo, 'secret'), listExistingGhNames(plan.repo, 'variable')]),
|
|
1443
|
-
interactive,
|
|
1444
|
-
)
|
|
1445
|
-
const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
|
|
1446
|
-
|
|
1447
|
-
if (collisions.length > 0) {
|
|
1448
|
-
if (!interactive && !options.force) {
|
|
1449
|
-
throw new Error(
|
|
1450
|
-
`Refusing to overwrite existing GitHub values in non-interactive mode: ${collisions.join(', ')}. Pass --force to confirm.`,
|
|
1451
|
-
)
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
if (!interactive && options.force) {
|
|
1455
|
-
log.warn(`Overwriting existing GitHub values: ${collisions.join(', ')}`)
|
|
1456
|
-
// proceed
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
if (interactive) {
|
|
1460
|
-
log.warn(`Existing GitHub values will be overwritten: ${collisions.join(', ')}`)
|
|
1461
|
-
const overwrite = await promptValue(
|
|
1462
|
-
confirm({
|
|
1463
|
-
message: 'Overwrite the existing GitHub values?',
|
|
1464
|
-
active: 'overwrite',
|
|
1465
|
-
inactive: 'cancel',
|
|
1466
|
-
initialValue: false,
|
|
1467
|
-
}),
|
|
1468
|
-
'Setup cancelled instead of overwriting existing values.',
|
|
1469
|
-
)
|
|
1470
|
-
|
|
1471
|
-
if (!overwrite) {
|
|
1472
|
-
cancelAndExit('Existing GitHub values left unchanged.')
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
let keyCreatedByThisRun = false
|
|
1478
|
-
const managementKey = plan.createKey ? resolveManagementKey() : undefined
|
|
1479
|
-
|
|
1480
|
-
if (plan.createKey && managementKey) {
|
|
1481
|
-
await withSpinner('Creating a new CLIProxyAPI key', async () => {
|
|
1482
|
-
await createManagementApiKey(baseUrl, managementKey, plan.keyValue)
|
|
1483
|
-
keyCreatedByThisRun = true
|
|
1484
|
-
})
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
try {
|
|
1488
|
-
await withGhRetry(
|
|
1489
|
-
'Writing GitHub secrets and variables',
|
|
1490
|
-
async spinnerInstance => {
|
|
1491
|
-
for (const secret of plan.template.secrets) {
|
|
1492
|
-
spinnerInstance.message(`Setting secret ${secret.name}`)
|
|
1493
|
-
await applyGhValue('secret', secret.name, plan.repo, secret.value)
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
for (const variable of plan.template.variables) {
|
|
1497
|
-
spinnerInstance.message(`Setting variable ${variable.name}`)
|
|
1498
|
-
await applyGhValue('variable', variable.name, plan.repo, variable.value)
|
|
1499
|
-
}
|
|
1500
|
-
},
|
|
1501
|
-
interactive,
|
|
1502
|
-
)
|
|
1503
|
-
|
|
1504
|
-
await withSpinner('Verifying the new key through the proxy', async () => {
|
|
1505
|
-
await assertProxyKeyWorks(baseUrl, plan.keyValue)
|
|
1506
|
-
})
|
|
1507
|
-
|
|
1508
|
-
if (plan.harness === 'opencode') {
|
|
1509
|
-
const workflow = await withGhRetry(
|
|
1510
|
-
`Checking ${plan.repo} fro-bot.yaml wiring`,
|
|
1511
|
-
async () => {
|
|
1512
|
-
return checkFroBotWorkflow(plan.repo)
|
|
1513
|
-
},
|
|
1514
|
-
interactive,
|
|
1515
|
-
)
|
|
1516
|
-
|
|
1517
|
-
switch (workflow.kind) {
|
|
1518
|
-
case 'missing': {
|
|
1519
|
-
log.warn(
|
|
1520
|
-
`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.`,
|
|
1521
|
-
)
|
|
1522
|
-
break
|
|
1523
|
-
}
|
|
1524
|
-
case 'unreachable': {
|
|
1525
|
-
log.warn(
|
|
1526
|
-
`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.`,
|
|
1527
|
-
)
|
|
1528
|
-
break
|
|
1529
|
-
}
|
|
1530
|
-
case 'no-agent-step': {
|
|
1531
|
-
log.warn(
|
|
1532
|
-
`${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.`,
|
|
1533
|
-
)
|
|
1534
|
-
break
|
|
1535
|
-
}
|
|
1536
|
-
case 'analyzed': {
|
|
1537
|
-
for (const step of workflow.stepsWithGaps) {
|
|
1538
|
-
const missing = [...step.missingInputs]
|
|
1539
|
-
log.warn(
|
|
1540
|
-
[
|
|
1541
|
-
`${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
|
|
1542
|
-
missing.length > 1 ? 's' : ''
|
|
1543
|
-
} (${missing.join(', ')}).`,
|
|
1544
|
-
`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'}.`,
|
|
1545
|
-
'',
|
|
1546
|
-
`Add under the 'with:' block of the 'fro-bot/agent' step:`,
|
|
1547
|
-
formatWorkflowSnippet(missing),
|
|
1548
|
-
].join('\n'),
|
|
1549
|
-
)
|
|
1550
|
-
}
|
|
1551
|
-
break
|
|
1552
|
-
}
|
|
1553
|
-
default: {
|
|
1554
|
-
const _exhaustive: never = workflow
|
|
1555
|
-
throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
}
|
|
1559
|
-
} catch (mutationError) {
|
|
1560
|
-
if (keyCreatedByThisRun && managementKey) {
|
|
1561
|
-
try {
|
|
1562
|
-
await deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
|
|
1563
|
-
log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
|
|
1564
|
-
} catch {
|
|
1565
|
-
log.warn(
|
|
1566
|
-
'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
|
|
1567
|
-
)
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
throw mutationError
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
if (interactive) {
|
|
1574
|
-
outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`)
|
|
1575
|
-
} else {
|
|
1576
|
-
log.success(`Setup complete for ${plan.repo}.`)
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// ── Smoke test (opt-in, non-blocking) ──────────────────────────────
|
|
1580
|
-
if (options.verifySmoke) {
|
|
1581
|
-
const smokeResult = await withSpinner('Running smoke test', async () =>
|
|
1582
|
-
runSmokeTest(plan.repo, plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')?.value ?? ''),
|
|
1583
|
-
).catch(async error => {
|
|
1584
|
-
// withSpinner re-throws; catch here so smoke test never gates setup
|
|
1585
|
-
return {
|
|
1586
|
-
kind: 'unverified' as const,
|
|
1587
|
-
message: `Smoke test error: ${extractErrorMessage(error)}`,
|
|
1588
|
-
runUrl: undefined,
|
|
1589
|
-
}
|
|
1590
|
-
})
|
|
1591
|
-
|
|
1592
|
-
switch (smokeResult.kind) {
|
|
1593
|
-
case 'pass': {
|
|
1594
|
-
log.success(`✓ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
1595
|
-
break
|
|
1596
|
-
}
|
|
1597
|
-
case 'fail': {
|
|
1598
|
-
log.warn(`✗ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
1599
|
-
break
|
|
1600
|
-
}
|
|
1601
|
-
case 'unverified': {
|
|
1602
|
-
log.warn(`⚠ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
1603
|
-
break
|
|
1604
|
-
}
|
|
1605
|
-
default: {
|
|
1606
|
-
const _exhaustive: never = smokeResult
|
|
1607
|
-
throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`)
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
} catch (error) {
|
|
1612
|
-
const message = extractErrorMessage(error)
|
|
1613
|
-
if (interactive) {
|
|
1614
|
-
cancel(message)
|
|
1615
|
-
}
|
|
1616
|
-
throw error
|
|
1617
|
-
}
|
|
676
|
+
.example('# Run non-interactively with an existing key (anthropic-only)')
|
|
677
|
+
.example('infra cliproxy setup --repo owner/repo --harness opencode --key sk-existing --force')
|
|
678
|
+
.example('# Enable both providers non-interactively (requires --force and --model)')
|
|
679
|
+
.example(
|
|
680
|
+
'infra cliproxy setup --repo owner/repo --harness opencode --providers anthropic,openai --model openai/gpt-5.4-mini --key sk-existing --ack-key-reuse --force',
|
|
681
|
+
)
|
|
682
|
+
.action(async (options, ctx) => {
|
|
683
|
+
await runSetupCommand(options, {ctx})
|
|
1618
684
|
})
|
|
1619
685
|
}
|