@marcusrbrown/infra 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +11965 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,809 +0,0 @@
1
- /// <reference types="bun" />
2
-
3
- import type {goke} from 'goke'
4
-
5
- import type {ActionCtx} from '../../lib/action-ctx'
6
-
7
- import {cancel, confirm, intro, log, note, outro, select, text} from '@clack/prompts'
8
- import {z} from 'zod'
9
-
10
- import {resolveManagementKey} from './config'
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'
46
-
47
- const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
48
-
49
- export interface SetupOptions {
50
- key?: string
51
- repo?: string
52
- harness?: Harness
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
60
- }
61
-
62
- interface SetupPlan {
63
- repo: string
64
- harness: Harness
65
- keyValue: string
66
- keyName?: string
67
- createKey: boolean
68
- template: HarnessTemplate
69
- }
70
-
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
- }
104
- }
105
-
106
- function resolveBaseUrl(input?: string): string {
107
- return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
108
- }
109
-
110
- /**
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.
114
- */
115
- function safeWarn(message: string): void {
116
- try {
117
- log.warn(message)
118
- } catch {
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
- )
137
- }
138
-
139
- /**
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.
143
- *
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).
147
- *
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.
152
- */
153
- async function verifyWrittenNamesVisible(
154
- repo: string,
155
- writtenSecretNames: string[],
156
- writtenVariableNames: string[],
157
- listExistingGhNames: typeof import('./setup/gh').listExistingGhNames,
158
- ): Promise<void> {
159
- try {
160
- let secretReadback: string[]
161
- let variableReadback: string[]
162
- let readbackFailed = false
163
-
164
- try {
165
- secretReadback = await listExistingGhNames(repo, 'secret')
166
- } catch {
167
- readbackFailed = true
168
- secretReadback = []
169
- }
170
-
171
- if (readbackFailed) {
172
- variableReadback = []
173
- } else {
174
- try {
175
- variableReadback = await listExistingGhNames(repo, 'variable')
176
- } catch {
177
- readbackFailed = true
178
- variableReadback = []
179
- }
180
- }
181
-
182
- if (readbackFailed) {
183
- safeWarn(buildCannotVerifyMessage(repo, writtenSecretNames, writtenVariableNames))
184
- return
185
- }
186
-
187
- const absentSecrets = writtenSecretNames.filter(name => !secretReadback.includes(name))
188
- const absentVariables = writtenVariableNames.filter(name => !variableReadback.includes(name))
189
-
190
- if (absentSecrets.length === 0 && absentVariables.length === 0) {
191
- // Happy path: all written names are visible. Emit nothing.
192
- return
193
- }
194
-
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
- ]
199
-
200
- if (absentSecrets.length > 0) {
201
- lines.push(` Absent secrets: ${absentSecrets.join(', ')}`)
202
- lines.push(` Verify manually: gh secret list --repo ${repo}`)
203
- }
204
-
205
- if (absentVariables.length > 0) {
206
- lines.push(` Absent variables: ${absentVariables.join(', ')}`)
207
- lines.push(` Verify manually: gh variable list --repo ${repo}`)
208
- }
209
-
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
- }
216
- }
217
-
218
- function extractErrorMessage(error: unknown): string {
219
- return error instanceof Error ? error.message : String(error)
220
- }
221
-
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)}`
228
- }
229
-
230
- async function buildInteractivePlan(
231
- options: SetupOptions,
232
- baseUrl: string,
233
- promptsImpl: Required<RunSetupDeps>['prompts'],
234
- ): Promise<SetupPlan> {
235
- const createKey = !options.key
236
- const keyName = createKey
237
- ? await promptsImpl.promptValue(
238
- text({
239
- message: 'Name this new CLIProxyAPI key',
240
- placeholder: 'my-repo-ci',
241
- validate: value => ((value ?? '').trim().length > 0 ? undefined : 'Key name is required.'),
242
- }),
243
- 'Setup cancelled before naming the key.',
244
- )
245
- : undefined
246
-
247
- const harness =
248
- options.harness ??
249
- (await promptsImpl.promptValue(
250
- select<Harness>({
251
- message: 'Choose the harness to configure',
252
- options: [
253
- {value: 'opencode', label: 'OpenCode'},
254
- {value: 'claude-code', label: 'Claude Code'},
255
- {value: 'generic', label: 'Generic'},
256
- ],
257
- }),
258
- 'Setup cancelled before selecting a harness.',
259
- ))
260
-
261
- const repo = options.repo
262
- ? ensureRepoFormat(options.repo)
263
- : ensureRepoFormat(
264
- await promptsImpl.promptValue(
265
- text({
266
- message: 'Target GitHub repository',
267
- placeholder: 'owner/repo',
268
- validate: value => {
269
- try {
270
- ensureRepoFormat(value ?? '')
271
- return undefined
272
- } catch (error) {
273
- return extractErrorMessage(error)
274
- }
275
- },
276
- }),
277
- 'Setup cancelled before choosing a repository.',
278
- ),
279
- )
280
-
281
- let providers: ProviderId[] | undefined
282
- let model: string | undefined
283
-
284
- if (harness === 'opencode') {
285
- const doPromptForProviders = promptsImpl.promptForProviders ?? promptForProviders
286
- const doPromptForModel = promptsImpl.promptForModel ?? promptForModel
287
- providers = await doPromptForProviders()
288
- model = await doPromptForModel(providers)
289
- }
290
-
291
- const keyValue = options.key ?? buildApiKeyValue(keyName ?? 'cliproxy')
292
- const genericSecretNames = harness === 'generic' ? await promptGenericSecretNames() : undefined
293
-
294
- return {
295
- repo,
296
- harness,
297
- keyValue,
298
- keyName,
299
- createKey,
300
- template: getHarnessTemplate(harness, {keyValue, baseUrl, genericSecretNames, providers, model}),
301
- }
302
- }
303
-
304
- /**
305
- * Returns true when the provider list includes anything beyond anthropic-only.
306
- * Anthropic-only repos see no behavior change (G7 invariant).
307
- */
308
- export function requiresDestructiveProviderChangeConfirmation(providers: ProviderId[]): boolean {
309
- return !(providers.length === 1 && providers[0] === 'anthropic')
310
- }
311
-
312
- // Deprecated: use requiresDestructiveProviderChangeConfirmation. Will be removed in a future major.
313
- export const mustConfirmDestructive = requiresDestructiveProviderChangeConfirmation
314
-
315
- export async function buildNonInteractivePlan(
316
- options: SetupOptions,
317
- baseUrl: string,
318
- deps?: Pick<RunSetupDeps, 'validation'>,
319
- ): Promise<SetupPlan> {
320
- const harness = harnessSchema.parse(options.harness)
321
- const repo = ensureRepoFormat(options.repo ?? '')
322
- const keyValue = options.key ?? ''
323
-
324
- const providers: ProviderId[] = options.providers ? parseProviders(options.providers) : ['anthropic']
325
-
326
- let model: string
327
- if (options.model) {
328
- model = options.model
329
- } else if (providers.length === 1) {
330
- model = PROVIDER_DEFAULTS[providers[0] as ProviderId]
331
- } else {
332
- // Unreachable: validateSetupOptions enforces model when providers.length > 1
333
- throw new Error('Pass --model <provider/model-id> when selecting multiple providers.')
334
- }
335
-
336
- // --dry-run: skip verifyModelsAvailable and force check; return plan for preview
337
- if (options.dryRun) {
338
- return {
339
- repo,
340
- harness,
341
- keyValue,
342
- createKey: false,
343
- template: getHarnessTemplate(harness, {keyValue: keyValue || 'sk-placeholder', baseUrl, providers, model}),
344
- }
345
- }
346
-
347
- const verifyModels = deps?.validation?.verifyModelsAvailable ?? verifyModelsAvailable
348
-
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) {
352
- throw new Error(
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, 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).`,
354
- )
355
- }
356
-
357
- await verifyModels(baseUrl, keyValue, providers, model)
358
-
359
- return {
360
- repo,
361
- harness,
362
- keyValue,
363
- createKey: false,
364
- template: getHarnessTemplate(harness, {keyValue, baseUrl, providers, model}),
365
- }
366
- }
367
-
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)
452
-
453
- if (interactive) {
454
- prompts.intro('CLIProxyAPI setup wizard')
455
- }
456
-
457
- try {
458
- await withSpinner('Checking GitHub CLI availability', async () => {
459
- await gh.assertGhInstalled()
460
- await gh.assertGhAuthenticated()
461
- })
462
-
463
- await withSpinner('Checking CLIProxyAPI reachability', async () => {
464
- await validation.assertProxyReachable(baseUrl)
465
- })
466
-
467
- const plan = interactive
468
- ? await buildInteractivePlan(options, baseUrl, prompts)
469
- : await buildNonInteractivePlan(options, baseUrl, {validation})
470
-
471
- if (plan.createKey) {
472
- mgmtKeyResolver()
473
- }
474
-
475
- await gh.withGhRetry(
476
- `Checking GitHub access for ${plan.repo}`,
477
- async () => {
478
- await gh.assertRepoAccess(plan.repo)
479
- },
480
- interactive,
481
- )
482
-
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,
520
- )
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
- )
541
- }
542
- }
543
-
544
- const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
545
-
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
- }
552
-
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
- }
560
-
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
- )
572
-
573
- if (!overwrite) {
574
- cancelAndExit('Existing GitHub values left unchanged.')
575
- }
576
- }
577
- }
578
-
579
- let keyCreatedByThisRun = false
580
- const managementKey = plan.createKey ? mgmtKeyResolver() : undefined
581
-
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
- })
587
- }
588
-
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
- )
605
-
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
- )
612
-
613
- await withSpinner('Verifying the new key through the proxy', async () => {
614
- await validation.assertProxyKeyWorks(baseUrl, plan.keyValue)
615
- })
616
-
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)}`)
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.')
673
- } catch {
674
- log.warn(
675
- 'Failed to roll back the newly created CLIProxyAPI key. Remove it manually via: infra cliproxy keys remove',
676
- )
677
- }
678
- }
679
- throw mutationError
680
- }
681
-
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
- })
700
-
701
- // Machine-parseable hook for MCP/agent consumers
702
- ctx.console.log(`[smoke-test] kind=${smokeResult.kind}`)
703
-
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
734
- }
735
- }
736
-
737
- export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
738
- cli
739
- .command(
740
- 'cliproxy setup',
741
- 'Interactively onboard a GitHub repository to CLIProxyAPI by creating or reusing a key and wiring the required GitHub secrets and variables.',
742
- )
743
- .option(
744
- '--key [key]',
745
- z
746
- .string()
747
- .describe(
748
- 'Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.',
749
- ),
750
- )
751
- .option(
752
- '--repo [repo]',
753
- z.string().describe('Target GitHub repository in owner/repo format. Skips the repository prompt when provided.'),
754
- )
755
- .option(
756
- '--harness [harness]',
757
- harnessSchema.describe(
758
- 'Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.',
759
- ),
760
- )
761
- .option(
762
- '--providers [providers]',
763
- z
764
- .string()
765
- .describe(
766
- 'Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai',
767
- ),
768
- )
769
- .option(
770
- '--model [model]',
771
- z
772
- .string()
773
- .regex(MODEL_ID_RE)
774
- .describe(
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',
776
- ),
777
- )
778
- .option(
779
- '--force',
780
- z.boolean().optional().describe('Overwrite existing GitHub secrets and variables without prompting.'),
781
- )
782
- .option('--dry-run', z.boolean().optional().describe('Print the plan without applying any changes.'))
783
- .option(
784
- '--verify-smoke',
785
- z.boolean().optional().describe('Run a smoke test against the proxy after setup completes.'),
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')
798
- .example('# Run the interactive onboarding wizard')
799
- .example('infra cliproxy setup')
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})
808
- })
809
- }