@marcusrbrown/infra 0.13.0 → 0.13.2
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/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,250 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/// <reference types="bun" />
|
|
2
|
-
|
|
3
|
-
import {describe, expect, it} from 'bun:test'
|
|
4
|
-
|
|
5
|
-
import {formatDryRunPreview} from './preview'
|
|
6
|
-
import {getHarnessTemplate} from './templates'
|
|
7
|
-
|
|
8
|
-
const KEY = 'sk-placeholder'
|
|
9
|
-
const BASE_URL = 'https://cliproxy.fro.bot'
|
|
10
|
-
|
|
11
|
-
describe('formatDryRunPreview', () => {
|
|
12
|
-
it('renders the dry-run header with repo and providers', () => {
|
|
13
|
-
const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
14
|
-
const preview = formatDryRunPreview({
|
|
15
|
-
repo: 'owner/repo',
|
|
16
|
-
harness: 'opencode',
|
|
17
|
-
providers: ['anthropic'],
|
|
18
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
19
|
-
template,
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
expect(preview).toContain('Dry run: cliproxy setup --harness opencode')
|
|
23
|
-
expect(preview).toContain('Repository: owner/repo')
|
|
24
|
-
expect(preview).toContain('Providers: anthropic')
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('renders planned secrets with byte sizes', () => {
|
|
28
|
-
const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
29
|
-
const preview = formatDryRunPreview({
|
|
30
|
-
repo: 'owner/repo',
|
|
31
|
-
harness: 'opencode',
|
|
32
|
-
providers: ['anthropic'],
|
|
33
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
34
|
-
template,
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
expect(preview).toContain('Planned secrets:')
|
|
38
|
-
expect(preview).toContain('OPENCODE_AUTH_JSON')
|
|
39
|
-
expect(preview).toContain('OPENCODE_CONFIG')
|
|
40
|
-
expect(preview).not.toContain('OMO_PROVIDERS')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('renders planned variables', () => {
|
|
44
|
-
const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
45
|
-
const preview = formatDryRunPreview({
|
|
46
|
-
repo: 'owner/repo',
|
|
47
|
-
harness: 'opencode',
|
|
48
|
-
providers: ['anthropic'],
|
|
49
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
50
|
-
template,
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
expect(preview).toContain('Planned variables:')
|
|
54
|
-
expect(preview).toContain('FRO_BOT_MODEL')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('renders proxy key as <proxy-key> placeholder, NOT the actual key value', () => {
|
|
58
|
-
const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
59
|
-
const preview = formatDryRunPreview({
|
|
60
|
-
repo: 'owner/repo',
|
|
61
|
-
harness: 'opencode',
|
|
62
|
-
providers: ['anthropic'],
|
|
63
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
64
|
-
template,
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
expect(preview).toContain('<proxy-key>')
|
|
68
|
-
expect(preview).not.toContain(KEY)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('renders "No mutations will be performed." footer', () => {
|
|
72
|
-
const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
73
|
-
const preview = formatDryRunPreview({
|
|
74
|
-
repo: 'owner/repo',
|
|
75
|
-
harness: 'opencode',
|
|
76
|
-
providers: ['anthropic'],
|
|
77
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
78
|
-
template,
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
expect(preview).toContain('No mutations will be performed.')
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('dual-provider preview lists both providers', () => {
|
|
85
|
-
const template = getHarnessTemplate('opencode', {
|
|
86
|
-
keyValue: KEY,
|
|
87
|
-
baseUrl: BASE_URL,
|
|
88
|
-
providers: ['anthropic', 'openai'],
|
|
89
|
-
model: 'openai/gpt-5.4-mini',
|
|
90
|
-
})
|
|
91
|
-
const preview = formatDryRunPreview({
|
|
92
|
-
repo: 'owner/repo',
|
|
93
|
-
harness: 'opencode',
|
|
94
|
-
providers: ['anthropic', 'openai'],
|
|
95
|
-
model: 'openai/gpt-5.4-mini',
|
|
96
|
-
template,
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
expect(preview).toContain('anthropic')
|
|
100
|
-
expect(preview).toContain('openai')
|
|
101
|
-
expect(preview).not.toContain(KEY)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('secret values in preview do NOT contain the actual key value', () => {
|
|
105
|
-
// Even if the template has the key embedded in JSON, the preview must redact it
|
|
106
|
-
const template = getHarnessTemplate('opencode', {keyValue: KEY, baseUrl: BASE_URL})
|
|
107
|
-
const preview = formatDryRunPreview({
|
|
108
|
-
repo: 'owner/repo',
|
|
109
|
-
harness: 'opencode',
|
|
110
|
-
providers: ['anthropic'],
|
|
111
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
112
|
-
template,
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
// The actual key must not appear anywhere in the preview output
|
|
116
|
-
expect(preview).not.toContain(KEY)
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
// ── byte-count contract tests ────────────────────
|
|
120
|
-
|
|
121
|
-
it('anthropic-only: OPENCODE_AUTH_JSON is 51 bytes with sk-placeholder key', () => {
|
|
122
|
-
const template = getHarnessTemplate('opencode', {
|
|
123
|
-
keyValue: KEY,
|
|
124
|
-
baseUrl: BASE_URL,
|
|
125
|
-
providers: ['anthropic'],
|
|
126
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
127
|
-
})
|
|
128
|
-
const preview = formatDryRunPreview({
|
|
129
|
-
repo: 'owner/repo',
|
|
130
|
-
harness: 'opencode',
|
|
131
|
-
providers: ['anthropic'],
|
|
132
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
133
|
-
template,
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
expect(preview).toContain('Providers: anthropic')
|
|
137
|
-
expect(preview).toContain('OPENCODE_AUTH_JSON (51 bytes)')
|
|
138
|
-
expect(preview).toContain('<proxy-key>')
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
it('dual-provider: OPENCODE_AUTH_JSON is 98 bytes with sk-placeholder key', () => {
|
|
142
|
-
const template = getHarnessTemplate('opencode', {
|
|
143
|
-
keyValue: KEY,
|
|
144
|
-
baseUrl: BASE_URL,
|
|
145
|
-
providers: ['anthropic', 'openai'],
|
|
146
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
147
|
-
})
|
|
148
|
-
const preview = formatDryRunPreview({
|
|
149
|
-
repo: 'owner/repo',
|
|
150
|
-
harness: 'opencode',
|
|
151
|
-
providers: ['anthropic', 'openai'],
|
|
152
|
-
model: 'anthropic/claude-sonnet-4-6',
|
|
153
|
-
template,
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
expect(preview).toContain('Providers: anthropic, openai')
|
|
157
|
-
expect(preview).toContain('OPENCODE_AUTH_JSON (98 bytes)')
|
|
158
|
-
})
|
|
159
|
-
})
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type {ProviderId} from './providers'
|
|
2
|
-
import type {Harness, HarnessTemplate} from './templates'
|
|
3
|
-
|
|
4
|
-
export interface DryRunPreviewOptions {
|
|
5
|
-
repo: string
|
|
6
|
-
harness: Harness
|
|
7
|
-
providers: ProviderId[]
|
|
8
|
-
model: string
|
|
9
|
-
template: HarnessTemplate
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Format a dry-run preview string. The proxy key value is NEVER included —
|
|
14
|
-
* it is rendered as `<proxy-key>` in all positions.
|
|
15
|
-
*/
|
|
16
|
-
export function formatDryRunPreview(opts: DryRunPreviewOptions): string {
|
|
17
|
-
const {repo, harness, providers, model, template} = opts
|
|
18
|
-
|
|
19
|
-
const lines: string[] = [
|
|
20
|
-
`Dry run: cliproxy setup --harness ${harness}`,
|
|
21
|
-
`Repository: ${repo}`,
|
|
22
|
-
`Providers: ${providers.join(', ')}`,
|
|
23
|
-
`Model: ${model}`,
|
|
24
|
-
'Planned secrets:',
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
for (const secret of template.secrets) {
|
|
28
|
-
const size = new TextEncoder().encode(secret.value).byteLength
|
|
29
|
-
lines.push(` - ${secret.name} (${size} bytes)`)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
lines.push('Planned variables:')
|
|
33
|
-
for (const variable of template.variables) {
|
|
34
|
-
lines.push(` - ${variable.name} = ${variable.value}`)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
lines.push('Proxy key (redacted): <proxy-key>')
|
|
38
|
-
lines.push('No mutations will be performed.')
|
|
39
|
-
|
|
40
|
-
return lines.join('\n')
|
|
41
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/// <reference types="bun" />
|
|
2
|
-
|
|
3
|
-
import {describe, expect, it} from 'bun:test'
|
|
4
|
-
|
|
5
|
-
import {buildApiKeyValue, ensureRepoFormat, ensureSecretName} from './prompts'
|
|
6
|
-
|
|
7
|
-
describe('ensureRepoFormat', () => {
|
|
8
|
-
it('happy path: returns owner/repo unchanged', () => {
|
|
9
|
-
expect(ensureRepoFormat('owner/repo')).toBe('owner/repo')
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
it('error path: throws on whitespace in value', () => {
|
|
13
|
-
expect(() => ensureRepoFormat('owner repo')).toThrow()
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('error path: throws on extra slash', () => {
|
|
17
|
-
expect(() => ensureRepoFormat('owner/repo/extra')).toThrow()
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('error path: throws on empty string', () => {
|
|
21
|
-
expect(() => ensureRepoFormat('')).toThrow()
|
|
22
|
-
})
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
describe('ensureSecretName', () => {
|
|
26
|
-
it('happy path: returns VALID_NAME unchanged', () => {
|
|
27
|
-
expect(ensureSecretName('VALID_NAME', 'secret')).toBe('VALID_NAME')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('error path: throws on lowercase name', () => {
|
|
31
|
-
expect(() => ensureSecretName('lower_case', 'secret')).toThrow()
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('error path: throws on name with hyphens', () => {
|
|
35
|
-
expect(() => ensureSecretName('with-dash', 'secret')).toThrow()
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('error path: throws when name starts with a digit', () => {
|
|
39
|
-
expect(() => ensureSecretName('123_START_DIGIT', 'secret')).toThrow()
|
|
40
|
-
})
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
describe('buildApiKeyValue', () => {
|
|
44
|
-
it('happy path: slugifies key name and appends uuid suffix', () => {
|
|
45
|
-
const result = buildApiKeyValue('my repo ci!')
|
|
46
|
-
expect(result).toMatch(/^sk-my-repo-ci-[a-f0-9]+$/)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('edge case: empty string falls back to cliproxy slug', () => {
|
|
50
|
-
const result = buildApiKeyValue('')
|
|
51
|
-
expect(result).toMatch(/^sk-cliproxy-[a-f0-9]+$/)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('edge case: uppercased input is lowercased in slug', () => {
|
|
55
|
-
const result = buildApiKeyValue('UPPERCASE')
|
|
56
|
-
expect(result).toMatch(/^sk-uppercase-[a-f0-9]+$/)
|
|
57
|
-
})
|
|
58
|
-
})
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/// <reference types="bun" />
|
|
2
|
-
|
|
3
|
-
import * as clack from '@clack/prompts'
|
|
4
|
-
|
|
5
|
-
export interface GenericSecretNames {
|
|
6
|
-
apiKeySecretName: string
|
|
7
|
-
baseUrlSecretName: string
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function ensureRepoFormat(value: string): string {
|
|
11
|
-
const trimmed = value.trim()
|
|
12
|
-
if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
13
|
-
throw new Error('Repository must be in owner/repo format.')
|
|
14
|
-
}
|
|
15
|
-
return trimmed
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function ensureSecretName(value: string, label: string): string {
|
|
19
|
-
const trimmed = value.trim()
|
|
20
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(trimmed)) {
|
|
21
|
-
throw new Error(`${label} must be SCREAMING_SNAKE_CASE.`)
|
|
22
|
-
}
|
|
23
|
-
return trimmed
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function cancelAndExit(message = 'Setup cancelled.'): never {
|
|
27
|
-
clack.cancel(message)
|
|
28
|
-
process.exit(0)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function promptValue<T extends string | boolean>(
|
|
32
|
-
promise: Promise<T | symbol>,
|
|
33
|
-
cancelMessage?: string,
|
|
34
|
-
): Promise<T> {
|
|
35
|
-
const value = await promise
|
|
36
|
-
if (clack.isCancel(value)) {
|
|
37
|
-
cancelAndExit(cancelMessage)
|
|
38
|
-
}
|
|
39
|
-
return value
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function buildApiKeyValue(keyName: string): string {
|
|
43
|
-
const slug = (
|
|
44
|
-
keyName
|
|
45
|
-
.trim()
|
|
46
|
-
.toLowerCase()
|
|
47
|
-
.match(/[a-z0-9]+/g) ?? []
|
|
48
|
-
)
|
|
49
|
-
.join('-')
|
|
50
|
-
.slice(0, 24)
|
|
51
|
-
const random = crypto.randomUUID().split('-').join('')
|
|
52
|
-
return `sk-${slug || 'cliproxy'}-${random}`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function extractErrorMessage(error: unknown): string {
|
|
56
|
-
return error instanceof Error ? error.message : String(error)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function promptGenericSecretNames(): Promise<GenericSecretNames> {
|
|
60
|
-
const apiKeySecretName = ensureSecretName(
|
|
61
|
-
await promptValue(
|
|
62
|
-
clack.text({
|
|
63
|
-
message: 'Name for the API key secret',
|
|
64
|
-
placeholder: 'CLIPROXY_API_KEY',
|
|
65
|
-
validate: value => {
|
|
66
|
-
try {
|
|
67
|
-
ensureSecretName(value ?? '', 'API key secret name')
|
|
68
|
-
return undefined
|
|
69
|
-
} catch (error) {
|
|
70
|
-
return extractErrorMessage(error)
|
|
71
|
-
}
|
|
72
|
-
},
|
|
73
|
-
}),
|
|
74
|
-
'Setup cancelled before choosing the generic API key secret name.',
|
|
75
|
-
),
|
|
76
|
-
'API key secret name',
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
const baseUrlSecretName = ensureSecretName(
|
|
80
|
-
await promptValue(
|
|
81
|
-
clack.text({
|
|
82
|
-
message: 'Name for the proxy base URL secret',
|
|
83
|
-
placeholder: 'CLIPROXY_BASE_URL',
|
|
84
|
-
validate: value => {
|
|
85
|
-
try {
|
|
86
|
-
ensureSecretName(value ?? '', 'Base URL secret name')
|
|
87
|
-
return undefined
|
|
88
|
-
} catch (error) {
|
|
89
|
-
return extractErrorMessage(error)
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
}),
|
|
93
|
-
'Setup cancelled before choosing the generic base URL secret name.',
|
|
94
|
-
),
|
|
95
|
-
'Base URL secret name',
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
return {apiKeySecretName, baseUrlSecretName}
|
|
99
|
-
}
|