@marcusrbrown/infra 0.2.0 → 0.3.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.
@@ -0,0 +1,145 @@
1
+ import type {goke} from 'goke'
2
+
3
+ import {existsSync} from 'node:fs'
4
+ import {resolve} from 'node:path'
5
+ import {z} from 'zod'
6
+
7
+ const REPO = 'marcusrbrown/infra'
8
+ const WORKFLOW_NAME = 'Deploy'
9
+ const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy.yaml'
10
+
11
+ type CliInstance = ReturnType<typeof goke>
12
+
13
+ export function resolveLocalDeployScriptPath(): string {
14
+ const primary = resolve(import.meta.dir, '../../../../apps/cliproxy/src/deploy.ts')
15
+ if (existsSync(primary)) {
16
+ return primary
17
+ }
18
+
19
+ return resolve(import.meta.dir, '../../../apps/cliproxy/src/deploy.ts')
20
+ }
21
+
22
+ export function getLocalDeployEnv(): Record<string, string> {
23
+ const path = process.env.PATH
24
+ const home = process.env.HOME
25
+ const sshAuthSock = process.env.SSH_AUTH_SOCK
26
+
27
+ if (!path) {
28
+ throw new Error('PATH is required for local deploy')
29
+ }
30
+
31
+ if (!home) {
32
+ throw new Error('HOME is required for local deploy')
33
+ }
34
+
35
+ if (!sshAuthSock) {
36
+ throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
37
+ }
38
+
39
+ return {
40
+ PATH: path,
41
+ HOME: home,
42
+ SSH_AUTH_SOCK: sshAuthSock,
43
+ CLIPROXY_DOMAIN: process.env.CLIPROXY_DOMAIN ?? '',
44
+ CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY ?? '',
45
+ }
46
+ }
47
+
48
+ export function validateLocalPreconditions(): {deployScriptPath: string} {
49
+ const deployScriptPath = resolveLocalDeployScriptPath()
50
+ if (!existsSync(deployScriptPath)) {
51
+ throw new Error(`Local deploy script not found at expected path: ${deployScriptPath}`)
52
+ }
53
+
54
+ getLocalDeployEnv()
55
+
56
+ return {deployScriptPath}
57
+ }
58
+
59
+ export function validateRemotePreconditions(): void {
60
+ if (!Bun.which('gh')) {
61
+ throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
62
+ }
63
+ }
64
+
65
+ export function registerCliproxyDeploy(cli: CliInstance): void {
66
+ cli
67
+ .command(
68
+ 'cliproxy deploy',
69
+ 'Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.',
70
+ )
71
+ .option(
72
+ '--local',
73
+ z
74
+ .boolean()
75
+ .default(false)
76
+ .describe(
77
+ 'Run local deployment with Bun using apps/cliproxy/src/deploy.ts instead of triggering GitHub Actions.',
78
+ ),
79
+ )
80
+ .option(
81
+ '--dry-run',
82
+ z
83
+ .boolean()
84
+ .default(false)
85
+ .describe(
86
+ 'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
87
+ ),
88
+ )
89
+ .example('# Trigger remote GitHub Actions deploy (default mode)')
90
+ .example('infra cliproxy deploy')
91
+ .example('# Validate local deploy preconditions and planned command')
92
+ .example('infra cliproxy deploy --local --dry-run')
93
+ .example('# Run local deploy with explicit SSH agent context')
94
+ .example('infra cliproxy deploy --local')
95
+ .action(async options => {
96
+ if (options.local) {
97
+ const {deployScriptPath} = validateLocalPreconditions()
98
+ const env = getLocalDeployEnv()
99
+ const command = ['bun', deployScriptPath]
100
+
101
+ if (options.dryRun) {
102
+ console.log('Dry run: local CLIProxyAPI deploy')
103
+ console.log(`- deploy script: ${deployScriptPath}`)
104
+ console.log(`- command: ${command.join(' ')}`)
105
+ console.log(`- CLIPROXY_DOMAIN=${env.CLIPROXY_DOMAIN || '(unset)'}`)
106
+ return
107
+ }
108
+
109
+ const child = Bun.spawn(command, {
110
+ env,
111
+ stdout: 'inherit',
112
+ stderr: 'inherit',
113
+ })
114
+
115
+ const exitCode = await child.exited
116
+ if (exitCode !== 0) {
117
+ throw new Error(`Local deploy failed with exit code ${exitCode}`)
118
+ }
119
+
120
+ return
121
+ }
122
+
123
+ validateRemotePreconditions()
124
+
125
+ if (options.dryRun) {
126
+ console.log('Dry run: remote CLIProxyAPI deploy')
127
+ console.log(`- command: gh workflow run ${WORKFLOW_NAME} --repo ${REPO}`)
128
+ console.log(`- workflow URL: ${WORKFLOW_URL}`)
129
+ return
130
+ }
131
+
132
+ const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
133
+ stdout: 'inherit',
134
+ stderr: 'inherit',
135
+ })
136
+
137
+ const exitCode = await child.exited
138
+ if (exitCode !== 0) {
139
+ throw new Error(`Failed to trigger ${WORKFLOW_NAME} workflow (exit code ${exitCode})`)
140
+ }
141
+
142
+ console.log(`Workflow triggered: ${WORKFLOW_URL}`)
143
+ console.log('Approve the production environment deployment in GitHub Actions to continue.')
144
+ })
145
+ }
@@ -0,0 +1,168 @@
1
+ import type {goke} from 'goke'
2
+
3
+ import {z} from 'zod'
4
+
5
+ const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
6
+ const HTTP_TIMEOUT_MS = 10_000
7
+
8
+ function stripTrailingSlash(value: string): string {
9
+ return value.endsWith('/') ? value.slice(0, -1) : value
10
+ }
11
+
12
+ function resolveBaseUrl(input?: string): string {
13
+ return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
14
+ }
15
+
16
+ function resolveManagementKey(input?: string): string {
17
+ const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY
18
+
19
+ if (!key) {
20
+ throw new Error('Management API key is required. Pass --key or set CLIPROXY_MANAGEMENT_KEY.')
21
+ }
22
+
23
+ return key
24
+ }
25
+
26
+ function managementHeaders(key: string): Headers {
27
+ const headers = new Headers()
28
+ headers.set('authorization', `Bearer ${key}`)
29
+ headers.set('x-management-key', key)
30
+ headers.set('content-type', 'application/json')
31
+ return headers
32
+ }
33
+
34
+ async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
35
+ const response = await fetch(endpoint, {
36
+ ...init,
37
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
38
+ })
39
+
40
+ if (!response.ok) {
41
+ const body = await response.text()
42
+ throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
43
+ }
44
+
45
+ try {
46
+ return await response.json()
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ export function toStringArray(payload: unknown): string[] {
53
+ if (Array.isArray(payload)) {
54
+ return payload.filter(item => typeof item === 'string')
55
+ }
56
+
57
+ if (payload && typeof payload === 'object') {
58
+ const value = (payload as Record<string, unknown>).api_keys
59
+ if (Array.isArray(value)) {
60
+ return value.filter(item => typeof item === 'string')
61
+ }
62
+ }
63
+
64
+ return []
65
+ }
66
+
67
+ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
68
+ cli
69
+ .command('cliproxy keys list', 'List CLIProxyAPI API keys from the management API.')
70
+ .option(
71
+ '--url [url]',
72
+ z
73
+ .string()
74
+ .describe(
75
+ 'Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.',
76
+ ),
77
+ )
78
+ .option(
79
+ '--key [key]',
80
+ z.string().describe('Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
81
+ )
82
+ .action(async options => {
83
+ const baseUrl = resolveBaseUrl(options.url)
84
+ const managementKey = resolveManagementKey(options.key)
85
+ const endpoint = `${baseUrl}/v0/management/api-keys`
86
+ const payload = await requestJson(endpoint, {
87
+ method: 'GET',
88
+ headers: managementHeaders(managementKey),
89
+ })
90
+
91
+ console.log(JSON.stringify(toStringArray(payload), null, 2))
92
+ })
93
+
94
+ cli
95
+ .command(
96
+ 'cliproxy keys add <key>',
97
+ 'Add an API key by fetching current keys, appending the value, and replacing full key set.',
98
+ )
99
+ .option(
100
+ '--url [url]',
101
+ z
102
+ .string()
103
+ .describe(
104
+ 'Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.',
105
+ ),
106
+ )
107
+ .option(
108
+ '--key [key]',
109
+ z.string().describe('Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
110
+ )
111
+ .example('# Add a new API key to the current key set')
112
+ .example('infra cliproxy keys add sk-live-123')
113
+ .action(async (apiKeyToAdd, options) => {
114
+ const baseUrl = resolveBaseUrl(options.url)
115
+ const managementKey = resolveManagementKey(options.key)
116
+ const endpoint = `${baseUrl}/v0/management/api-keys`
117
+
118
+ const currentPayload = await requestJson(endpoint, {
119
+ method: 'GET',
120
+ headers: managementHeaders(managementKey),
121
+ })
122
+ const currentKeys = toStringArray(currentPayload)
123
+
124
+ if (currentKeys.includes(apiKeyToAdd)) {
125
+ console.log('Key already present; no update required.')
126
+ return
127
+ }
128
+
129
+ const nextKeys = [...currentKeys, apiKeyToAdd]
130
+ const updatedPayload = await requestJson(endpoint, {
131
+ method: 'PUT',
132
+ headers: managementHeaders(managementKey),
133
+ body: JSON.stringify({api_keys: nextKeys}),
134
+ })
135
+
136
+ console.log(JSON.stringify(updatedPayload ?? {api_keys: nextKeys}, null, 2))
137
+ })
138
+
139
+ cli
140
+ .command('cliproxy keys remove <key>', 'Remove an API key via management API endpoint query parameter.')
141
+ .option(
142
+ '--url [url]',
143
+ z
144
+ .string()
145
+ .describe(
146
+ 'Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.',
147
+ ),
148
+ )
149
+ .option(
150
+ '--key [key]',
151
+ z.string().describe('Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
152
+ )
153
+ .example('# Remove an API key from CLIProxyAPI')
154
+ .example('infra cliproxy keys remove sk-live-123')
155
+ .action(async (apiKeyToRemove, options) => {
156
+ const baseUrl = resolveBaseUrl(options.url)
157
+ const managementKey = resolveManagementKey(options.key)
158
+ const params = new URLSearchParams({value: apiKeyToRemove})
159
+ const endpoint = `${baseUrl}/v0/management/api-keys?${params.toString()}`
160
+
161
+ const payload = await requestJson(endpoint, {
162
+ method: 'DELETE',
163
+ headers: managementHeaders(managementKey),
164
+ })
165
+
166
+ console.log(JSON.stringify(payload, null, 2))
167
+ })
168
+ }
@@ -0,0 +1,81 @@
1
+ import type {goke} from 'goke'
2
+
3
+ import {z} from 'zod'
4
+
5
+ const DEFAULT_HOST = 'cliproxy.fro.bot'
6
+ const DEFAULT_REMOTE_USER = 'root'
7
+
8
+ export function resolveHost(input?: string): string {
9
+ const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST
10
+
11
+ if (!host) {
12
+ throw new Error('CLIPROXY_DOMAIN is required. Pass --host or set CLIPROXY_DOMAIN.')
13
+ }
14
+
15
+ return host
16
+ }
17
+
18
+ export function requireSshAuthSock(): string {
19
+ const sshAuthSock = process.env.SSH_AUTH_SOCK
20
+ if (!sshAuthSock) {
21
+ throw new Error('SSH_AUTH_SOCK is required. Start ssh-agent and load your SSH key before running cliproxy login.')
22
+ }
23
+
24
+ return sshAuthSock
25
+ }
26
+
27
+ export function registerCliproxyLogin(cli: ReturnType<typeof goke>): void {
28
+ cli
29
+ .command(
30
+ 'cliproxy login <provider>',
31
+ 'Run provider login on the remote CLIProxyAPI host and print OAuth URL output.',
32
+ )
33
+ .option(
34
+ '--host [host]',
35
+ z
36
+ .string()
37
+ .describe('CLIProxyAPI droplet host for SSH execution. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.'),
38
+ )
39
+ .example('# Start Claude login flow on remote CLIProxyAPI instance')
40
+ .example('infra cliproxy login claude')
41
+ .action(async (provider, options) => {
42
+ if (provider !== 'claude') {
43
+ throw new Error(`Unsupported provider "${provider}". Currently only "claude" is supported.`)
44
+ }
45
+
46
+ const host = resolveHost(options.host)
47
+ const sshAuthSock = requireSshAuthSock()
48
+ const path = process.env.PATH
49
+ const home = process.env.HOME
50
+
51
+ if (!path) {
52
+ throw new Error('PATH is required to invoke ssh')
53
+ }
54
+
55
+ if (!home) {
56
+ throw new Error('HOME is required to invoke ssh')
57
+ }
58
+
59
+ const remoteCommand = 'docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --no-browser --claude-login'
60
+
61
+ const child = Bun.spawn(
62
+ ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', `${DEFAULT_REMOTE_USER}@${host}`, remoteCommand],
63
+ {
64
+ env: {
65
+ PATH: path,
66
+ HOME: home,
67
+ SSH_AUTH_SOCK: sshAuthSock,
68
+ },
69
+ stdout: 'inherit',
70
+ stderr: 'inherit',
71
+ },
72
+ )
73
+
74
+ const exitCode = await child.exited
75
+ if (exitCode !== 0) {
76
+ throw new Error(`Remote login command failed with exit code ${exitCode}`)
77
+ }
78
+
79
+ console.log('If an OAuth URL was printed above, open it in your browser to complete login.')
80
+ })
81
+ }
@@ -0,0 +1,271 @@
1
+ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
2
+
3
+ import {
4
+ checkHttpReachability,
5
+ checkUsageStats,
6
+ checkVersion,
7
+ formatDurationMs,
8
+ levelLabel,
9
+ stripTrailingSlash,
10
+ toNumber,
11
+ } from './cliproxy-status'
12
+
13
+ const originalFetch = globalThis.fetch
14
+
15
+ type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
16
+
17
+ function createFetchImplementation(handler: FetchReplacement): typeof fetch {
18
+ return Object.assign(
19
+ (input: string | URL | Request, init?: RequestInit) => {
20
+ if (typeof input !== 'string') {
21
+ throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
22
+ }
23
+
24
+ return handler(input, init)
25
+ },
26
+ {preconnect: originalFetch.preconnect},
27
+ )
28
+ }
29
+
30
+ describe('cliproxy status helpers', () => {
31
+ beforeEach(() => {
32
+ globalThis.fetch = createFetchImplementation(async () => {
33
+ throw new Error('Unexpected fetch call')
34
+ })
35
+ })
36
+
37
+ afterEach(() => {
38
+ globalThis.fetch = originalFetch
39
+ })
40
+
41
+ describe('levelLabel', () => {
42
+ it('returns OK for ok', () => {
43
+ expect(levelLabel('ok')).toBe('OK')
44
+ })
45
+
46
+ it('returns WARN for warning', () => {
47
+ expect(levelLabel('warning')).toBe('WARN')
48
+ })
49
+
50
+ it('returns ERROR for error', () => {
51
+ expect(levelLabel('error')).toBe('ERROR')
52
+ })
53
+ })
54
+
55
+ describe('formatDurationMs', () => {
56
+ it('formats positive durations', () => {
57
+ expect(formatDurationMs(150)).toBe('150ms')
58
+ })
59
+
60
+ it('formats zero duration', () => {
61
+ expect(formatDurationMs(0)).toBe('0ms')
62
+ })
63
+
64
+ it('clamps negative durations to zero', () => {
65
+ expect(formatDurationMs(-5)).toBe('0ms')
66
+ })
67
+ })
68
+
69
+ describe('stripTrailingSlash', () => {
70
+ it('removes a trailing slash', () => {
71
+ expect(stripTrailingSlash('https://example.com/')).toBe('https://example.com')
72
+ })
73
+
74
+ it('does nothing when there is no trailing slash', () => {
75
+ expect(stripTrailingSlash('https://example.com')).toBe('https://example.com')
76
+ })
77
+ })
78
+
79
+ describe('toNumber', () => {
80
+ it('returns finite numbers as-is', () => {
81
+ expect(toNumber(42)).toBe(42)
82
+ })
83
+
84
+ it('returns null for non-numbers', () => {
85
+ expect(toNumber('not a number')).toBeNull()
86
+ })
87
+
88
+ it('returns null for NaN', () => {
89
+ expect(toNumber(Number.NaN)).toBeNull()
90
+ })
91
+
92
+ it('returns null for infinity', () => {
93
+ expect(toNumber(Number.POSITIVE_INFINITY)).toBeNull()
94
+ })
95
+ })
96
+
97
+ describe('checkHttpReachability', () => {
98
+ it('returns ok for HTTP 200', async () => {
99
+ globalThis.fetch = createFetchImplementation(async () => new Response('ok', {status: 200}))
100
+
101
+ const result = await checkHttpReachability('https://cliproxy.example.com', false)
102
+
103
+ expect(result.level).toBe('ok')
104
+ expect(result.summary).toContain('GET https://cliproxy.example.com → 200')
105
+ expect(result.summary).toContain('ms')
106
+ })
107
+
108
+ it('returns error for HTTP 500', async () => {
109
+ globalThis.fetch = createFetchImplementation(async () => new Response('error', {status: 500}))
110
+
111
+ const result = await checkHttpReachability('https://cliproxy.example.com', false)
112
+
113
+ expect(result.level).toBe('error')
114
+ expect(result.summary).toContain('GET https://cliproxy.example.com → 500')
115
+ })
116
+
117
+ it('returns error details for network failures', async () => {
118
+ globalThis.fetch = createFetchImplementation(async () => {
119
+ throw new Error('Network timeout')
120
+ })
121
+
122
+ const result = await checkHttpReachability('https://cliproxy.example.com', true)
123
+
124
+ expect(result.level).toBe('error')
125
+ expect(result.summary).toContain('Network timeout')
126
+ expect(result.details).toEqual(['URL: https://cliproxy.example.com', 'Timeout: 10000ms'])
127
+ })
128
+ })
129
+
130
+ describe('checkUsageStats', () => {
131
+ it('returns ok when failures are zero', async () => {
132
+ globalThis.fetch = createFetchImplementation(
133
+ async () =>
134
+ new Response(JSON.stringify({total_requests: 10, failure_count: 0}), {
135
+ status: 200,
136
+ headers: {'content-type': 'application/json'},
137
+ }),
138
+ )
139
+
140
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
141
+
142
+ expect(result.level).toBe('ok')
143
+ expect(result.summary).toBe('total_requests=10, failure_count=0')
144
+ })
145
+
146
+ it('returns warning when token refresh is likely needed', async () => {
147
+ globalThis.fetch = createFetchImplementation(
148
+ async () =>
149
+ new Response(JSON.stringify({total_requests: 10, failure_count: 3}), {
150
+ status: 200,
151
+ headers: {'content-type': 'application/json'},
152
+ }),
153
+ )
154
+
155
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
156
+
157
+ expect(result.level).toBe('warning')
158
+ expect(result.summary).toContain('total_requests=10, failure_count=3')
159
+ expect(result.summary).toContain('token refresh likely needed')
160
+ })
161
+
162
+ it('returns warning when rate limited', async () => {
163
+ globalThis.fetch = createFetchImplementation(async () => new Response('rate limited', {status: 429}))
164
+
165
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
166
+
167
+ expect(result.level).toBe('warning')
168
+ expect(result.summary).toContain('Rate limited')
169
+ })
170
+
171
+ it('returns error for non-200 responses', async () => {
172
+ globalThis.fetch = createFetchImplementation(async () => new Response('boom', {status: 500}))
173
+
174
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
175
+
176
+ expect(result.level).toBe('error')
177
+ expect(result.summary).toContain('HTTP 500')
178
+ })
179
+
180
+ it('returns error for network failures', async () => {
181
+ globalThis.fetch = createFetchImplementation(async () => {
182
+ throw new Error('socket hang up')
183
+ })
184
+
185
+ const result = await checkUsageStats('https://cliproxy.example.com', 'secret')
186
+
187
+ expect(result.level).toBe('error')
188
+ expect(result.summary).toContain('Unable to read usage stats: socket hang up')
189
+ })
190
+ })
191
+
192
+ describe('checkVersion', () => {
193
+ it('returns ok for JSON string payloads', async () => {
194
+ globalThis.fetch = createFetchImplementation(
195
+ async () =>
196
+ new Response(JSON.stringify('1.2.3'), {
197
+ status: 200,
198
+ headers: {'content-type': 'application/json'},
199
+ }),
200
+ )
201
+
202
+ const result = await checkVersion('https://cliproxy.example.com', 'secret')
203
+
204
+ expect(result.level).toBe('ok')
205
+ expect(result.summary).toBe('1.2.3')
206
+ })
207
+
208
+ it('returns ok for object payloads with version', async () => {
209
+ globalThis.fetch = createFetchImplementation(
210
+ async () =>
211
+ new Response(JSON.stringify({version: '1.2.3'}), {
212
+ status: 200,
213
+ headers: {'content-type': 'application/json'},
214
+ }),
215
+ )
216
+
217
+ const result = await checkVersion('https://cliproxy.example.com', 'secret')
218
+
219
+ expect(result.level).toBe('ok')
220
+ expect(result.summary).toBe('1.2.3')
221
+ })
222
+
223
+ it('returns warning for empty version strings', async () => {
224
+ globalThis.fetch = createFetchImplementation(
225
+ async () =>
226
+ new Response(JSON.stringify({version: ''}), {
227
+ status: 200,
228
+ headers: {'content-type': 'application/json'},
229
+ }),
230
+ )
231
+
232
+ const result = await checkVersion('https://cliproxy.example.com', 'secret')
233
+
234
+ expect(result.level).toBe('warning')
235
+ expect(result.summary).toContain('did not include a usable version string')
236
+ })
237
+
238
+ it('returns warning for missing version fields', async () => {
239
+ globalThis.fetch = createFetchImplementation(
240
+ async () =>
241
+ new Response(JSON.stringify({}), {
242
+ status: 200,
243
+ headers: {'content-type': 'application/json'},
244
+ }),
245
+ )
246
+
247
+ const result = await checkVersion('https://cliproxy.example.com', 'secret')
248
+
249
+ expect(result.level).toBe('warning')
250
+ expect(result.summary).toContain('did not include a usable version string')
251
+ })
252
+
253
+ it('returns warning when rate limited', async () => {
254
+ globalThis.fetch = createFetchImplementation(async () => new Response('rate limited', {status: 429}))
255
+
256
+ const result = await checkVersion('https://cliproxy.example.com', 'secret')
257
+
258
+ expect(result.level).toBe('warning')
259
+ expect(result.summary).toContain('Rate limited')
260
+ })
261
+
262
+ it('returns error for non-200 responses', async () => {
263
+ globalThis.fetch = createFetchImplementation(async () => new Response('boom', {status: 500}))
264
+
265
+ const result = await checkVersion('https://cliproxy.example.com', 'secret')
266
+
267
+ expect(result.level).toBe('error')
268
+ expect(result.summary).toContain('HTTP 500')
269
+ })
270
+ })
271
+ })