@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,237 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
import type {ActionCtx} from '../../lib/action-ctx'
|
|
3
|
-
|
|
4
|
-
import {z} from 'zod'
|
|
5
|
-
|
|
6
|
-
import {requestJson} from './shared'
|
|
7
|
-
|
|
8
|
-
declare const process: {
|
|
9
|
-
env: Record<string, string | undefined>
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
13
|
-
|
|
14
|
-
function stripTrailingSlash(value: string): string {
|
|
15
|
-
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Permissive schema: unknown fields preserved via passthrough().
|
|
19
|
-
// owned_by is optional — CLIProxyAPI v7 may omit it; provider is inferred from id instead.
|
|
20
|
-
const modelEntrySchema = z
|
|
21
|
-
.object({
|
|
22
|
-
id: z.string(),
|
|
23
|
-
owned_by: z.string().optional(),
|
|
24
|
-
created: z.number().optional(),
|
|
25
|
-
})
|
|
26
|
-
.passthrough()
|
|
27
|
-
|
|
28
|
-
const modelsResponseSchema = z
|
|
29
|
-
.object({
|
|
30
|
-
data: z.array(modelEntrySchema),
|
|
31
|
-
})
|
|
32
|
-
.passthrough()
|
|
33
|
-
|
|
34
|
-
type ModelEntry = z.infer<typeof modelEntrySchema>
|
|
35
|
-
|
|
36
|
-
// Known bare-id patterns per provider, used only when owned_by is absent
|
|
37
|
-
// (e.g. CLIProxyAPI v7 omits owned_by from /v1/models entries).
|
|
38
|
-
const PROVIDER_ID_PATTERNS: Record<string, RegExp> = {
|
|
39
|
-
openai: /^(?:gpt-|o1-|o3-|codex)/i,
|
|
40
|
-
anthropic: /^claude-/i,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const VALID_PROVIDERS = ['anthropic', 'openai'] as const
|
|
44
|
-
type ValidProvider = (typeof VALID_PROVIDERS)[number]
|
|
45
|
-
|
|
46
|
-
function isValidProvider(value: string): value is ValidProvider {
|
|
47
|
-
return (VALID_PROVIDERS as readonly string[]).includes(value)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Derives the provider string for a model entry.
|
|
52
|
-
* Prefers owned_by when present and non-empty; falls back to PROVIDER_ID_PATTERNS
|
|
53
|
-
* matching against the bare id; falls back to 'unknown'.
|
|
54
|
-
* Display/validation-only — never an auth or trust signal.
|
|
55
|
-
*/
|
|
56
|
-
function deriveProvider(entry: ModelEntry): string {
|
|
57
|
-
if (entry.owned_by !== undefined && entry.owned_by.trim() !== '') {
|
|
58
|
-
return entry.owned_by
|
|
59
|
-
}
|
|
60
|
-
// Strip any existing provider/ prefix before pattern matching
|
|
61
|
-
const bareId = stripProviderPrefix(entry.id)
|
|
62
|
-
for (const [provider, pattern] of Object.entries(PROVIDER_ID_PATTERNS)) {
|
|
63
|
-
if (pattern.test(bareId)) return provider
|
|
64
|
-
}
|
|
65
|
-
return 'unknown'
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Strips an existing `provider/` prefix from a raw id, if present.
|
|
70
|
-
* Defensive: handles entries that already have a prefix so we don't double-prefix.
|
|
71
|
-
*/
|
|
72
|
-
function stripProviderPrefix(id: string): string {
|
|
73
|
-
const slashIdx = id.indexOf('/')
|
|
74
|
-
if (slashIdx === -1) return id
|
|
75
|
-
// Only strip if the prefix looks like a known provider or 'unknown'
|
|
76
|
-
const prefix = id.slice(0, slashIdx)
|
|
77
|
-
if (prefix === 'unknown' || isValidProvider(prefix)) {
|
|
78
|
-
return id.slice(slashIdx + 1)
|
|
79
|
-
}
|
|
80
|
-
return id
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Decides whether a /v1/models entry belongs to a provider.
|
|
85
|
-
* Prefers owned_by when present; falls back to id prefix / known bare-id
|
|
86
|
-
* patterns when absent. Display/validation-only — never an auth or trust signal.
|
|
87
|
-
*/
|
|
88
|
-
function entryMatchesProvider(entry: ModelEntry, provider: string): boolean {
|
|
89
|
-
return deriveProvider(entry) === provider
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Sort entries: grouped by provider (ascending), within each group by created asc.
|
|
94
|
-
* Entries with missing/non-number created sort last within their group.
|
|
95
|
-
*/
|
|
96
|
-
function sortEntries(entries: ModelEntry[]): ModelEntry[] {
|
|
97
|
-
// Annotate with derived provider for stable grouping
|
|
98
|
-
const annotated = entries.map(e => ({entry: e, provider: deriveProvider(e)}))
|
|
99
|
-
|
|
100
|
-
// Sort: provider asc, then created asc (missing created → Infinity → sorts last)
|
|
101
|
-
annotated.sort((a, b) => {
|
|
102
|
-
const providerCmp = a.provider.localeCompare(b.provider)
|
|
103
|
-
if (providerCmp !== 0) return providerCmp
|
|
104
|
-
const aCreated = typeof a.entry.created === 'number' ? a.entry.created : Infinity
|
|
105
|
-
const bCreated = typeof b.entry.created === 'number' ? b.entry.created : Infinity
|
|
106
|
-
return aCreated - bCreated
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
return annotated.map(a => a.entry)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Formats a model entry for plain output: `provider/raw_id`.
|
|
114
|
-
*/
|
|
115
|
-
function formatPlainLine(entry: ModelEntry): string {
|
|
116
|
-
const provider = deriveProvider(entry)
|
|
117
|
-
const rawId = stripProviderPrefix(entry.id)
|
|
118
|
-
return `${provider}/${rawId}`
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface VerboseEntry {
|
|
122
|
-
id: string
|
|
123
|
-
provider: string
|
|
124
|
-
raw_id: string
|
|
125
|
-
created: string | null
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Formats a model entry for verbose JSON output.
|
|
130
|
-
*/
|
|
131
|
-
function formatVerboseEntry(entry: ModelEntry): VerboseEntry {
|
|
132
|
-
const provider = deriveProvider(entry)
|
|
133
|
-
const rawId = stripProviderPrefix(entry.id)
|
|
134
|
-
return {
|
|
135
|
-
id: `${provider}/${rawId}`,
|
|
136
|
-
provider,
|
|
137
|
-
raw_id: rawId,
|
|
138
|
-
created: typeof entry.created === 'number' ? new Date(entry.created * 1000).toISOString() : null,
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export interface ModelsOptions {
|
|
143
|
-
url?: string
|
|
144
|
-
key?: string
|
|
145
|
-
verbose?: boolean
|
|
146
|
-
provider?: string
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export async function cliproxyModelsAction(options: ModelsOptions, ctx: ActionCtx): Promise<void> {
|
|
150
|
-
try {
|
|
151
|
-
const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
152
|
-
|
|
153
|
-
// Trusted URL: the canonical configured/default host, trailing-slash-normalized.
|
|
154
|
-
// Ambient env keys must ONLY follow to this trusted destination to prevent
|
|
155
|
-
// secret exfiltration when an agent passes an attacker-controlled --url.
|
|
156
|
-
const trustedUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
157
|
-
const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl
|
|
158
|
-
|
|
159
|
-
// An explicit --key is always honored (operator knows what they're doing).
|
|
160
|
-
// An ambient env key is only forwarded when the resolved baseUrl is trusted.
|
|
161
|
-
const key = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY)
|
|
162
|
-
|
|
163
|
-
if (!key) {
|
|
164
|
-
throw new Error('No API key. Provide --key or set CLIPROXY_API_KEY.')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Validate provider before any HTTP call
|
|
168
|
-
if (options.provider !== undefined && !isValidProvider(options.provider)) {
|
|
169
|
-
throw new Error(`Unknown provider "${options.provider}". Use anthropic or openai.`)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const endpoint = `${baseUrl}/v1/models`
|
|
173
|
-
const raw = await requestJson(endpoint, {
|
|
174
|
-
headers: {Authorization: `Bearer ${key}`},
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
const parseResult = modelsResponseSchema.safeParse(raw)
|
|
178
|
-
if (!parseResult.success) {
|
|
179
|
-
const message = parseResult.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')
|
|
180
|
-
throw new Error(`Malformed /v1/models response: ${message}`)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
let entries = parseResult.data.data
|
|
184
|
-
|
|
185
|
-
if (options.provider !== undefined) {
|
|
186
|
-
entries = entries.filter(e => entryMatchesProvider(e, options.provider as string))
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Sort: provider-grouped (asc), date-asc within each group
|
|
190
|
-
entries = sortEntries(entries)
|
|
191
|
-
|
|
192
|
-
if (options.verbose) {
|
|
193
|
-
// --verbose: emit a single JSON array (pretty-printed) of the fields we have.
|
|
194
|
-
// Always valid JSON — empty/zero-match → [].
|
|
195
|
-
const verboseEntries = entries.map(formatVerboseEntry)
|
|
196
|
-
ctx.console.log(JSON.stringify(verboseEntries, null, 2))
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (entries.length === 0) {
|
|
201
|
-
ctx.console.log(
|
|
202
|
-
options.provider === undefined ? 'No models.' : `No models found for provider ${options.provider}.`,
|
|
203
|
-
)
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
for (const entry of entries) {
|
|
208
|
-
ctx.console.log(formatPlainLine(entry))
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
212
|
-
ctx.console.error(message)
|
|
213
|
-
ctx.process.exit(1)
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export function registerCliproxyModels(cli: ReturnType<typeof goke>): void {
|
|
218
|
-
cli
|
|
219
|
-
.command('cliproxy models [provider]', 'List the models CLIProxyAPI serves.')
|
|
220
|
-
.option(
|
|
221
|
-
'--url [url]',
|
|
222
|
-
z.string().describe('Base URL for CLIProxyAPI. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.'),
|
|
223
|
-
)
|
|
224
|
-
.option('--key [key]', z.string().describe('API key (bearer). Falls back to CLIPROXY_API_KEY when omitted.'))
|
|
225
|
-
.option('--verbose', 'Show owned_by and created date for each model.')
|
|
226
|
-
.example('# List all models served by CLIProxyAPI')
|
|
227
|
-
.example('infra cliproxy models')
|
|
228
|
-
.example('# Filter to Anthropic models only')
|
|
229
|
-
.example('infra cliproxy models anthropic')
|
|
230
|
-
.example('# Show verbose output with owned_by and created date')
|
|
231
|
-
.example('infra cliproxy models --verbose')
|
|
232
|
-
.example('# Filter to OpenAI models with verbose output')
|
|
233
|
-
.example('infra cliproxy models openai --verbose')
|
|
234
|
-
.action((provider, options, ctx) =>
|
|
235
|
-
cliproxyModelsAction({...options, provider: provider as string | undefined}, ctx),
|
|
236
|
-
)
|
|
237
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import {resolve} from 'node:path'
|
|
2
|
-
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
3
|
-
|
|
4
|
-
const cliDir = resolve(import.meta.dir, '../../..')
|
|
5
|
-
|
|
6
|
-
const envKeys = ['CLIPROXY_DOMAIN', 'HOME', 'PATH'] as const
|
|
7
|
-
|
|
8
|
-
type ManagedEnvKey = (typeof envKeys)[number]
|
|
9
|
-
|
|
10
|
-
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
11
|
-
|
|
12
|
-
function restoreManagedEnv(): void {
|
|
13
|
-
for (const key of envKeys) {
|
|
14
|
-
const value = originalEnv[key]
|
|
15
|
-
|
|
16
|
-
if (value === undefined) {
|
|
17
|
-
delete process.env[key]
|
|
18
|
-
continue
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
process.env[key] = value
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function runOpenCommand(
|
|
26
|
-
args: string[],
|
|
27
|
-
envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
|
|
28
|
-
): Promise<{stdout: string; stderr: string; exitCode: number}> {
|
|
29
|
-
const env = {...process.env}
|
|
30
|
-
|
|
31
|
-
for (const [key, value] of Object.entries(envOverrides)) {
|
|
32
|
-
if (value === undefined) {
|
|
33
|
-
delete env[key]
|
|
34
|
-
continue
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
env[key] = value
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const proc = Bun.spawn(['bun', 'src/cli.ts', 'cliproxy', 'open', ...args], {
|
|
41
|
-
cwd: cliDir,
|
|
42
|
-
env: {
|
|
43
|
-
...env,
|
|
44
|
-
HOME: env.HOME ?? '/tmp/test-home',
|
|
45
|
-
NO_COLOR: '1',
|
|
46
|
-
PATH: env.PATH ?? '/usr/bin:/bin',
|
|
47
|
-
},
|
|
48
|
-
stdout: 'pipe',
|
|
49
|
-
stderr: 'pipe',
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
53
|
-
new Response(proc.stdout).text(),
|
|
54
|
-
new Response(proc.stderr).text(),
|
|
55
|
-
proc.exited,
|
|
56
|
-
])
|
|
57
|
-
|
|
58
|
-
return {stdout, stderr, exitCode}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
describe('cliproxy open', () => {
|
|
62
|
-
beforeEach(() => {
|
|
63
|
-
originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
afterEach(() => {
|
|
67
|
-
restoreManagedEnv()
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
describe('validation', () => {
|
|
71
|
-
it('requires interactive terminal', async () => {
|
|
72
|
-
const {stderr, exitCode} = await runOpenCommand([])
|
|
73
|
-
expect(exitCode).not.toBe(0)
|
|
74
|
-
expect(stderr).toContain('interactive terminal')
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
describe('host resolution', () => {
|
|
79
|
-
it('uses --host flag', async () => {
|
|
80
|
-
const {stderr, exitCode} = await runOpenCommand(['--host', 'custom.host.example'])
|
|
81
|
-
expect(exitCode).not.toBe(0)
|
|
82
|
-
expect(stderr).toContain('interactive terminal')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('uses CLIPROXY_DOMAIN env var', async () => {
|
|
86
|
-
const {stderr, exitCode} = await runOpenCommand([], {
|
|
87
|
-
CLIPROXY_DOMAIN: 'custom.host.example',
|
|
88
|
-
})
|
|
89
|
-
expect(exitCode).not.toBe(0)
|
|
90
|
-
expect(stderr).toContain('interactive terminal')
|
|
91
|
-
})
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
describe('host validation', () => {
|
|
95
|
-
it('rejects CLIPROXY_DOMAIN env with a leading dash (ProxyCommand injection vector)', async () => {
|
|
96
|
-
const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: '-oProxyCommand=evil'})
|
|
97
|
-
expect(exitCode).not.toBe(0)
|
|
98
|
-
expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('rejects CLIPROXY_DOMAIN env with shell metacharacters (semicolon)', async () => {
|
|
102
|
-
const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: 'gateway.example.com;rm -rf'})
|
|
103
|
-
expect(exitCode).not.toBe(0)
|
|
104
|
-
expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('rejects CLIPROXY_DOMAIN env with an at-sign', async () => {
|
|
108
|
-
const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: 'user@cliproxy.fro.bot'})
|
|
109
|
-
expect(exitCode).not.toBe(0)
|
|
110
|
-
expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
|
|
111
|
-
})
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
describe('unit: resolveHost', () => {
|
|
115
|
-
it('returns input when provided', async () => {
|
|
116
|
-
const {resolveHost} = await import('./open')
|
|
117
|
-
expect(resolveHost('test.example.com')).toBe('test.example.com')
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('falls back to CLIPROXY_DOMAIN', async () => {
|
|
121
|
-
process.env.CLIPROXY_DOMAIN = 'env.example.com'
|
|
122
|
-
const {resolveHost} = await import('./open')
|
|
123
|
-
expect(resolveHost()).toBe('env.example.com')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('falls back to default host', async () => {
|
|
127
|
-
delete process.env.CLIPROXY_DOMAIN
|
|
128
|
-
const {resolveHost} = await import('./open')
|
|
129
|
-
expect(resolveHost()).toBe('cliproxy.fro.bot')
|
|
130
|
-
})
|
|
131
|
-
})
|
|
132
|
-
})
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {z} from 'zod'
|
|
4
|
-
|
|
5
|
-
import {validateCliproxyHost} from './host'
|
|
6
|
-
|
|
7
|
-
const DEFAULT_HOST = 'cliproxy.fro.bot'
|
|
8
|
-
const DEFAULT_REMOTE_USER = 'root'
|
|
9
|
-
|
|
10
|
-
export function resolveHost(input?: string): string {
|
|
11
|
-
const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST
|
|
12
|
-
|
|
13
|
-
if (!host) {
|
|
14
|
-
throw new Error('CLIPROXY_DOMAIN is required. Pass --host or set CLIPROXY_DOMAIN.')
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return host
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function registerCliproxyOpen(cli: ReturnType<typeof goke>): void {
|
|
21
|
-
cli
|
|
22
|
-
.command('cliproxy open', 'Launch CLIProxyAPI TUI dashboard via SSH.')
|
|
23
|
-
.option(
|
|
24
|
-
'--host [host]',
|
|
25
|
-
z.string().describe('SSH host for CLIProxyAPI server. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.'),
|
|
26
|
-
)
|
|
27
|
-
.example('# Open the TUI dashboard on the remote CLIProxyAPI instance')
|
|
28
|
-
.example('infra cliproxy open')
|
|
29
|
-
.example('# Open on a custom host')
|
|
30
|
-
.example('infra cliproxy open --host custom.example.com')
|
|
31
|
-
.action(async options => {
|
|
32
|
-
const host = validateCliproxyHost(resolveHost(options.host))
|
|
33
|
-
|
|
34
|
-
if (!process.stdin.isTTY) {
|
|
35
|
-
throw new Error('cliproxy open requires an interactive terminal. Run from a shell with TTY attached.')
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const path = process.env.PATH
|
|
39
|
-
const home = process.env.HOME
|
|
40
|
-
|
|
41
|
-
if (!path) {
|
|
42
|
-
throw new Error('PATH is required to invoke ssh')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (!home) {
|
|
46
|
-
throw new Error('HOME is required to invoke ssh')
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const remoteCommand = 'cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --tui'
|
|
50
|
-
|
|
51
|
-
const child = Bun.spawn(
|
|
52
|
-
[
|
|
53
|
-
'ssh',
|
|
54
|
-
'-tt',
|
|
55
|
-
'-o',
|
|
56
|
-
'BatchMode=yes',
|
|
57
|
-
'-o',
|
|
58
|
-
'ConnectTimeout=10',
|
|
59
|
-
`${DEFAULT_REMOTE_USER}@${host}`,
|
|
60
|
-
remoteCommand,
|
|
61
|
-
],
|
|
62
|
-
{
|
|
63
|
-
env: {
|
|
64
|
-
PATH: path,
|
|
65
|
-
HOME: home,
|
|
66
|
-
},
|
|
67
|
-
stdin: 'inherit',
|
|
68
|
-
stdout: 'inherit',
|
|
69
|
-
stderr: 'inherit',
|
|
70
|
-
},
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
const exitCode = await child.exited
|
|
74
|
-
if (exitCode !== 0) {
|
|
75
|
-
throw new Error(`Remote TUI command failed with exit code ${exitCode}`)
|
|
76
|
-
}
|
|
77
|
-
})
|
|
78
|
-
}
|
|
@@ -1,218 +0,0 @@
|
|
|
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
|
-
})
|