@marcusrbrown/infra 0.6.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 +6 -0
- 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 +1867 -247
- package/src/commands/cliproxy/setup.ts +544 -831
- 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,52 +1,62 @@
|
|
|
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
|
-
const DEFAULT_OMO_PROVIDERS = 'claude-max20'
|
|
15
|
-
const DEFAULT_FRO_BOT_MODEL = 'anthropic/claude-sonnet-4-6'
|
|
16
|
-
|
|
17
|
-
const harnessSchema = z.enum(['opencode', 'claude-code', 'generic'])
|
|
18
|
-
const ghRepoViewSchema = z.object({
|
|
19
|
-
nameWithOwner: z.string(),
|
|
20
|
-
viewerPermission: z.string(),
|
|
21
|
-
})
|
|
22
|
-
const ghNameListSchema = z.array(z.object({name: z.string()}))
|
|
23
|
-
|
|
24
|
-
export type Harness = z.infer<typeof harnessSchema>
|
|
25
48
|
|
|
26
49
|
export interface SetupOptions {
|
|
27
50
|
key?: string
|
|
28
51
|
repo?: string
|
|
29
52
|
harness?: Harness
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export interface VariableAssignment {
|
|
38
|
-
name: string
|
|
39
|
-
value: string
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface HarnessTemplate {
|
|
43
|
-
secrets: SecretAssignment[]
|
|
44
|
-
variables: VariableAssignment[]
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface GenericSecretNames {
|
|
48
|
-
apiKeySecretName: string
|
|
49
|
-
baseUrlSecretName: string
|
|
53
|
+
/** Raw comma-separated provider list string (e.g. "anthropic,openai"). Use parseProviders() to validate. */
|
|
54
|
+
providers?: string
|
|
55
|
+
model?: string
|
|
56
|
+
force?: boolean
|
|
57
|
+
dryRun?: boolean
|
|
58
|
+
verifySmoke?: boolean
|
|
59
|
+
ackKeyReuse?: boolean
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
interface SetupPlan {
|
|
@@ -58,611 +68,63 @@ interface SetupPlan {
|
|
|
58
68
|
template: HarnessTemplate
|
|
59
69
|
}
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (isInteractive) {
|
|
77
|
-
return
|
|
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
|
|
78
86
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
throw new Error('--repo is required when stdin is not a TTY. Provide the target GitHub repository as owner/repo.')
|
|
87
|
+
prompts?: {
|
|
88
|
+
promptValue: typeof promptValue
|
|
89
|
+
confirm: typeof confirm
|
|
90
|
+
intro: typeof intro
|
|
91
|
+
note: typeof note
|
|
92
|
+
outro: typeof outro
|
|
86
93
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
throw new Error('--harness is required when stdin is not a TTY. Choose opencode or claude-code.')
|
|
94
|
+
smoke?: {
|
|
95
|
+
runSmokeTest: typeof runSmokeTest
|
|
90
96
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
validation?: {
|
|
98
|
+
assertProxyReachable: typeof assertProxyReachable
|
|
99
|
+
assertProxyKeyWorks: typeof assertProxyKeyWorks
|
|
100
|
+
verifyModelsAvailable: typeof verifyModelsAvailable
|
|
94
101
|
}
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
values: {
|
|
100
|
-
keyValue?: string
|
|
101
|
-
baseUrl?: string
|
|
102
|
-
genericSecretNames?: GenericSecretNames
|
|
103
|
-
} = {},
|
|
104
|
-
): HarnessTemplate {
|
|
105
|
-
const keyValue = values.keyValue ?? 'sk-placeholder'
|
|
106
|
-
const baseUrl = stripTrailingSlash(values.baseUrl ?? DEFAULT_CLIPROXY_URL)
|
|
107
|
-
|
|
108
|
-
if (harness === 'opencode') {
|
|
109
|
-
return {
|
|
110
|
-
secrets: [
|
|
111
|
-
{
|
|
112
|
-
name: 'OPENCODE_AUTH_JSON',
|
|
113
|
-
value: JSON.stringify({anthropic: {type: 'api', key: keyValue}}),
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
name: 'OPENCODE_CONFIG',
|
|
117
|
-
value: JSON.stringify({provider: {anthropic: {options: {baseURL: `${baseUrl}/v1`}}}}),
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
name: 'OMO_PROVIDERS',
|
|
121
|
-
value: DEFAULT_OMO_PROVIDERS,
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
variables: [
|
|
125
|
-
{
|
|
126
|
-
name: 'FRO_BOT_MODEL',
|
|
127
|
-
value: DEFAULT_FRO_BOT_MODEL,
|
|
128
|
-
},
|
|
129
|
-
],
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (harness === 'claude-code') {
|
|
134
|
-
return {
|
|
135
|
-
secrets: [
|
|
136
|
-
{
|
|
137
|
-
name: 'ANTHROPIC_API_KEY',
|
|
138
|
-
value: keyValue,
|
|
139
|
-
},
|
|
140
|
-
],
|
|
141
|
-
variables: [],
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!values.genericSecretNames) {
|
|
146
|
-
throw new Error('Generic harness requires custom secret names.')
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
secrets: [
|
|
151
|
-
{name: values.genericSecretNames.apiKeySecretName, value: keyValue},
|
|
152
|
-
{name: values.genericSecretNames.baseUrlSecretName, value: `${baseUrl}/v1`},
|
|
153
|
-
],
|
|
154
|
-
variables: [],
|
|
155
|
-
}
|
|
104
|
+
function resolveBaseUrl(input?: string): string {
|
|
105
|
+
return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
156
106
|
}
|
|
157
107
|
|
|
158
108
|
function extractErrorMessage(error: unknown): string {
|
|
159
109
|
return error instanceof Error ? error.message : String(error)
|
|
160
110
|
}
|
|
161
111
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function ensureSecretName(value: string, label: string): string {
|
|
171
|
-
const trimmed = value.trim()
|
|
172
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
|
|
173
|
-
throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`)
|
|
174
|
-
}
|
|
175
|
-
return trimmed
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function cancelAndExit(message = 'Setup cancelled.'): never {
|
|
179
|
-
cancel(message)
|
|
180
|
-
process.exit(0)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
async function promptValue<T extends string | boolean>(
|
|
184
|
-
promise: Promise<T | symbol>,
|
|
185
|
-
cancelMessage?: string,
|
|
186
|
-
): Promise<T> {
|
|
187
|
-
const value = await promise
|
|
188
|
-
if (isCancel(value)) {
|
|
189
|
-
cancelAndExit(cancelMessage)
|
|
190
|
-
}
|
|
191
|
-
return value
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async function withSpinner<T>(message: string, run: (spinnerInstance: SpinnerResult) => Promise<T>): Promise<T> {
|
|
195
|
-
const spinnerInstance = spinner()
|
|
196
|
-
spinnerInstance.start(message)
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
const result = await run(spinnerInstance)
|
|
200
|
-
spinnerInstance.stop(message)
|
|
201
|
-
return result
|
|
202
|
-
} catch (error) {
|
|
203
|
-
spinnerInstance.error(`${message} failed`)
|
|
204
|
-
throw error
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async function runCommand(command: string, args: string[]): Promise<CommandResult> {
|
|
209
|
-
const child = Bun.spawn([command, ...args], {
|
|
210
|
-
stdout: 'pipe',
|
|
211
|
-
stderr: 'pipe',
|
|
212
|
-
env: process.env,
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
216
|
-
new Response(child.stdout).text(),
|
|
217
|
-
new Response(child.stderr).text(),
|
|
218
|
-
child.exited,
|
|
219
|
-
])
|
|
220
|
-
|
|
221
|
-
return {stdout, stderr, exitCode}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function runGh(args: string[]): Promise<CommandResult> {
|
|
225
|
-
return runCommand('gh', args)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function isGhRateLimitError(text: string): boolean {
|
|
229
|
-
return /rate limit/i.test(text)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
|
|
234
|
-
* exempt from rate limiting itself, so this should succeed even when the
|
|
235
|
-
* primary GraphQL limit is exhausted. Returns a formatted local time string
|
|
236
|
-
* or a fallback phrase when the endpoint is unreachable.
|
|
237
|
-
*/
|
|
238
|
-
async function queryRateLimitReset(): Promise<string> {
|
|
239
|
-
try {
|
|
240
|
-
const result = await runGh(['api', 'rate_limit'])
|
|
241
|
-
if (result.exitCode === 0) {
|
|
242
|
-
const parsed = JSON.parse(result.stdout) as {
|
|
243
|
-
resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
|
|
244
|
-
}
|
|
245
|
-
const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
|
|
246
|
-
if (reset) {
|
|
247
|
-
return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
} catch {
|
|
251
|
-
// Fall through to generic phrase
|
|
252
|
-
}
|
|
253
|
-
return 'an unknown time'
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
|
|
258
|
-
* rate-limit errors when in interactive mode. In non-interactive mode the
|
|
259
|
-
* error is re-thrown with the reset time appended so the caller can surface
|
|
260
|
-
* it without prompting.
|
|
261
|
-
*/
|
|
262
|
-
export async function withGhRetry<T>(
|
|
263
|
-
label: string,
|
|
264
|
-
fn: (spinnerInstance: SpinnerResult) => Promise<T>,
|
|
265
|
-
interactive: boolean,
|
|
266
|
-
queryReset: () => Promise<string> = queryRateLimitReset,
|
|
267
|
-
): Promise<T> {
|
|
268
|
-
for (;;) {
|
|
269
|
-
try {
|
|
270
|
-
return await withSpinner(label, fn)
|
|
271
|
-
} catch (error) {
|
|
272
|
-
const message = extractErrorMessage(error)
|
|
273
|
-
if (!isGhRateLimitError(message)) {
|
|
274
|
-
throw error
|
|
275
|
-
}
|
|
276
|
-
const reset = await queryReset()
|
|
277
|
-
if (!interactive) {
|
|
278
|
-
throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
|
|
279
|
-
}
|
|
280
|
-
log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
|
|
281
|
-
const retry = await promptValue(
|
|
282
|
-
confirm({
|
|
283
|
-
message: 'Retry this step when ready?',
|
|
284
|
-
active: 'retry',
|
|
285
|
-
inactive: 'abort',
|
|
286
|
-
initialValue: true,
|
|
287
|
-
}),
|
|
288
|
-
'Setup aborted after rate limit.',
|
|
289
|
-
)
|
|
290
|
-
if (!retry) {
|
|
291
|
-
cancelAndExit('Setup aborted after GitHub API rate limit.')
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
async function assertGhInstalled(): Promise<void> {
|
|
298
|
-
if (!Bun.which('gh')) {
|
|
299
|
-
throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
async function assertGhAuthenticated(): Promise<void> {
|
|
304
|
-
const result = await runGh(['auth', 'status'])
|
|
305
|
-
if (result.exitCode !== 0) {
|
|
306
|
-
throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim())
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async function assertRepoAccess(repo: string): Promise<void> {
|
|
311
|
-
const result = await runGh(['repo', 'view', repo, '--json', 'nameWithOwner,viewerPermission'])
|
|
312
|
-
if (result.exitCode !== 0) {
|
|
313
|
-
throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim())
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout))
|
|
317
|
-
const writePermissions = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
|
|
318
|
-
|
|
319
|
-
if (!writePermissions.has(parsed.viewerPermission)) {
|
|
320
|
-
throw new Error(
|
|
321
|
-
`GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`,
|
|
322
|
-
)
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): Promise<string[]> {
|
|
327
|
-
const result = await runGh([kind, 'list', '--repo', repo, '--json', 'name'])
|
|
328
|
-
if (result.exitCode !== 0) {
|
|
329
|
-
throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim())
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export type FroBotWorkflowCheck =
|
|
336
|
-
| {kind: 'missing'}
|
|
337
|
-
| {kind: 'unreachable'; reason: string}
|
|
338
|
-
| {kind: 'no-agent-step'}
|
|
339
|
-
| {
|
|
340
|
-
kind: 'analyzed'
|
|
341
|
-
stepsWithGaps: readonly {stepOrdinal: number; missingInputs: readonly string[]}[]
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// github-token and prompt are intentionally excluded from this check:
|
|
345
|
-
// github-token is harness-agnostic (PAT wiring, not secret-routing) and prompt is
|
|
346
|
-
// workflow-defined (the user's prompt body, not a harness default).
|
|
347
|
-
const REQUIRED_OPENCODE_INPUTS = ['auth-json', 'opencode-config', 'omo-providers', 'model'] as const
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Slice the workflow content into one entry per `fro-bot/agent` step. Handles
|
|
351
|
-
* both the `- name:\n uses: ...` and `- uses: ...` step shapes. Returns an
|
|
352
|
-
* empty array if no fro-bot/agent step is present.
|
|
353
|
-
*
|
|
354
|
-
* Step-scoped slicing prevents false-passes where a same-named input key in a
|
|
355
|
-
* sibling step (strategy.matrix, custom actions, reusable workflow with:
|
|
356
|
-
* blocks) could mask a genuine gap in fro-bot/agent's wiring.
|
|
357
|
-
*/
|
|
358
|
-
function findFroBotAgentStepBodies(content: string): {stepOrdinal: number; body: string}[] {
|
|
359
|
-
const bodies: {stepOrdinal: number; body: string}[] = []
|
|
360
|
-
const pattern = /^(\s*(?:-\s+)?)uses:\s*fro-bot\/agent@/gm
|
|
361
|
-
|
|
362
|
-
for (const match of content.matchAll(pattern)) {
|
|
363
|
-
if (match.index === undefined || match[1] === undefined) continue
|
|
364
|
-
|
|
365
|
-
const stepBodyIndent = match[1].length
|
|
366
|
-
const dashIndent = Math.max(0, stepBodyIndent - 2)
|
|
367
|
-
const lines = content.slice(match.index).split('\n')
|
|
368
|
-
const stepLines: string[] = [lines[0] ?? '']
|
|
369
|
-
|
|
370
|
-
for (let index = 1; index < lines.length; index += 1) {
|
|
371
|
-
const line = lines[index] ?? ''
|
|
372
|
-
if (!line.trim()) {
|
|
373
|
-
stepLines.push(line)
|
|
374
|
-
continue
|
|
375
|
-
}
|
|
376
|
-
const firstNonSpace = line.search(/\S/)
|
|
377
|
-
if (firstNonSpace === dashIndent && line.trimStart().startsWith('-')) break
|
|
378
|
-
if (firstNonSpace < dashIndent) break
|
|
379
|
-
stepLines.push(line)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
bodies.push({stepOrdinal: bodies.length + 1, body: stepLines.join('\n')})
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return bodies
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export function analyzeFroBotWorkflow(workflowContent: string): FroBotWorkflowCheck {
|
|
389
|
-
const steps = findFroBotAgentStepBodies(workflowContent)
|
|
390
|
-
|
|
391
|
-
if (steps.length === 0) {
|
|
392
|
-
return {kind: 'no-agent-step'}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const stepsWithGaps = steps
|
|
396
|
-
.map(step => ({
|
|
397
|
-
stepOrdinal: step.stepOrdinal,
|
|
398
|
-
missingInputs: REQUIRED_OPENCODE_INPUTS.filter(input => {
|
|
399
|
-
const inputPattern = new RegExp(String.raw`^\s+${input}:`, 'm')
|
|
400
|
-
return !inputPattern.test(step.body)
|
|
401
|
-
}),
|
|
402
|
-
}))
|
|
403
|
-
.filter(step => step.missingInputs.length > 0)
|
|
404
|
-
|
|
405
|
-
return {kind: 'analyzed', stepsWithGaps}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Extracted pure helper: turn a `gh api /repos/.../contents/<file>` result into
|
|
410
|
-
* a FroBotWorkflowCheck. Separated from checkFroBotWorkflow so tests can exercise
|
|
411
|
-
* the 404-vs-transport-error logic without mocking Bun.spawn.
|
|
412
|
-
*/
|
|
413
|
-
export function interpretGhContentResult(result: CommandResult): FroBotWorkflowCheck {
|
|
414
|
-
if (result.exitCode === 0) {
|
|
415
|
-
return analyzeFroBotWorkflow(result.stdout)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// gh prints `gh: Not Found (HTTP 404)` on 404; anything else is auth/network/5xx.
|
|
419
|
-
if (/HTTP 404/.test(result.stderr)) {
|
|
420
|
-
return {kind: 'missing'}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return {
|
|
424
|
-
kind: 'unreachable',
|
|
425
|
-
reason: result.stderr.trim() || `gh api exited with code ${result.exitCode}`,
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async function checkFroBotWorkflow(repo: string): Promise<FroBotWorkflowCheck> {
|
|
430
|
-
const result = await runGh([
|
|
431
|
-
'api',
|
|
432
|
-
'--header',
|
|
433
|
-
'Accept: application/vnd.github.raw',
|
|
434
|
-
`/repos/${repo}/contents/.github/workflows/fro-bot.yaml`,
|
|
435
|
-
])
|
|
436
|
-
|
|
437
|
-
return interpretGhContentResult(result)
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Snippet uses 10-space indent to match the canonical `with:` block depth
|
|
441
|
-
// in marcusrbrown/infra/.github/workflows/fro-bot.yaml, so users can paste
|
|
442
|
-
// directly under their step's `with:` key without re-indenting.
|
|
443
|
-
export function formatWorkflowSnippet(missingInputs: readonly string[]): string {
|
|
444
|
-
/* eslint-disable no-template-curly-in-string -- GitHub Actions expression syntax, not JS template literals */
|
|
445
|
-
const inputMap: Record<string, string> = {
|
|
446
|
-
'auth-json': 'auth-json: ${{ secrets.OPENCODE_AUTH_JSON }}',
|
|
447
|
-
'opencode-config': 'opencode-config: ${{ secrets.OPENCODE_CONFIG }}',
|
|
448
|
-
'omo-providers': 'omo-providers: ${{ secrets.OMO_PROVIDERS }}',
|
|
449
|
-
model: 'model: ${{ vars.FRO_BOT_MODEL }}',
|
|
450
|
-
}
|
|
451
|
-
/* eslint-enable no-template-curly-in-string */
|
|
452
|
-
return missingInputs.map(input => ` ${inputMap[input]}`).join('\n')
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async function assertProxyReachable(baseUrl: string): Promise<void> {
|
|
456
|
-
try {
|
|
457
|
-
const response = await fetch(baseUrl, {
|
|
458
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
459
|
-
})
|
|
460
|
-
|
|
461
|
-
if (!response.ok) {
|
|
462
|
-
throw new Error(`Proxy check failed for ${baseUrl}: HTTP ${response.status}. Is the proxy running and reachable?`)
|
|
463
|
-
}
|
|
464
|
-
} catch (error) {
|
|
465
|
-
if (error instanceof Error && error.message.startsWith('Proxy check failed')) {
|
|
466
|
-
throw error
|
|
467
|
-
}
|
|
468
|
-
throw new Error(`Unable to reach proxy at ${baseUrl}: ${extractErrorMessage(error)}`)
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
async function assertProxyKeyWorks(baseUrl: string, keyValue: string): Promise<void> {
|
|
473
|
-
try {
|
|
474
|
-
const response = await fetch(`${baseUrl}/v1/models`, {
|
|
475
|
-
headers: {
|
|
476
|
-
authorization: `Bearer ${keyValue}`,
|
|
477
|
-
},
|
|
478
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
479
|
-
})
|
|
480
|
-
|
|
481
|
-
if (!response.ok) {
|
|
482
|
-
throw new Error(`Proxy key verification failed with HTTP ${response.status}`)
|
|
483
|
-
}
|
|
484
|
-
} catch (error) {
|
|
485
|
-
if (error instanceof Error && error.message.startsWith('Proxy key verification')) {
|
|
486
|
-
throw error
|
|
487
|
-
}
|
|
488
|
-
throw new Error(`Unable to verify proxy key at ${baseUrl}: ${extractErrorMessage(error)}`)
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
|
|
493
|
-
const response = await fetch(endpoint, {
|
|
494
|
-
...init,
|
|
495
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
if (!response.ok) {
|
|
499
|
-
const body = await response.text()
|
|
500
|
-
throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
try {
|
|
504
|
-
return await response.json()
|
|
505
|
-
} catch {
|
|
506
|
-
return null
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function managementHeaders(key: string): Headers {
|
|
511
|
-
const headers = new Headers()
|
|
512
|
-
headers.set('x-management-key', key)
|
|
513
|
-
headers.set('content-type', 'application/json')
|
|
514
|
-
return headers
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function buildApiKeyValue(keyName: string): string {
|
|
518
|
-
const slug = (
|
|
519
|
-
keyName
|
|
520
|
-
.trim()
|
|
521
|
-
.toLowerCase()
|
|
522
|
-
.match(/[a-z0-9]+/g) ?? []
|
|
523
|
-
)
|
|
524
|
-
.join('-')
|
|
525
|
-
.slice(0, 24)
|
|
526
|
-
const random = crypto.randomUUID().split('-').join('')
|
|
527
|
-
return `sk-${slug || 'cliproxy'}-${random}`
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
async function createManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
|
|
531
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
532
|
-
const currentPayload = await requestJson(endpoint, {
|
|
533
|
-
method: 'GET',
|
|
534
|
-
headers: managementHeaders(managementKey),
|
|
535
|
-
})
|
|
536
|
-
const currentKeys = toStringArray(currentPayload)
|
|
537
|
-
|
|
538
|
-
if (currentKeys.includes(keyValue)) {
|
|
539
|
-
return
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
await requestJson(endpoint, {
|
|
543
|
-
method: 'PUT',
|
|
544
|
-
headers: managementHeaders(managementKey),
|
|
545
|
-
body: JSON.stringify([...currentKeys, keyValue]),
|
|
546
|
-
})
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
async function deleteManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
|
|
550
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
551
|
-
const currentPayload = await requestJson(endpoint, {
|
|
552
|
-
method: 'GET',
|
|
553
|
-
headers: managementHeaders(managementKey),
|
|
554
|
-
})
|
|
555
|
-
const currentKeys = toStringArray(currentPayload)
|
|
556
|
-
const filtered = currentKeys.filter(k => k !== keyValue)
|
|
557
|
-
|
|
558
|
-
if (filtered.length === currentKeys.length) {
|
|
559
|
-
return
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
await requestJson(endpoint, {
|
|
563
|
-
method: 'PUT',
|
|
564
|
-
headers: managementHeaders(managementKey),
|
|
565
|
-
body: JSON.stringify(filtered),
|
|
566
|
-
})
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
async function applyGhValue(kind: 'secret' | 'variable', name: string, repo: string, value: string): Promise<void> {
|
|
570
|
-
if (kind === 'secret') {
|
|
571
|
-
const child = Bun.spawn(['gh', 'secret', 'set', name, '--repo', repo], {
|
|
572
|
-
stdin: new Blob([value]).stream(),
|
|
573
|
-
stdout: 'pipe',
|
|
574
|
-
stderr: 'pipe',
|
|
575
|
-
env: process.env,
|
|
576
|
-
})
|
|
577
|
-
|
|
578
|
-
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
|
|
579
|
-
|
|
580
|
-
if (exitCode !== 0) {
|
|
581
|
-
throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim())
|
|
582
|
-
}
|
|
583
|
-
return
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
const result = await runGh([kind, 'set', name, '--repo', repo, '--body', value])
|
|
587
|
-
if (result.exitCode !== 0) {
|
|
588
|
-
throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function formatTemplateSummary(template: HarnessTemplate): string {
|
|
593
|
-
const secretLines = template.secrets.map(secret => `- secret ${secret.name}`)
|
|
594
|
-
const variableLines = template.variables.map(variable => `- variable ${variable.name}`)
|
|
595
|
-
return [...secretLines, ...variableLines].join('\n')
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function collectCollisions(
|
|
599
|
-
template: HarnessTemplate,
|
|
600
|
-
existingSecrets: string[],
|
|
601
|
-
existingVariables: string[],
|
|
602
|
-
): string[] {
|
|
603
|
-
const collisions: string[] = []
|
|
604
|
-
|
|
605
|
-
for (const secret of template.secrets) {
|
|
606
|
-
if (existingSecrets.includes(secret.name)) {
|
|
607
|
-
collisions.push(`secret ${secret.name}`)
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
for (const variable of template.variables) {
|
|
612
|
-
if (existingVariables.includes(variable.name)) {
|
|
613
|
-
collisions.push(`variable ${variable.name}`)
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
return collisions
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
async function promptGenericSecretNames(): Promise<GenericSecretNames> {
|
|
621
|
-
const apiKeySecretName = ensureSecretName(
|
|
622
|
-
await promptValue(
|
|
623
|
-
text({
|
|
624
|
-
message: 'Name for the API key secret',
|
|
625
|
-
placeholder: 'CLIPROXY_API_KEY',
|
|
626
|
-
validate: value => {
|
|
627
|
-
try {
|
|
628
|
-
ensureSecretName(value ?? '', 'API key secret name')
|
|
629
|
-
return undefined
|
|
630
|
-
} catch (error) {
|
|
631
|
-
return extractErrorMessage(error)
|
|
632
|
-
}
|
|
633
|
-
},
|
|
634
|
-
}),
|
|
635
|
-
'Setup cancelled before choosing the generic API key secret name.',
|
|
636
|
-
),
|
|
637
|
-
'API key secret name',
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
const baseUrlSecretName = ensureSecretName(
|
|
641
|
-
await promptValue(
|
|
642
|
-
text({
|
|
643
|
-
message: 'Name for the proxy base URL secret',
|
|
644
|
-
placeholder: 'CLIPROXY_BASE_URL',
|
|
645
|
-
validate: value => {
|
|
646
|
-
try {
|
|
647
|
-
ensureSecretName(value ?? '', 'Base URL secret name')
|
|
648
|
-
return undefined
|
|
649
|
-
} catch (error) {
|
|
650
|
-
return extractErrorMessage(error)
|
|
651
|
-
}
|
|
652
|
-
},
|
|
653
|
-
}),
|
|
654
|
-
'Setup cancelled before choosing the generic base URL secret name.',
|
|
655
|
-
),
|
|
656
|
-
'Base URL secret name',
|
|
657
|
-
)
|
|
658
|
-
|
|
659
|
-
return {apiKeySecretName, baseUrlSecretName}
|
|
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)}`
|
|
660
118
|
}
|
|
661
119
|
|
|
662
|
-
async function buildInteractivePlan(
|
|
120
|
+
async function buildInteractivePlan(
|
|
121
|
+
options: SetupOptions,
|
|
122
|
+
baseUrl: string,
|
|
123
|
+
promptsImpl: Required<RunSetupDeps>['prompts'],
|
|
124
|
+
): Promise<SetupPlan> {
|
|
663
125
|
const createKey = !options.key
|
|
664
126
|
const keyName = createKey
|
|
665
|
-
? await promptValue(
|
|
127
|
+
? await promptsImpl.promptValue(
|
|
666
128
|
text({
|
|
667
129
|
message: 'Name this new CLIProxyAPI key',
|
|
668
130
|
placeholder: 'my-repo-ci',
|
|
@@ -674,7 +136,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
674
136
|
|
|
675
137
|
const harness =
|
|
676
138
|
options.harness ??
|
|
677
|
-
(await promptValue(
|
|
139
|
+
(await promptsImpl.promptValue(
|
|
678
140
|
select<Harness>({
|
|
679
141
|
message: 'Choose the harness to configure',
|
|
680
142
|
options: [
|
|
@@ -689,7 +151,7 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
689
151
|
const repo = options.repo
|
|
690
152
|
? ensureRepoFormat(options.repo)
|
|
691
153
|
: ensureRepoFormat(
|
|
692
|
-
await promptValue(
|
|
154
|
+
await promptsImpl.promptValue(
|
|
693
155
|
text({
|
|
694
156
|
message: 'Target GitHub repository',
|
|
695
157
|
placeholder: 'owner/repo',
|
|
@@ -706,6 +168,14 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
706
168
|
),
|
|
707
169
|
)
|
|
708
170
|
|
|
171
|
+
let providers: ProviderId[] | undefined
|
|
172
|
+
let model: string | undefined
|
|
173
|
+
|
|
174
|
+
if (harness === 'opencode') {
|
|
175
|
+
providers = await promptForProviders()
|
|
176
|
+
model = await promptForModel(providers)
|
|
177
|
+
}
|
|
178
|
+
|
|
709
179
|
const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
|
|
710
180
|
const genericSecretNames = harness === 'generic' ? await promptGenericSecretNames() : undefined
|
|
711
181
|
|
|
@@ -715,258 +185,501 @@ async function buildInteractivePlan(options: SetupOptions, baseUrl: string): Pro
|
|
|
715
185
|
keyValue,
|
|
716
186
|
keyName,
|
|
717
187
|
createKey,
|
|
718
|
-
template: getHarnessTemplate(harness, {keyValue, baseUrl, genericSecretNames}),
|
|
188
|
+
template: getHarnessTemplate(harness, {keyValue, baseUrl, genericSecretNames, providers, model}),
|
|
719
189
|
}
|
|
720
190
|
}
|
|
721
191
|
|
|
722
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Returns true when the provider list includes anything beyond anthropic-only.
|
|
194
|
+
* Anthropic-only repos see no behavior change (G7 invariant).
|
|
195
|
+
*/
|
|
196
|
+
export function requiresDestructiveProviderChangeConfirmation(providers: ProviderId[]): boolean {
|
|
197
|
+
return !(providers.length === 1 && providers[0] === 'anthropic')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Deprecated: use requiresDestructiveProviderChangeConfirmation. Will be removed in a future major.
|
|
201
|
+
export const mustConfirmDestructive = requiresDestructiveProviderChangeConfirmation
|
|
202
|
+
|
|
203
|
+
export async function buildNonInteractivePlan(
|
|
204
|
+
options: SetupOptions,
|
|
205
|
+
baseUrl: string,
|
|
206
|
+
deps?: Pick<RunSetupDeps, 'validation'>,
|
|
207
|
+
): Promise<SetupPlan> {
|
|
723
208
|
const harness = harnessSchema.parse(options.harness)
|
|
724
209
|
const repo = ensureRepoFormat(options.repo ?? '')
|
|
725
210
|
const keyValue = options.key ?? ''
|
|
726
211
|
|
|
212
|
+
const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
|
|
213
|
+
|
|
214
|
+
let model: string
|
|
215
|
+
if (options.model) {
|
|
216
|
+
model = options.model
|
|
217
|
+
} else if (providers.length === 1) {
|
|
218
|
+
model = PROVIDER_DEFAULTS[providers[0] as ProviderId]
|
|
219
|
+
} else {
|
|
220
|
+
// Unreachable: validateSetupOptions enforces model when providers.length > 1
|
|
221
|
+
throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --dry-run: skip verifyModelsAvailable and force check; return plan for preview
|
|
225
|
+
if (options.dryRun) {
|
|
226
|
+
return {
|
|
227
|
+
repo,
|
|
228
|
+
harness,
|
|
229
|
+
keyValue,
|
|
230
|
+
createKey: false,
|
|
231
|
+
template: getHarnessTemplate(harness, {keyValue: keyValue || 'sk-placeholder', baseUrl, providers, model}),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable
|
|
236
|
+
|
|
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) {
|
|
240
|
+
throw new Error(
|
|
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).`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await verifyModels(baseUrl, keyValue, providers, model)
|
|
246
|
+
|
|
727
247
|
return {
|
|
728
248
|
repo,
|
|
729
249
|
harness,
|
|
730
250
|
keyValue,
|
|
731
251
|
createKey: false,
|
|
732
|
-
template: getHarnessTemplate(harness, {keyValue, baseUrl}),
|
|
252
|
+
template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
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
|
|
733
335
|
}
|
|
734
|
-
}
|
|
735
336
|
|
|
736
|
-
|
|
737
|
-
cli
|
|
738
|
-
.command(
|
|
739
|
-
'cliproxy setup',
|
|
740
|
-
'Interactively onboard a GitHub repository to CLIProxyAPI by creating or reusing a key and wiring the required GitHub secrets and variables.',
|
|
741
|
-
)
|
|
742
|
-
.option(
|
|
743
|
-
'--key [key]',
|
|
744
|
-
z
|
|
745
|
-
.string()
|
|
746
|
-
.describe(
|
|
747
|
-
'Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.',
|
|
748
|
-
),
|
|
749
|
-
)
|
|
750
|
-
.option(
|
|
751
|
-
'--repo [repo]',
|
|
752
|
-
z.string().describe('Target GitHub repository in owner/repo format. Skips the repository prompt when provided.'),
|
|
753
|
-
)
|
|
754
|
-
.option(
|
|
755
|
-
'--harness [harness]',
|
|
756
|
-
harnessSchema.describe(
|
|
757
|
-
'Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.',
|
|
758
|
-
),
|
|
759
|
-
)
|
|
760
|
-
.example('# Run the interactive onboarding wizard')
|
|
761
|
-
.example('infra cliproxy setup')
|
|
762
|
-
.example('# Run non-interactively with an existing key')
|
|
763
|
-
.example('infra cliproxy setup --key sk-test --repo owner/repo --harness opencode')
|
|
764
|
-
.action(async options => {
|
|
765
|
-
const interactive = Boolean(process.stdin.isTTY)
|
|
766
|
-
const baseUrl = resolveBaseUrl()
|
|
337
|
+
validateSetupOptions(options, interactive)
|
|
767
338
|
|
|
768
|
-
|
|
339
|
+
if (interactive) {
|
|
340
|
+
prompts.intro('CLIProxyAPI setup wizard')
|
|
341
|
+
}
|
|
769
342
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
343
|
+
try {
|
|
344
|
+
await withSpinner('Checking GitHub CLI availability', async () => {
|
|
345
|
+
await gh.assertGhInstalled()
|
|
346
|
+
await gh.assertGhAuthenticated()
|
|
347
|
+
})
|
|
773
348
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
await assertGhAuthenticated()
|
|
778
|
-
})
|
|
349
|
+
await withSpinner('Checking CLIProxyAPI reachability', async () => {
|
|
350
|
+
await validation.assertProxyReachable(baseUrl)
|
|
351
|
+
})
|
|
779
352
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
353
|
+
const plan = interactive
|
|
354
|
+
? await buildInteractivePlan(options, baseUrl, prompts)
|
|
355
|
+
: await buildNonInteractivePlan(options, baseUrl, {validation})
|
|
783
356
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
357
|
+
if (plan.createKey) {
|
|
358
|
+
mgmtKeyResolver()
|
|
359
|
+
}
|
|
787
360
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
361
|
+
await gh.withGhRetry(
|
|
362
|
+
`Checking GitHub access for ${plan.repo}`,
|
|
363
|
+
async () => {
|
|
364
|
+
await gh.assertRepoAccess(plan.repo)
|
|
365
|
+
},
|
|
366
|
+
interactive,
|
|
367
|
+
)
|
|
791
368
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
await assertRepoAccess(plan.repo)
|
|
796
|
-
},
|
|
797
|
-
interactive,
|
|
798
|
-
)
|
|
369
|
+
if (options.key) {
|
|
370
|
+
log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
|
|
371
|
+
}
|
|
799
372
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
+
)
|
|
803
385
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
].join('\n'),
|
|
814
|
-
'Setup summary',
|
|
815
|
-
)
|
|
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
|
+
)
|
|
816
395
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
inactive: 'no',
|
|
822
|
-
initialValue: true,
|
|
823
|
-
}),
|
|
824
|
-
'Setup cancelled before applying GitHub values.',
|
|
825
|
-
)
|
|
396
|
+
if (!shouldContinue) {
|
|
397
|
+
cancelAndExit('No changes applied.')
|
|
398
|
+
}
|
|
399
|
+
}
|
|
826
400
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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,
|
|
406
|
+
)
|
|
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.')
|
|
830
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
|
+
)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
831
429
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
430
|
+
const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
|
|
431
|
+
|
|
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).`,
|
|
837
436
|
)
|
|
838
|
-
|
|
437
|
+
}
|
|
839
438
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
)
|
|
845
|
-
}
|
|
439
|
+
if (!interactive && options.force) {
|
|
440
|
+
log.warn(`Overwriting existing GitHub values: ${collisions.join(', ')}`)
|
|
441
|
+
// proceed
|
|
442
|
+
}
|
|
846
443
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
+
)
|
|
857
455
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
}
|
|
456
|
+
if (!overwrite) {
|
|
457
|
+
cancelAndExit('Existing GitHub values left unchanged.')
|
|
861
458
|
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
862
461
|
|
|
863
|
-
|
|
864
|
-
|
|
462
|
+
let keyCreatedByThisRun = false
|
|
463
|
+
const managementKey = plan.createKey ? mgmtKeyResolver() : undefined
|
|
865
464
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
+
})
|
|
470
|
+
}
|
|
872
471
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
+
}
|
|
881
480
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
+
)
|
|
889
488
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
if (plan.harness === 'opencode') {
|
|
895
|
-
const workflow = await withGhRetry(
|
|
896
|
-
`Checking ${plan.repo} fro-bot.yaml wiring`,
|
|
897
|
-
async () => {
|
|
898
|
-
return checkFroBotWorkflow(plan.repo)
|
|
899
|
-
},
|
|
900
|
-
interactive,
|
|
901
|
-
)
|
|
489
|
+
await withSpinner('Verifying the new key through the proxy', async () => {
|
|
490
|
+
await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
|
|
491
|
+
})
|
|
902
492
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
`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'}.`,
|
|
931
|
-
'',
|
|
932
|
-
`Add under the 'with:' block of the 'fro-bot/agent' step:`,
|
|
933
|
-
formatWorkflowSnippet(missing),
|
|
934
|
-
].join('\n'),
|
|
935
|
-
)
|
|
936
|
-
}
|
|
937
|
-
break
|
|
938
|
-
}
|
|
939
|
-
default: {
|
|
940
|
-
const _exhaustive: never = workflow
|
|
941
|
-
throw new Error(`Unhandled FroBotWorkflowCheck kind: ${JSON.stringify(_exhaustive)}`)
|
|
942
|
-
}
|
|
943
|
-
}
|
|
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
|
+
)
|
|
501
|
+
|
|
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
|
|
944
520
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
await deleteManagementApiKey(baseUrl, managementKey, plan.keyValue)
|
|
949
|
-
log.warn('Rolled back the newly created CLIProxyAPI key after failure.')
|
|
950
|
-
} catch {
|
|
521
|
+
case 'analyzed': {
|
|
522
|
+
for (const step of workflow.stepsWithGaps) {
|
|
523
|
+
const missing = [...step.missingInputs]
|
|
951
524
|
log.warn(
|
|
952
|
-
|
|
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'),
|
|
953
534
|
)
|
|
954
535
|
}
|
|
536
|
+
break
|
|
955
537
|
}
|
|
956
|
-
|
|
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.')
|
|
549
|
+
} catch {
|
|
550
|
+
log.warn(
|
|
551
|
+
'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
throw mutationError
|
|
556
|
+
}
|
|
557
|
+
|
|
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,
|
|
957
574
|
}
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
// Machine-parseable hook for MCP/agent consumers
|
|
578
|
+
ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`)
|
|
958
579
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
|
963
588
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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)}`)
|
|
968
596
|
}
|
|
969
|
-
throw error
|
|
970
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
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
614
|
+
cli
|
|
615
|
+
.command(
|
|
616
|
+
'cliproxy setup',
|
|
617
|
+
'Interactively onboard a GitHub repository to CLIProxyAPI by creating or reusing a key and wiring the required GitHub secrets and variables.',
|
|
618
|
+
)
|
|
619
|
+
.option(
|
|
620
|
+
'--key [key]',
|
|
621
|
+
z
|
|
622
|
+
.string()
|
|
623
|
+
.describe(
|
|
624
|
+
'Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.',
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
.option(
|
|
628
|
+
'--repo [repo]',
|
|
629
|
+
z.string().describe('Target GitHub repository in owner/repo format. Skips the repository prompt when provided.'),
|
|
630
|
+
)
|
|
631
|
+
.option(
|
|
632
|
+
'--harness [harness]',
|
|
633
|
+
harnessSchema.describe(
|
|
634
|
+
'Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.',
|
|
635
|
+
),
|
|
636
|
+
)
|
|
637
|
+
.option(
|
|
638
|
+
'--providers [providers]',
|
|
639
|
+
z
|
|
640
|
+
.string()
|
|
641
|
+
.describe(
|
|
642
|
+
'Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai',
|
|
643
|
+
),
|
|
644
|
+
)
|
|
645
|
+
.option(
|
|
646
|
+
'--model [model]',
|
|
647
|
+
z
|
|
648
|
+
.string()
|
|
649
|
+
.regex(MODEL_ID_RE)
|
|
650
|
+
.describe(
|
|
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',
|
|
652
|
+
),
|
|
653
|
+
)
|
|
654
|
+
.option(
|
|
655
|
+
'--force',
|
|
656
|
+
z.boolean().optional().describe('Overwrite existing GitHub secrets and variables without prompting.'),
|
|
657
|
+
)
|
|
658
|
+
.option('--dry-run', z.boolean().optional().describe('Print the plan without applying any changes.'))
|
|
659
|
+
.option(
|
|
660
|
+
'--verify-smoke',
|
|
661
|
+
z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
|
|
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')
|
|
674
|
+
.example('# Run the interactive onboarding wizard')
|
|
675
|
+
.example('infra cliproxy setup')
|
|
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})
|
|
971
684
|
})
|
|
972
685
|
}
|