@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
package/package.json
CHANGED
|
@@ -86,6 +86,12 @@ Commands:
|
|
|
86
86
|
--key [key] Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.
|
|
87
87
|
--repo [repo] Target GitHub repository in owner/repo format. Skips the repository prompt when provided.
|
|
88
88
|
--harness [harness] Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.
|
|
89
|
+
--providers [providers] Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai
|
|
90
|
+
--model [model] Override the default model. Must be provider-prefixed and lowercase. Required when multiple providers selected. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o
|
|
91
|
+
--force Overwrite existing GitHub secrets and variables without prompting.
|
|
92
|
+
--dry-run Print the plan without applying any changes.
|
|
93
|
+
--verify-smoke Run a smoke test against the proxy after setup completes.
|
|
94
|
+
--ack-key-reuse 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. (default: false)
|
|
89
95
|
|
|
90
96
|
|
|
91
97
|
gateway status Show operational health of the gateway deployment via docker compose ps.
|
|
@@ -5,11 +5,12 @@ import {chmodSync} from 'node:fs'
|
|
|
5
5
|
|
|
6
6
|
import {z} from 'zod'
|
|
7
7
|
|
|
8
|
+
import {managementHeaders, requestJson} from './shared'
|
|
9
|
+
|
|
8
10
|
/** Minimal ctx surface consumed by cliproxy config actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
9
11
|
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
10
12
|
|
|
11
13
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
12
|
-
const HTTP_TIMEOUT_MS = 10_000
|
|
13
14
|
|
|
14
15
|
function stripTrailingSlash(value: string): string {
|
|
15
16
|
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
@@ -29,31 +30,6 @@ export function resolveManagementKey(input?: string): string {
|
|
|
29
30
|
return key
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function managementHeaders(key: string): Headers {
|
|
33
|
-
const headers = new Headers()
|
|
34
|
-
headers.set('x-management-key', key)
|
|
35
|
-
headers.set('content-type', 'application/json')
|
|
36
|
-
return headers
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
|
|
40
|
-
const response = await fetch(endpoint, {
|
|
41
|
-
...init,
|
|
42
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
if (!response.ok) {
|
|
46
|
-
const body = await response.text()
|
|
47
|
-
throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
return await response.json()
|
|
52
|
-
} catch {
|
|
53
|
-
return null
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
33
|
export function parseBoolean(value: string): boolean {
|
|
58
34
|
const normalized = value.toLowerCase()
|
|
59
35
|
if (normalized === 'true') {
|
|
@@ -4,11 +4,14 @@ import type {ActionCtx} from '../../lib/action-ctx'
|
|
|
4
4
|
|
|
5
5
|
import {z} from 'zod'
|
|
6
6
|
|
|
7
|
+
import {managementHeaders, parseManagementKeyList, requestJson, toStringArray} from './shared'
|
|
8
|
+
|
|
9
|
+
export {toStringArray} from './shared'
|
|
10
|
+
|
|
7
11
|
/** Minimal ctx surface consumed by cliproxy keys actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
8
12
|
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
9
13
|
|
|
10
14
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
11
|
-
const HTTP_TIMEOUT_MS = 10_000
|
|
12
15
|
|
|
13
16
|
function stripTrailingSlash(value: string): string {
|
|
14
17
|
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
@@ -28,47 +31,6 @@ function resolveManagementKey(input?: string): string {
|
|
|
28
31
|
return key
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
function managementHeaders(key: string): Headers {
|
|
32
|
-
const headers = new Headers()
|
|
33
|
-
headers.set('x-management-key', key)
|
|
34
|
-
headers.set('content-type', 'application/json')
|
|
35
|
-
return headers
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
|
|
39
|
-
const response = await fetch(endpoint, {
|
|
40
|
-
...init,
|
|
41
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
if (!response.ok) {
|
|
45
|
-
const body = await response.text()
|
|
46
|
-
throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
return await response.json()
|
|
51
|
-
} catch {
|
|
52
|
-
return null
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function toStringArray(payload: unknown): string[] {
|
|
57
|
-
if (Array.isArray(payload)) {
|
|
58
|
-
return payload.filter(item => typeof item === 'string')
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (payload && typeof payload === 'object') {
|
|
62
|
-
const obj = payload as Record<string, unknown>
|
|
63
|
-
const value = obj['api-keys'] ?? obj.api_keys
|
|
64
|
-
if (Array.isArray(value)) {
|
|
65
|
-
return value.filter(item => typeof item === 'string')
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return []
|
|
70
|
-
}
|
|
71
|
-
|
|
72
34
|
export interface KeysListOptions {
|
|
73
35
|
url?: string
|
|
74
36
|
key?: string
|
|
@@ -126,7 +88,10 @@ export async function cliproxyKeysAddAction(
|
|
|
126
88
|
method: 'GET',
|
|
127
89
|
headers: managementHeaders(managementKey),
|
|
128
90
|
})
|
|
129
|
-
|
|
91
|
+
// Strict parse: a malformed or unexpected GET response must fail closed before the
|
|
92
|
+
// destructive PUT replaces the entire key list. Permissive parsing would collapse
|
|
93
|
+
// unknown shapes to [] and overwrite existing keys.
|
|
94
|
+
const currentKeys = parseManagementKeyList(currentPayload)
|
|
130
95
|
|
|
131
96
|
if (currentKeys.includes(apiKeyToAdd)) {
|
|
132
97
|
ctx.console.log('Key already present; no update required.')
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
import {afterEach, describe, expect, it, mock, spyOn} from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import {isGhRateLimitError, withGhRetry} from './gh'
|
|
6
|
+
|
|
7
|
+
// ─── isGhRateLimitError ──────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
describe('isGhRateLimitError', () => {
|
|
10
|
+
it('returns true when text contains "rate limit"', () => {
|
|
11
|
+
expect(isGhRateLimitError('API rate limit exceeded')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('is case-insensitive', () => {
|
|
15
|
+
expect(isGhRateLimitError('You have exceeded a secondary RATE LIMIT')).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns false for unrelated error messages', () => {
|
|
19
|
+
expect(isGhRateLimitError('Not Found (HTTP 404)')).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns false for an empty string', () => {
|
|
23
|
+
expect(isGhRateLimitError('')).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns false for a connection timeout', () => {
|
|
27
|
+
expect(isGhRateLimitError('connection timeout')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// ─── withGhRetry ─────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
describe('withGhRetry', () => {
|
|
34
|
+
it('returns the value when fn succeeds immediately', async () => {
|
|
35
|
+
const result = await withGhRetry('test label', async () => 'ok', false)
|
|
36
|
+
|
|
37
|
+
expect(result).toBe('ok')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('re-throws non-rate-limit errors without querying the reset time', async () => {
|
|
41
|
+
const queryReset = async (): Promise<string> => {
|
|
42
|
+
throw new Error('queryReset should not have been called')
|
|
43
|
+
}
|
|
44
|
+
const err = new Error('some other error')
|
|
45
|
+
|
|
46
|
+
await expect(withGhRetry('test label', async () => Promise.reject(err), false, queryReset)).rejects.toThrow(
|
|
47
|
+
'some other error',
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('re-throws with reset time appended in non-interactive mode on rate limit', async () => {
|
|
52
|
+
const queryReset = async (): Promise<string> => '2:30 PM'
|
|
53
|
+
|
|
54
|
+
await expect(
|
|
55
|
+
withGhRetry(
|
|
56
|
+
'test label',
|
|
57
|
+
async () => {
|
|
58
|
+
throw new Error('API rate limit exceeded for url')
|
|
59
|
+
},
|
|
60
|
+
false,
|
|
61
|
+
queryReset,
|
|
62
|
+
),
|
|
63
|
+
).rejects.toThrow('resets at 2:30 PM')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// ─── applyGhValue stdin-pipe-not-body invariant (PR #102) ────────────────────
|
|
68
|
+
|
|
69
|
+
describe('applyGhValue', () => {
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
mock.restore()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('pipes secret value via stdin — never uses --body flag', async () => {
|
|
75
|
+
const {applyGhValue} = await import('./gh')
|
|
76
|
+
|
|
77
|
+
let capturedArgs: string[] = []
|
|
78
|
+
let capturedStdin: ReadableStream<Uint8Array> | undefined
|
|
79
|
+
|
|
80
|
+
const spawnSpy = spyOn(Bun, 'spawn').mockImplementation(((cmds: string[], opts?: {stdin?: unknown}) => {
|
|
81
|
+
capturedArgs = cmds
|
|
82
|
+
capturedStdin = opts?.stdin as ReadableStream<Uint8Array> | undefined
|
|
83
|
+
// Return a minimal fake child process
|
|
84
|
+
return {
|
|
85
|
+
stdout: new Response('').body,
|
|
86
|
+
stderr: new Response('').body,
|
|
87
|
+
exited: Promise.resolve(0),
|
|
88
|
+
stdin: null,
|
|
89
|
+
pid: 0,
|
|
90
|
+
killed: false,
|
|
91
|
+
exitCode: 0,
|
|
92
|
+
signalCode: null,
|
|
93
|
+
kill: () => {},
|
|
94
|
+
ref: () => {},
|
|
95
|
+
unref: () => {},
|
|
96
|
+
readable: new Response('').body,
|
|
97
|
+
}
|
|
98
|
+
}) as unknown as typeof Bun.spawn)
|
|
99
|
+
|
|
100
|
+
await applyGhValue('secret', 'MY_SECRET', 'owner/repo', 'my-value')
|
|
101
|
+
|
|
102
|
+
expect(capturedArgs).toContain('gh')
|
|
103
|
+
expect(capturedArgs).toContain('secret')
|
|
104
|
+
expect(capturedArgs).toContain('set')
|
|
105
|
+
expect(capturedArgs).toContain('MY_SECRET')
|
|
106
|
+
expect(capturedArgs).toContain('--repo')
|
|
107
|
+
expect(capturedArgs).toContain('owner/repo')
|
|
108
|
+
// The critical invariant: --body must NOT be in the args
|
|
109
|
+
expect(capturedArgs).not.toContain('--body')
|
|
110
|
+
// stdin must be provided (value piped via stdin)
|
|
111
|
+
expect(capturedStdin).toBeDefined()
|
|
112
|
+
|
|
113
|
+
spawnSpy.mockRestore()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ─── createManagementApiKey / deleteManagementApiKey toStringArray parity ─────
|
|
118
|
+
|
|
119
|
+
describe('management API key helpers — response shape handling', () => {
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
mock.restore()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('createManagementApiKey handles top-level array response from GET', async () => {
|
|
125
|
+
const {createManagementApiKey} = await import('./gh')
|
|
126
|
+
|
|
127
|
+
const calls: {method: string; body?: string}[] = []
|
|
128
|
+
globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
|
|
129
|
+
const method = init?.method ?? 'GET'
|
|
130
|
+
calls.push({method, body: init?.body as string | undefined})
|
|
131
|
+
if (method === 'GET') {
|
|
132
|
+
// Top-level array response
|
|
133
|
+
return new Response(JSON.stringify(['existing-key']), {status: 200})
|
|
134
|
+
}
|
|
135
|
+
return new Response('{}', {status: 200})
|
|
136
|
+
}) as unknown as typeof fetch
|
|
137
|
+
|
|
138
|
+
await createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')
|
|
139
|
+
|
|
140
|
+
const putCall = calls.find(c => c.method === 'PUT')
|
|
141
|
+
expect(putCall).toBeDefined()
|
|
142
|
+
const body = JSON.parse(putCall?.body ?? '[]') as string[]
|
|
143
|
+
expect(body).toContain('existing-key')
|
|
144
|
+
expect(body).toContain('new-key')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('createManagementApiKey handles object-shaped {api-keys:[...]} response from GET', async () => {
|
|
148
|
+
const {createManagementApiKey} = await import('./gh')
|
|
149
|
+
|
|
150
|
+
const calls: {method: string; body?: string}[] = []
|
|
151
|
+
globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
|
|
152
|
+
const method = init?.method ?? 'GET'
|
|
153
|
+
calls.push({method, body: init?.body as string | undefined})
|
|
154
|
+
if (method === 'GET') {
|
|
155
|
+
// Object-shaped response — the form CLIProxyAPI actually returns
|
|
156
|
+
return new Response(JSON.stringify({'api-keys': ['existing-key']}), {status: 200})
|
|
157
|
+
}
|
|
158
|
+
return new Response('{}', {status: 200})
|
|
159
|
+
}) as unknown as typeof fetch
|
|
160
|
+
|
|
161
|
+
await createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')
|
|
162
|
+
|
|
163
|
+
const putCall = calls.find(c => c.method === 'PUT')
|
|
164
|
+
expect(putCall).toBeDefined()
|
|
165
|
+
const body = JSON.parse(putCall?.body ?? '[]') as string[]
|
|
166
|
+
expect(body).toContain('existing-key')
|
|
167
|
+
expect(body).toContain('new-key')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('createManagementApiKey THROWS without making a destructive PUT when GET returns unexpected payload shape', async () => {
|
|
171
|
+
const {createManagementApiKey} = await import('./gh')
|
|
172
|
+
|
|
173
|
+
// Mock fetch: GET returns 200 with payload `null` (valid JSON but unexpected shape).
|
|
174
|
+
// Pre-fix silent-failure path: requestJson would have returned null, toStringArray(null)
|
|
175
|
+
// would have collapsed to [], the PUT would have replaced the entire key list with
|
|
176
|
+
// just the new key — deleting all existing repo keys.
|
|
177
|
+
// Post-fix: parseManagementKeyList throws on null, the PUT is never made.
|
|
178
|
+
let putCallCount = 0
|
|
179
|
+
globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
|
|
180
|
+
const method = init?.method ?? 'GET'
|
|
181
|
+
if (method === 'PUT') putCallCount++
|
|
182
|
+
if (method === 'GET') {
|
|
183
|
+
return new Response(JSON.stringify(null), {
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: {'content-type': 'application/json'},
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
return new Response('{}', {status: 200})
|
|
189
|
+
}) as unknown as typeof fetch
|
|
190
|
+
|
|
191
|
+
await expect(createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')).rejects.toThrow(
|
|
192
|
+
/Unexpected management key-list shape/,
|
|
193
|
+
)
|
|
194
|
+
expect(putCallCount).toBe(0)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('createManagementApiKey THROWS without PUT when GET returns malformed JSON', async () => {
|
|
198
|
+
const {createManagementApiKey} = await import('./gh')
|
|
199
|
+
|
|
200
|
+
let putCallCount = 0
|
|
201
|
+
globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
|
|
202
|
+
const method = init?.method ?? 'GET'
|
|
203
|
+
if (method === 'PUT') putCallCount++
|
|
204
|
+
if (method === 'GET') {
|
|
205
|
+
return new Response('not-json-content', {
|
|
206
|
+
status: 200,
|
|
207
|
+
headers: {'content-type': 'text/plain'},
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
return new Response('{}', {status: 200})
|
|
211
|
+
}) as unknown as typeof fetch
|
|
212
|
+
|
|
213
|
+
await expect(createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')).rejects.toThrow(
|
|
214
|
+
/returned malformed JSON/,
|
|
215
|
+
)
|
|
216
|
+
expect(putCallCount).toBe(0)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/// <reference types="bun" />
|
|
2
|
+
|
|
3
|
+
import type {SpinnerResult} from '@clack/prompts'
|
|
4
|
+
|
|
5
|
+
import {confirm, log, spinner} from '@clack/prompts'
|
|
6
|
+
|
|
7
|
+
import {managementHeaders, parseManagementKeyList, requestJson} from '../shared'
|
|
8
|
+
import {cancelAndExit, promptValue} from './prompts'
|
|
9
|
+
|
|
10
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface CommandResult {
|
|
13
|
+
stdout: string
|
|
14
|
+
stderr: string
|
|
15
|
+
exitCode: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ─── Local helpers ────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Local copy — avoids a circular import with setup.ts (gh → setup → gh). */
|
|
21
|
+
function extractErrorMessage(error: unknown): string {
|
|
22
|
+
return error instanceof Error ? error.message : String(error)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Spawn helpers ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export async function withSpinner<T>(message: string, run: (spinnerInstance: SpinnerResult) => Promise<T>): Promise<T> {
|
|
28
|
+
const spinnerInstance = spinner()
|
|
29
|
+
spinnerInstance.start(message)
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const result = await run(spinnerInstance)
|
|
33
|
+
spinnerInstance.stop(message)
|
|
34
|
+
return result
|
|
35
|
+
} catch (error) {
|
|
36
|
+
spinnerInstance.error(`${message} failed`)
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function runCommand(command: string, args: string[]): Promise<CommandResult> {
|
|
42
|
+
const child = Bun.spawn([command, ...args], {
|
|
43
|
+
stdout: 'pipe',
|
|
44
|
+
stderr: 'pipe',
|
|
45
|
+
env: process.env,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
49
|
+
new Response(child.stdout).text(),
|
|
50
|
+
new Response(child.stderr).text(),
|
|
51
|
+
child.exited,
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
return {stdout, stderr, exitCode}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runGh(args: string[]): Promise<CommandResult> {
|
|
58
|
+
return runCommand('gh', args)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Rate-limit helpers ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export function isGhRateLimitError(text: string): boolean {
|
|
64
|
+
return /rate limit/i.test(text)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
|
|
69
|
+
* exempt from rate limiting itself, so this should succeed even when the
|
|
70
|
+
* primary GraphQL limit is exhausted. Returns a formatted local time string
|
|
71
|
+
* or a fallback phrase when the endpoint is unreachable.
|
|
72
|
+
*/
|
|
73
|
+
async function queryRateLimitReset(): Promise<string> {
|
|
74
|
+
try {
|
|
75
|
+
const result = await runGh(['api', 'rate_limit'])
|
|
76
|
+
if (result.exitCode === 0) {
|
|
77
|
+
const parsed = JSON.parse(result.stdout) as {
|
|
78
|
+
resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
|
|
79
|
+
}
|
|
80
|
+
const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
|
|
81
|
+
if (reset) {
|
|
82
|
+
return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Fall through to generic phrase
|
|
87
|
+
}
|
|
88
|
+
return 'an unknown time'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
|
|
93
|
+
* rate-limit errors when in interactive mode. In non-interactive mode the
|
|
94
|
+
* error is re-thrown with the reset time appended so the caller can surface
|
|
95
|
+
* it without prompting.
|
|
96
|
+
*/
|
|
97
|
+
export async function withGhRetry<T>(
|
|
98
|
+
label: string,
|
|
99
|
+
fn: (spinnerInstance: SpinnerResult) => Promise<T>,
|
|
100
|
+
interactive: boolean,
|
|
101
|
+
queryReset: () => Promise<string> = queryRateLimitReset,
|
|
102
|
+
): Promise<T> {
|
|
103
|
+
for (;;) {
|
|
104
|
+
try {
|
|
105
|
+
return await withSpinner(label, fn)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = extractErrorMessage(error)
|
|
108
|
+
if (!isGhRateLimitError(message)) {
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
const reset = await queryReset()
|
|
112
|
+
if (!interactive) {
|
|
113
|
+
throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
|
|
114
|
+
}
|
|
115
|
+
log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
|
|
116
|
+
const retry = await promptValue(
|
|
117
|
+
confirm({
|
|
118
|
+
message: 'Retry this step when ready?',
|
|
119
|
+
active: 'retry',
|
|
120
|
+
inactive: 'abort',
|
|
121
|
+
initialValue: true,
|
|
122
|
+
}),
|
|
123
|
+
'Setup aborted after rate limit.',
|
|
124
|
+
)
|
|
125
|
+
if (!retry) {
|
|
126
|
+
cancelAndExit('Setup aborted after GitHub API rate limit.')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Preflight assertions ─────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export async function assertGhInstalled(): Promise<void> {
|
|
135
|
+
if (!Bun.which('gh')) {
|
|
136
|
+
throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function assertGhAuthenticated(): Promise<void> {
|
|
141
|
+
const result = await runGh(['auth', 'status'])
|
|
142
|
+
if (result.exitCode !== 0) {
|
|
143
|
+
throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim())
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function assertRepoAccess(repo: string): Promise<void> {
|
|
148
|
+
const {z} = await import('zod')
|
|
149
|
+
const ghRepoViewSchema = z.object({
|
|
150
|
+
nameWithOwner: z.string(),
|
|
151
|
+
viewerPermission: z.string(),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const result = await runGh(['repo', 'view', repo, '--json', 'nameWithOwner,viewerPermission'])
|
|
155
|
+
if (result.exitCode !== 0) {
|
|
156
|
+
throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim())
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout))
|
|
160
|
+
const writePermissions = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
|
|
161
|
+
|
|
162
|
+
if (!writePermissions.has(parsed.viewerPermission)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): Promise<string[]> {
|
|
170
|
+
const {z} = await import('zod')
|
|
171
|
+
const ghNameListSchema = z.array(z.object({name: z.string()}))
|
|
172
|
+
|
|
173
|
+
const result = await runGh([kind, 'list', '--repo', repo, '--json', 'name'])
|
|
174
|
+
if (result.exitCode !== 0) {
|
|
175
|
+
throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim())
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Management API key helpers ───────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export async function createManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
|
|
184
|
+
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
185
|
+
const currentPayload = await requestJson(endpoint, {
|
|
186
|
+
method: 'GET',
|
|
187
|
+
headers: managementHeaders(managementKey),
|
|
188
|
+
})
|
|
189
|
+
const currentKeys = parseManagementKeyList(currentPayload)
|
|
190
|
+
|
|
191
|
+
if (currentKeys.includes(keyValue)) {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await requestJson(endpoint, {
|
|
196
|
+
method: 'PUT',
|
|
197
|
+
headers: managementHeaders(managementKey),
|
|
198
|
+
body: JSON.stringify([...currentKeys, keyValue]),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function deleteManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
|
|
203
|
+
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
204
|
+
const currentPayload = await requestJson(endpoint, {
|
|
205
|
+
method: 'GET',
|
|
206
|
+
headers: managementHeaders(managementKey),
|
|
207
|
+
})
|
|
208
|
+
const currentKeys = parseManagementKeyList(currentPayload)
|
|
209
|
+
const filtered = currentKeys.filter(k => k !== keyValue)
|
|
210
|
+
|
|
211
|
+
if (filtered.length === currentKeys.length) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await requestJson(endpoint, {
|
|
216
|
+
method: 'PUT',
|
|
217
|
+
headers: managementHeaders(managementKey),
|
|
218
|
+
body: JSON.stringify(filtered),
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── GitHub value application ─────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export async function applyGhValue(
|
|
225
|
+
kind: 'secret' | 'variable',
|
|
226
|
+
name: string,
|
|
227
|
+
repo: string,
|
|
228
|
+
value: string,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
if (kind === 'secret') {
|
|
231
|
+
const child = Bun.spawn(['gh', 'secret', 'set', name, '--repo', repo], {
|
|
232
|
+
stdin: new Blob([value]).stream(),
|
|
233
|
+
stdout: 'pipe',
|
|
234
|
+
stderr: 'pipe',
|
|
235
|
+
env: process.env,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
|
|
239
|
+
|
|
240
|
+
if (exitCode !== 0) {
|
|
241
|
+
throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim())
|
|
242
|
+
}
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = await runGh([kind, 'set', name, '--repo', repo, '--body', value])
|
|
247
|
+
if (result.exitCode !== 0) {
|
|
248
|
+
throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
|
|
249
|
+
}
|
|
250
|
+
}
|