@marcusrbrown/infra 0.7.0 → 0.8.1
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 +245 -0
- package/src/commands/cliproxy/setup/providers.ts +136 -0
- package/src/commands/cliproxy/setup/smoke-test.test.ts +821 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +223 -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 +1948 -1944
- package/src/commands/cliproxy/setup.ts +543 -1353
- 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,173 @@ interface SetupPlan {
|
|
|
192
68
|
template: HarnessTemplate
|
|
193
69
|
}
|
|
194
70
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
86
|
+
}
|
|
87
|
+
prompts?: {
|
|
88
|
+
promptValue: typeof promptValue
|
|
89
|
+
confirm: typeof confirm
|
|
90
|
+
intro: typeof intro
|
|
91
|
+
note: typeof note
|
|
92
|
+
outro: typeof outro
|
|
93
|
+
promptForProviders?: typeof promptForProviders
|
|
94
|
+
promptForModel?: typeof promptForModel
|
|
95
|
+
}
|
|
96
|
+
smoke?: {
|
|
97
|
+
runSmokeTest: typeof runSmokeTest
|
|
98
|
+
}
|
|
99
|
+
validation?: {
|
|
100
|
+
assertProxyReachable: typeof assertProxyReachable
|
|
101
|
+
assertProxyKeyWorks: typeof assertProxyKeyWorks
|
|
102
|
+
verifyModelsAvailable: typeof verifyModelsAvailable
|
|
103
|
+
}
|
|
203
104
|
}
|
|
204
105
|
|
|
205
106
|
function resolveBaseUrl(input?: string): string {
|
|
206
107
|
return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
207
108
|
}
|
|
208
109
|
|
|
209
|
-
export function validateSetupOptions(options: SetupOptions, isInteractive: boolean): void {
|
|
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.')
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
if (!options.repo) {
|
|
238
|
-
throw new Error('--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.')
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (!options.harness) {
|
|
242
|
-
throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (options.harness === 'generic') {
|
|
246
|
-
throw new Error('--harness generic is interactive-only because it requires custom secret names.')
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export function getHarnessTemplate(
|
|
251
|
-
harness: Harness,
|
|
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
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function extractErrorMessage(error: unknown): string {
|
|
350
|
-
return error instanceof Error ? error.message : String(error)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function ensureRepoFormat(value: string): string {
|
|
354
|
-
const trimmed = value.trim()
|
|
355
|
-
if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
356
|
-
throw new Error('Repository must be in owner/repo format.')
|
|
357
|
-
}
|
|
358
|
-
return trimmed
|
|
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
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
async function runCommand(command: string, args: string[]): Promise<CommandResult> {
|
|
400
|
-
const child = Bun.spawn([command, ...args], {
|
|
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
110
|
/**
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* or a fallback phrase when the endpoint is unreachable.
|
|
111
|
+
* Emit a warning without ever throwing. Used inside verifyWrittenNamesVisible so that a
|
|
112
|
+
* failure in warning emission itself cannot escape to the outer write catch and wrongly
|
|
113
|
+
* roll back a key whose secrets are already written.
|
|
428
114
|
*/
|
|
429
|
-
|
|
115
|
+
function safeWarn(message: string): void {
|
|
430
116
|
try {
|
|
431
|
-
|
|
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
|
-
}
|
|
117
|
+
log.warn(message)
|
|
441
118
|
} catch {
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
):
|
|
459
|
-
|
|
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)
|
|
119
|
+
// Warning emission must never escape the post-write readback — a throw here would
|
|
120
|
+
// reach the outer write catch and wrongly roll back a key whose secrets are written.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildCannotVerifyMessage(repo: string, writtenSecretNames: string[], writtenVariableNames: string[]): string {
|
|
125
|
+
const secretPart =
|
|
126
|
+
writtenSecretNames.length > 0
|
|
127
|
+
? `gh secret list --repo ${repo} (expect: ${writtenSecretNames.join(', ')})`
|
|
128
|
+
: `gh secret list --repo ${repo}`
|
|
129
|
+
const variablePart =
|
|
130
|
+
writtenVariableNames.length > 0
|
|
131
|
+
? `gh variable list --repo ${repo} (expect: ${writtenVariableNames.join(', ')})`
|
|
132
|
+
: `gh variable list --repo ${repo}`
|
|
133
|
+
return (
|
|
134
|
+
`Post-write readback: could not verify the written names are visible in ${repo} ` +
|
|
135
|
+
`(the GitHub list call failed). Verify manually: ${secretPart}; ${variablePart}.`
|
|
136
|
+
)
|
|
524
137
|
}
|
|
525
138
|
|
|
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
139
|
/**
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
140
|
+
* After a successful write, re-list secret and variable names and warn if any written name
|
|
141
|
+
* is absent on readback — signaling an unreliable token list view that may have bypassed
|
|
142
|
+
* the pre-write safety gates.
|
|
549
143
|
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
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.
|
|
144
|
+
* Distinguishes:
|
|
145
|
+
* - Verified mismatch: readback succeeded but a written name is absent (strong signal).
|
|
146
|
+
* - Cannot verify: the readback gh call itself failed (weaker signal).
|
|
655
147
|
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
148
|
+
* NEVER throws. The entire body is wrapped in a single try/catch so any failure — including
|
|
149
|
+
* errors during diff computation or warning emission — degrades to the cannot-verify warning.
|
|
150
|
+
* A throw here would propagate to the mutationError rollback and wrongly delete a key whose
|
|
151
|
+
* secrets are already written.
|
|
658
152
|
*/
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
153
|
+
async function verifyWrittenNamesVisible(
|
|
154
|
+
repo: string,
|
|
155
|
+
writtenSecretNames: string[],
|
|
156
|
+
writtenVariableNames: string[],
|
|
157
|
+
listExistingGhNames: typeof import('./setup/gh').listExistingGhNames,
|
|
664
158
|
): 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
159
|
try {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
160
|
+
let secretReadback: string[]
|
|
161
|
+
let variableReadback: string[]
|
|
162
|
+
let readbackFailed = false
|
|
732
163
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
throw error
|
|
164
|
+
try {
|
|
165
|
+
secretReadback = await listExistingGhNames(repo, 'secret')
|
|
166
|
+
} catch {
|
|
167
|
+
readbackFailed = true
|
|
168
|
+
secretReadback = []
|
|
739
169
|
}
|
|
740
|
-
throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
170
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}
|
|
750
|
-
|
|
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
|
|
171
|
+
if (readbackFailed) {
|
|
172
|
+
variableReadback = []
|
|
173
|
+
} else {
|
|
174
|
+
try {
|
|
175
|
+
variableReadback = await listExistingGhNames(repo, 'variable')
|
|
176
|
+
} catch {
|
|
177
|
+
readbackFailed = true
|
|
178
|
+
variableReadback = []
|
|
179
|
+
}
|
|
759
180
|
}
|
|
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
181
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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())
|
|
182
|
+
if (readbackFailed) {
|
|
183
|
+
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
|
|
184
|
+
return
|
|
854
185
|
}
|
|
855
|
-
return
|
|
856
|
-
}
|
|
857
186
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
|
|
861
|
-
}
|
|
862
|
-
}
|
|
187
|
+
const absentSecrets = writtenSecretNames.filter(name => !secretReadback.includes(name))
|
|
188
|
+
const absentVariables = writtenVariableNames.filter(name => !variableReadback.includes(name))
|
|
863
189
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
}
|
|
190
|
+
if (absentSecrets.length === 0 && absentVariables.length === 0) {
|
|
191
|
+
// Happy path: all written names are visible. Emit nothing.
|
|
192
|
+
return
|
|
193
|
+
}
|
|
869
194
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
): string[] {
|
|
875
|
-
const collisions: string[] = []
|
|
195
|
+
const lines: string[] = [
|
|
196
|
+
`Post-write readback: the following written names are not visible in ${repo} — ` +
|
|
197
|
+
`the token's list view may be unreliable and the pre-write safety gates may have been bypassed.`,
|
|
198
|
+
]
|
|
876
199
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
200
|
+
if (absentSecrets.length > 0) {
|
|
201
|
+
lines.push(` Absent secrets: ${absentSecrets.join(', ')}`)
|
|
202
|
+
lines.push(` Verify manually: gh secret list --repo ${repo}`)
|
|
880
203
|
}
|
|
881
|
-
}
|
|
882
204
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
205
|
+
if (absentVariables.length > 0) {
|
|
206
|
+
lines.push(` Absent variables: ${absentVariables.join(', ')}`)
|
|
207
|
+
lines.push(` Verify manually: gh variable list --repo ${repo}`)
|
|
886
208
|
}
|
|
887
|
-
}
|
|
888
209
|
|
|
889
|
-
|
|
210
|
+
safeWarn(lines.join('\n'))
|
|
211
|
+
} catch {
|
|
212
|
+
// Any failure in the entire verification block (readback, diff, or warning emission)
|
|
213
|
+
// degrades to this softer cannot-verify warning. Never re-throw.
|
|
214
|
+
safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
|
|
215
|
+
}
|
|
890
216
|
}
|
|
891
217
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
)
|
|
218
|
+
function extractErrorMessage(error: unknown): string {
|
|
219
|
+
return error instanceof Error ? error.message : String(error)
|
|
220
|
+
}
|
|
930
221
|
|
|
931
|
-
|
|
222
|
+
// Redact a bearer token for display in interactive prompts — never show raw key values.
|
|
223
|
+
// Exported for direct unit testing of the redaction contract. The redacted form is
|
|
224
|
+
// what gets shown in the interactive key-reuse prompt; the raw key must never reach the prompt UI.
|
|
225
|
+
export function redactKey(key: string): string {
|
|
226
|
+
if (key.length < 12) return 'sk-***'
|
|
227
|
+
return `${key.slice(0, 3)}***${key.slice(-4)}`
|
|
932
228
|
}
|
|
933
229
|
|
|
934
|
-
async function buildInteractivePlan(
|
|
230
|
+
async function buildInteractivePlan(
|
|
231
|
+
options: SetupOptions,
|
|
232
|
+
baseUrl: string,
|
|
233
|
+
promptsImpl: Required<RunSetupDeps>['prompts'],
|
|
234
|
+
): Promise<SetupPlan> {
|
|
935
235
|
const createKey = !options.key
|
|
936
236
|
const keyName = createKey
|
|
937
|
-
? await promptValue(
|
|
237
|
+
? await promptsImpl.promptValue(
|
|
938
238
|
text({
|
|
939
239
|
message: 'Name this new CLIProxyAPI key',
|
|
940
240
|
placeholder: 'my-repo-ci',
|
|
@@ -946,7 +246,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
946
246
|
|
|
947
247
|
const harness =
|
|
948
248
|
options.harness ??
|
|
949
|
-
(await promptValue(
|
|
249
|
+
(await promptsImpl.promptValue(
|
|
950
250
|
select<Harness>({
|
|
951
251
|
message: 'Choose the harness to configure',
|
|
952
252
|
options: [
|
|
@@ -961,7 +261,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
961
261
|
const repo = options.repo
|
|
962
262
|
? ensureRepoFormat(options.repo)
|
|
963
263
|
: ensureRepoFormat(
|
|
964
|
-
await promptValue(
|
|
264
|
+
await promptsImpl.promptValue(
|
|
965
265
|
text({
|
|
966
266
|
message: 'Target GitHub repository',
|
|
967
267
|
placeholder: 'owner/repo',
|
|
@@ -982,8 +282,10 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
982
282
|
let model: string | undefined
|
|
983
283
|
|
|
984
284
|
if (harness === 'opencode') {
|
|
985
|
-
|
|
986
|
-
|
|
285
|
+
const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders
|
|
286
|
+
const doPromptForModel = promptsImpl.promptForModel ?? promptForModel
|
|
287
|
+
providers = await doPromptForProviders()
|
|
288
|
+
model = await doPromptForModel(providers)
|
|
987
289
|
}
|
|
988
290
|
|
|
989
291
|
const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
|
|
@@ -1003,50 +305,18 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
1003
305
|
* Returns true when the provider list includes anything beyond anthropic-only.
|
|
1004
306
|
* Anthropic-only repos see no behavior change (G7 invariant).
|
|
1005
307
|
*/
|
|
1006
|
-
export function
|
|
308
|
+
export function requiresDestructiveProviderChangeConfirmation(providers: ProviderId[]): boolean {
|
|
1007
309
|
return !(providers.length === 1 && providers[0] === 'anthropic')
|
|
1008
310
|
}
|
|
1009
311
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
harness: Harness
|
|
1013
|
-
providers: ProviderId[]
|
|
1014
|
-
model: string
|
|
1015
|
-
template: HarnessTemplate
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
/**
|
|
1019
|
-
* Format a dry-run preview string. The proxy key value is NEVER included —
|
|
1020
|
-
* it is rendered as `<proxy-key>` in all positions.
|
|
1021
|
-
*/
|
|
1022
|
-
export function formatDryRunPreview(opts: DryRunPreviewOptions): string {
|
|
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
|
-
}
|
|
312
|
+
// Deprecated: use requiresDestructiveProviderChangeConfirmation. Will be removed in a future major.
|
|
313
|
+
export const mustConfirmDestructive = requiresDestructiveProviderChangeConfirmation
|
|
1037
314
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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> {
|
|
315
|
+
export async function buildNonInteractivePlan(
|
|
316
|
+
options: SetupOptions,
|
|
317
|
+
baseUrl: string,
|
|
318
|
+
deps?: Pick<RunSetupDeps, 'validation'>,
|
|
319
|
+
): Promise<SetupPlan> {
|
|
1050
320
|
const harness = harnessSchema.parse(options.harness)
|
|
1051
321
|
const repo = ensureRepoFormat(options.repo ?? '')
|
|
1052
322
|
const keyValue = options.key ?? ''
|
|
@@ -1074,15 +344,18 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
|
|
|
1074
344
|
}
|
|
1075
345
|
}
|
|
1076
346
|
|
|
1077
|
-
|
|
347
|
+
const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable
|
|
1078
348
|
|
|
1079
|
-
// Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode
|
|
1080
|
-
|
|
349
|
+
// Destructive overwrite gate: non-anthropic-only requires --force in non-interactive mode.
|
|
350
|
+
// Check BEFORE verifyModelsAvailable to avoid a network call when the gate will reject anyway.
|
|
351
|
+
if (requiresDestructiveProviderChangeConfirmation(providers) && !options.force) {
|
|
1081
352
|
throw new Error(
|
|
1082
|
-
'
|
|
353
|
+
`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
354
|
)
|
|
1084
355
|
}
|
|
1085
356
|
|
|
357
|
+
await verifyModels(baseUrl, keyValue, providers, model)
|
|
358
|
+
|
|
1086
359
|
return {
|
|
1087
360
|
repo,
|
|
1088
361
|
harness,
|
|
@@ -1092,206 +365,372 @@ export async function buildNonInteractivePlan(options: SetupOptions, baseUrl: st
|
|
|
1092
365
|
}
|
|
1093
366
|
}
|
|
1094
367
|
|
|
1095
|
-
//
|
|
368
|
+
// Default real implementations for DI
|
|
369
|
+
const realGh: Required<RunSetupDeps>['gh'] = {
|
|
370
|
+
assertGhInstalled,
|
|
371
|
+
assertGhAuthenticated,
|
|
372
|
+
assertRepoAccess,
|
|
373
|
+
listExistingGhNames,
|
|
374
|
+
createManagementApiKey,
|
|
375
|
+
deleteManagementApiKey,
|
|
376
|
+
applyGhValue,
|
|
377
|
+
withGhRetry,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const realPrompts: Required<RunSetupDeps>['prompts'] = {
|
|
381
|
+
promptValue,
|
|
382
|
+
confirm,
|
|
383
|
+
intro,
|
|
384
|
+
note,
|
|
385
|
+
outro,
|
|
386
|
+
promptForProviders,
|
|
387
|
+
promptForModel,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const realSmoke: Required<RunSetupDeps>['smoke'] = {
|
|
391
|
+
runSmokeTest,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const realValidation: Required<RunSetupDeps>['validation'] = {
|
|
395
|
+
assertProxyReachable,
|
|
396
|
+
assertProxyKeyWorks,
|
|
397
|
+
verifyModelsAvailable,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const realCtx: ActionCtx = {
|
|
401
|
+
console: {
|
|
402
|
+
log: (...args: unknown[]) => {
|
|
403
|
+
console.log(...args)
|
|
404
|
+
},
|
|
405
|
+
error: (...args: unknown[]) => {
|
|
406
|
+
console.error(...args)
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
process: {
|
|
410
|
+
stdout: {write: (chunk: string) => process.stdout.write(chunk)},
|
|
411
|
+
stderr: {write: (chunk: string) => process.stderr.write(chunk)},
|
|
412
|
+
exit: (code: number) => process.exit(code),
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Internal: test-only DI surface. Not part of the published API.
|
|
417
|
+
export async function runSetupCommand(options: SetupOptions, deps: RunSetupDeps = {}): Promise<void> {
|
|
418
|
+
const interactive = deps.interactive ?? Boolean(process.stdin.isTTY)
|
|
419
|
+
const baseUrl = deps.baseUrl ?? resolveBaseUrl()
|
|
420
|
+
// ctxInjected distinguishes MCP-mounted callers (which need ctx.console.error to surface errors
|
|
421
|
+
// through the MCP transport) from bare CLI callers (where cli.ts top-level catch already logs).
|
|
422
|
+
// Without this distinction, CLI users see the error twice.
|
|
423
|
+
const ctxInjected = deps.ctx !== undefined
|
|
424
|
+
const ctx = deps.ctx ?? realCtx
|
|
425
|
+
const gh = deps.gh ?? realGh
|
|
426
|
+
const prompts = deps.prompts ?? realPrompts
|
|
427
|
+
const smoke = deps.smoke ?? realSmoke
|
|
428
|
+
const validation = deps.validation ?? realValidation
|
|
429
|
+
const mgmtKeyResolver = deps.resolveManagementKey ?? resolveManagementKey
|
|
430
|
+
|
|
431
|
+
// --dry-run: short-circuit before validation so it works with no flags.
|
|
432
|
+
// Never blocks on stdin — safe to run anywhere.
|
|
433
|
+
if (options.dryRun) {
|
|
434
|
+
const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
|
|
435
|
+
const model = options.model ?? PROVIDER_DEFAULTS[providers[0] as ProviderId]
|
|
436
|
+
const harness = options.harness ?? 'opencode'
|
|
437
|
+
const repo = options.repo ?? '<repo not specified>'
|
|
438
|
+
const keyValue = options.key ?? 'sk-placeholder'
|
|
439
|
+
ctx.console.log(
|
|
440
|
+
formatDryRunPreview({
|
|
441
|
+
repo,
|
|
442
|
+
harness,
|
|
443
|
+
providers,
|
|
444
|
+
model,
|
|
445
|
+
template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
validateSetupOptions(options, interactive)
|
|
1096
452
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
| {kind: 'unverified'; message: string; runUrl?: string}
|
|
453
|
+
if (interactive) {
|
|
454
|
+
prompts.intro('CLIProxyAPI setup wizard')
|
|
455
|
+
}
|
|
1101
456
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
createdAt: string
|
|
1108
|
-
}
|
|
457
|
+
try {
|
|
458
|
+
await withSpinner('Checking GitHub CLI availability', async () => {
|
|
459
|
+
await gh.assertGhInstalled()
|
|
460
|
+
await gh.assertGhAuthenticated()
|
|
461
|
+
})
|
|
1109
462
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
_testDelayMs?: number
|
|
1114
|
-
/** Override the trigger timestamp used for createdAt heuristic. */
|
|
1115
|
-
_testTriggerTime?: Date
|
|
1116
|
-
}
|
|
463
|
+
await withSpinner('Checking CLIProxyAPI reachability', async () => {
|
|
464
|
+
await validation.assertProxyReachable(baseUrl)
|
|
465
|
+
})
|
|
1117
466
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
* runs exist (baselineId=null), falls back to createdAt > triggerTime.
|
|
1125
|
-
*
|
|
1126
|
-
* Known edge case: if a concurrent contributor's run appears before ours,
|
|
1127
|
-
* we pick the highest databaseId above baseline — this may misattribute
|
|
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
|
|
467
|
+
const plan = interactive
|
|
468
|
+
? await buildInteractivePlan(options, baseUrl, prompts)
|
|
469
|
+
: await buildNonInteractivePlan(options, baseUrl, {validation})
|
|
470
|
+
|
|
471
|
+
if (plan.createKey) {
|
|
472
|
+
mgmtKeyResolver()
|
|
1143
473
|
}
|
|
1144
|
-
await new Promise(resolve => setTimeout(resolve, ms))
|
|
1145
|
-
}
|
|
1146
474
|
|
|
1147
|
-
|
|
475
|
+
await gh.withGhRetry(
|
|
476
|
+
`Checking GitHub access for ${plan.repo}`,
|
|
477
|
+
async () => {
|
|
478
|
+
await gh.assertRepoAccess(plan.repo)
|
|
479
|
+
},
|
|
480
|
+
interactive,
|
|
481
|
+
)
|
|
1148
482
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
483
|
+
if (options.key) {
|
|
484
|
+
log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (interactive) {
|
|
488
|
+
prompts.note(
|
|
489
|
+
[
|
|
490
|
+
`Proxy: ${baseUrl}`,
|
|
491
|
+
`Repository: ${plan.repo}`,
|
|
492
|
+
`Harness: ${plan.harness}`,
|
|
493
|
+
plan.createKey ? `New key name: ${plan.keyName}` : 'Using existing key value',
|
|
494
|
+
'GitHub values to write:',
|
|
495
|
+
formatTemplateSummary(plan.template),
|
|
496
|
+
].join('\n'),
|
|
497
|
+
'Setup summary',
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
const shouldContinue = await prompts.promptValue(
|
|
501
|
+
prompts.confirm({
|
|
502
|
+
message: 'Proceed with GitHub secret and variable updates?',
|
|
503
|
+
active: 'yes',
|
|
504
|
+
inactive: 'no',
|
|
505
|
+
initialValue: true,
|
|
506
|
+
}),
|
|
507
|
+
'Setup cancelled before applying GitHub values.',
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
if (!shouldContinue) {
|
|
511
|
+
cancelAndExit('No changes applied.')
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const [existingSecrets, existingVariables] = await gh.withGhRetry(
|
|
516
|
+
'Checking existing GitHub secrets and variables',
|
|
517
|
+
async () =>
|
|
518
|
+
Promise.all([gh.listExistingGhNames(plan.repo, 'secret'), gh.listExistingGhNames(plan.repo, 'variable')]),
|
|
519
|
+
interactive,
|
|
1155
520
|
)
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
521
|
+
|
|
522
|
+
// Key-reuse acknowledgment guard: bearer token must not appear in prompt text
|
|
523
|
+
if (options.key && existingSecrets.includes('OPENCODE_AUTH_JSON')) {
|
|
524
|
+
if (interactive) {
|
|
525
|
+
const proceed = await prompts.promptValue(
|
|
526
|
+
prompts.confirm({
|
|
527
|
+
message: `You supplied --key ${redactKey(options.key)}. Verify it matches the bearer token inside the existing OPENCODE_AUTH_JSON on ${plan.repo}. Continue?`,
|
|
528
|
+
active: 'yes',
|
|
529
|
+
inactive: 'no',
|
|
530
|
+
initialValue: false,
|
|
531
|
+
}),
|
|
532
|
+
'Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.',
|
|
533
|
+
)
|
|
534
|
+
if (!proceed) {
|
|
535
|
+
cancelAndExit('Setup cancelled. Run with --ack-key-reuse to bypass interactive confirmation.')
|
|
536
|
+
}
|
|
537
|
+
} else if (!options.ackKeyReuse) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`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.)`,
|
|
540
|
+
)
|
|
1165
541
|
}
|
|
1166
542
|
}
|
|
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
543
|
|
|
1172
|
-
|
|
1173
|
-
const triggerTime = internals._testTriggerTime ?? new Date()
|
|
544
|
+
const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
|
|
1174
545
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
}
|
|
546
|
+
if (collisions.length > 0) {
|
|
547
|
+
if (!interactive && !options.force) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`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).`,
|
|
550
|
+
)
|
|
551
|
+
}
|
|
1189
552
|
|
|
1190
|
-
|
|
1191
|
-
|
|
553
|
+
if (!interactive && options.force) {
|
|
554
|
+
log.warn(
|
|
555
|
+
`Overwriting existing GitHub values: ${collisions.join(', ')}. ` +
|
|
556
|
+
`Concurrent setup runs against the same repo are not coordinated and resolve last-write-wins — don't run setup against this repo from two places at once.`,
|
|
557
|
+
)
|
|
558
|
+
// proceed
|
|
559
|
+
}
|
|
1192
560
|
|
|
1193
|
-
|
|
1194
|
-
|
|
561
|
+
if (interactive) {
|
|
562
|
+
log.warn(`Existing GitHub values will be overwritten: ${collisions.join(', ')}`)
|
|
563
|
+
const overwrite = await prompts.promptValue(
|
|
564
|
+
prompts.confirm({
|
|
565
|
+
message: 'Overwrite the existing GitHub values?',
|
|
566
|
+
active: 'overwrite',
|
|
567
|
+
inactive: 'cancel',
|
|
568
|
+
initialValue: false,
|
|
569
|
+
}),
|
|
570
|
+
'Setup cancelled instead of overwriting existing values.',
|
|
571
|
+
)
|
|
1195
572
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
[
|
|
1200
|
-
'gh',
|
|
1201
|
-
'run',
|
|
1202
|
-
'list',
|
|
1203
|
-
'--workflow=fro-bot.yaml',
|
|
1204
|
-
'--repo',
|
|
1205
|
-
repo,
|
|
1206
|
-
'--limit',
|
|
1207
|
-
'5',
|
|
1208
|
-
'--json',
|
|
1209
|
-
'databaseId,status,conclusion,url,createdAt',
|
|
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[]
|
|
573
|
+
if (!overwrite) {
|
|
574
|
+
cancelAndExit('Existing GitHub values left unchanged.')
|
|
575
|
+
}
|
|
1220
576
|
}
|
|
1221
|
-
} catch {
|
|
1222
|
-
// Parse/network error — retry on next poll
|
|
1223
|
-
continue
|
|
1224
577
|
}
|
|
1225
578
|
|
|
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
|
-
})
|
|
579
|
+
let keyCreatedByThisRun = false
|
|
580
|
+
const managementKey = plan.createKey ? mgmtKeyResolver() : undefined
|
|
1234
581
|
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1237
|
-
|
|
582
|
+
if (plan.createKey && managementKey) {
|
|
583
|
+
await withSpinner('Creating a new CLIProxyAPI key', async () => {
|
|
584
|
+
await gh.createManagementApiKey(baseUrl, managementKey, plan.keyValue)
|
|
585
|
+
keyCreatedByThisRun = true
|
|
586
|
+
})
|
|
1238
587
|
}
|
|
1239
588
|
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
589
|
+
try {
|
|
590
|
+
await gh.withGhRetry(
|
|
591
|
+
'Writing GitHub secrets and variables',
|
|
592
|
+
async spinnerInstance => {
|
|
593
|
+
for (const secret of plan.template.secrets) {
|
|
594
|
+
spinnerInstance.message(`Setting secret ${secret.name}`)
|
|
595
|
+
await gh.applyGhValue('secret', secret.name, plan.repo, secret.value)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
for (const variable of plan.template.variables) {
|
|
599
|
+
spinnerInstance.message(`Setting variable ${variable.name}`)
|
|
600
|
+
await gh.applyGhValue('variable', variable.name, plan.repo, variable.value)
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
interactive,
|
|
604
|
+
)
|
|
1243
605
|
|
|
1244
|
-
|
|
606
|
+
await verifyWrittenNamesVisible(
|
|
607
|
+
plan.repo,
|
|
608
|
+
plan.template.secrets.map(s => s.name),
|
|
609
|
+
plan.template.variables.map(v => v.name),
|
|
610
|
+
gh.listExistingGhNames,
|
|
611
|
+
)
|
|
1245
612
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
}
|
|
613
|
+
await withSpinner('Verifying the new key through the proxy', async () => {
|
|
614
|
+
await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
|
|
615
|
+
})
|
|
1250
616
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
617
|
+
if (plan.harness === 'opencode') {
|
|
618
|
+
const workflow = await gh.withGhRetry(
|
|
619
|
+
`Checking ${plan.repo} fro-bot.yaml wiring`,
|
|
620
|
+
async () => {
|
|
621
|
+
return checkFroBotWorkflow(plan.repo)
|
|
622
|
+
},
|
|
623
|
+
interactive,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
switch (workflow.kind) {
|
|
627
|
+
case 'missing': {
|
|
628
|
+
log.warn(
|
|
629
|
+
`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.`,
|
|
630
|
+
)
|
|
631
|
+
break
|
|
632
|
+
}
|
|
633
|
+
case 'unreachable': {
|
|
634
|
+
log.warn(
|
|
635
|
+
`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.`,
|
|
636
|
+
)
|
|
637
|
+
break
|
|
638
|
+
}
|
|
639
|
+
case 'no-agent-step': {
|
|
640
|
+
log.warn(
|
|
641
|
+
`${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.`,
|
|
642
|
+
)
|
|
643
|
+
break
|
|
644
|
+
}
|
|
645
|
+
case 'analyzed': {
|
|
646
|
+
for (const step of workflow.stepsWithGaps) {
|
|
647
|
+
const missing = [...step.missingInputs]
|
|
648
|
+
log.warn(
|
|
649
|
+
[
|
|
650
|
+
`${plan.repo} .github/workflows/fro-bot.yaml fro-bot/agent step #${step.stepOrdinal} is missing ${missing.length} required input${
|
|
651
|
+
missing.length > 1 ? 's' : ''
|
|
652
|
+
} (${missing.join(', ')}).`,
|
|
653
|
+
`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'}.`,
|
|
654
|
+
'',
|
|
655
|
+
`Add under the 'with:' block of the 'fro-bot/agent' step:`,
|
|
656
|
+
formatWorkflowSnippet(missing),
|
|
657
|
+
].join('\n'),
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
break
|
|
661
|
+
}
|
|
662
|
+
default: {
|
|
663
|
+
const _exhaustive: never = workflow
|
|
664
|
+
throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
|
|
1270
665
|
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
} catch (mutationError) {
|
|
669
|
+
if (keyCreatedByThisRun && managementKey) {
|
|
670
|
+
try {
|
|
671
|
+
await gh.deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
|
|
672
|
+
log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
|
|
1271
673
|
} catch {
|
|
1272
|
-
|
|
674
|
+
log.warn(
|
|
675
|
+
'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
|
|
676
|
+
)
|
|
1273
677
|
}
|
|
1274
|
-
return {kind: 'pass', message: `Smoke test passed${logNote}`, runUrl}
|
|
1275
678
|
}
|
|
1276
|
-
|
|
1277
|
-
return {kind: 'fail', message: `Run completed with conclusion=${conclusion ?? 'unknown'}`, runUrl}
|
|
679
|
+
throw mutationError
|
|
1278
680
|
}
|
|
1279
681
|
|
|
1280
|
-
|
|
1281
|
-
|
|
682
|
+
if (interactive) {
|
|
683
|
+
prompts.outro(`Setup complete for ${plan.repo}. The ${plan.harness} harness can now use ${baseUrl}/v1.`)
|
|
684
|
+
} else {
|
|
685
|
+
log.success(`Setup complete for ${plan.repo}.`)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Smoke test (opt-in, non-blocking) ──────────────────────────────
|
|
689
|
+
if (options.verifySmoke) {
|
|
690
|
+
const smokeResult = await withSpinner('Running smoke test', async () =>
|
|
691
|
+
smoke.runSmokeTest(plan.repo, plan.template.variables.find(v => v.name === 'FRO_BOT_MODEL')?.value ?? ''),
|
|
692
|
+
).catch(async error => {
|
|
693
|
+
// withSpinner re-throws; catch here so smoke test never gates setup
|
|
694
|
+
return {
|
|
695
|
+
kind: 'unverified' as const,
|
|
696
|
+
message: `Smoke test error: ${extractErrorMessage(error)}`,
|
|
697
|
+
runUrl: undefined,
|
|
698
|
+
}
|
|
699
|
+
})
|
|
1282
700
|
|
|
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
|
-
}
|
|
701
|
+
// Machine-parseable hook for MCP/agent consumers
|
|
702
|
+
ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`)
|
|
1291
703
|
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
704
|
+
switch (smokeResult.kind) {
|
|
705
|
+
case 'pass': {
|
|
706
|
+
log.success(`✓ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
707
|
+
break
|
|
708
|
+
}
|
|
709
|
+
case 'fail': {
|
|
710
|
+
log.warn(`✗ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
711
|
+
break
|
|
712
|
+
}
|
|
713
|
+
case 'unverified': {
|
|
714
|
+
log.warn(`⚠ ${smokeResult.message}${smokeResult.runUrl ? ` — ${smokeResult.runUrl}` : ''}`)
|
|
715
|
+
break
|
|
716
|
+
}
|
|
717
|
+
default: {
|
|
718
|
+
const _exhaustive: never = smokeResult
|
|
719
|
+
throw new Error(`Unhandled SmokeResult kind: ${JSON.stringify(_exhaustive)}`)
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} catch (error) {
|
|
724
|
+
const message = extractErrorMessage(error)
|
|
725
|
+
// MCP-mounted callers need ctx.console.error to surface the message through the MCP transport.
|
|
726
|
+
// Bare CLI callers leave it to cli.ts's top-level catch — emitting here too would double-log.
|
|
727
|
+
if (ctxInjected) {
|
|
728
|
+
ctx.console.error(message)
|
|
729
|
+
}
|
|
730
|
+
if (interactive) {
|
|
731
|
+
cancel(message)
|
|
732
|
+
}
|
|
733
|
+
throw error
|
|
1295
734
|
}
|
|
1296
735
|
}
|
|
1297
736
|
|
|
@@ -1324,7 +763,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
1324
763
|
z
|
|
1325
764
|
.string()
|
|
1326
765
|
.describe(
|
|
1327
|
-
'Comma-separated list of providers to enable. Supported values: anthropic, openai. Example: --providers anthropic,openai',
|
|
766
|
+
'Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai',
|
|
1328
767
|
),
|
|
1329
768
|
)
|
|
1330
769
|
.option(
|
|
@@ -1333,7 +772,7 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
1333
772
|
.string()
|
|
1334
773
|
.regex(MODEL_ID_RE)
|
|
1335
774
|
.describe(
|
|
1336
|
-
'Override the default model. Must be provider-prefixed and lowercase. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o',
|
|
775
|
+
'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
776
|
),
|
|
1338
777
|
)
|
|
1339
778
|
.option(
|
|
@@ -1345,275 +784,26 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
1345
784
|
'--verify-smoke',
|
|
1346
785
|
z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
|
|
1347
786
|
)
|
|
787
|
+
.option(
|
|
788
|
+
'--ack-key-reuse',
|
|
789
|
+
z
|
|
790
|
+
.boolean()
|
|
791
|
+
.default(false)
|
|
792
|
+
.describe(
|
|
793
|
+
'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.',
|
|
794
|
+
),
|
|
795
|
+
)
|
|
796
|
+
.example('# Preview planned actions without applying any changes (no flags required)')
|
|
797
|
+
.example('infra cliproxy setup --dry-run')
|
|
1348
798
|
.example('# Run the interactive onboarding wizard')
|
|
1349
799
|
.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
|
-
}
|
|
800
|
+
.example('# Run non-interactively with an existing key (anthropic-only)')
|
|
801
|
+
.example('infra cliproxy setup --repo owner/repo --harness opencode --key sk-existing --force')
|
|
802
|
+
.example('# Enable both providers non-interactively (requires --force and --model)')
|
|
803
|
+
.example(
|
|
804
|
+
'infra cliproxy setup --repo owner/repo --harness opencode --providers anthropic,openai --model openai/gpt-5.4-mini --key sk-existing --ack-key-reuse --force',
|
|
805
|
+
)
|
|
806
|
+
.action(async (options, ctx) => {
|
|
807
|
+
await runSetupCommand(options, {ctx})
|
|
1618
808
|
})
|
|
1619
809
|
}
|