@marcusrbrown/infra 0.4.11 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.4.11",
3
+ "version": "0.6.0",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -36,6 +36,7 @@
36
36
  "zod": "^4.3.6"
37
37
  },
38
38
  "devDependencies": {
39
+ "@modelcontextprotocol/sdk": "1.29.0",
39
40
  "yaml": "2.9.0"
40
41
  },
41
42
  "engines": {
@@ -71,7 +71,7 @@ Commands:
71
71
  --key [key] Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
72
72
 
73
73
 
74
- cliproxy login <provider> Run provider login on the remote CLIProxyAPI host and print OAuth URL output.
74
+ cliproxy login <provider> Run provider login on the remote CLIProxyAPI host. Supported providers: claude, codex.
75
75
 
76
76
  --host [host] CLIProxyAPI droplet host for SSH execution. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.
77
77
 
@@ -0,0 +1,108 @@
1
+ import {describe, expect, it} from 'bun:test'
2
+
3
+ import {createCapturedCtx, expectCapturedToInclude} from './mcp-ctx-fixture'
4
+
5
+ describe('createCapturedCtx', () => {
6
+ describe('ctx.console.log', () => {
7
+ it('populates captured.stdout with a single string arg', () => {
8
+ const {ctx, captured} = createCapturedCtx()
9
+ ctx.console.log('hello')
10
+ expect(captured.stdout).toEqual(['hello'])
11
+ })
12
+
13
+ it('space-joins multiple args matching console.log behavior', () => {
14
+ const {ctx, captured} = createCapturedCtx()
15
+ ctx.console.log('foo', 'bar', 42)
16
+ expect(captured.stdout).toEqual(['foo bar 42'])
17
+ })
18
+ })
19
+
20
+ describe('ctx.process.stdout.write', () => {
21
+ it('pushes a string chunk to captured.stdout', () => {
22
+ const {ctx, captured} = createCapturedCtx()
23
+ ctx.process.stdout.write('chunk')
24
+ expect(captured.stdout).toEqual(['chunk'])
25
+ })
26
+
27
+ it('decodes a Uint8Array chunk as utf-8 and pushes to captured.stdout', () => {
28
+ const {ctx, captured} = createCapturedCtx()
29
+ ctx.process.stdout.write(new TextEncoder().encode('buf'))
30
+ expect(captured.stdout).toEqual(['buf'])
31
+ })
32
+ })
33
+
34
+ describe('ctx.process.stderr.write', () => {
35
+ it('pushes a string chunk to captured.stderr', () => {
36
+ const {ctx, captured} = createCapturedCtx()
37
+ ctx.process.stderr.write('err-chunk')
38
+ expect(captured.stderr).toEqual(['err-chunk'])
39
+ })
40
+ })
41
+
42
+ describe('ctx.console.error', () => {
43
+ it('pushes formatted string to captured.stderr', () => {
44
+ const {ctx, captured} = createCapturedCtx()
45
+ ctx.console.error('oops', 99)
46
+ expect(captured.stderr).toEqual(['oops 99'])
47
+ })
48
+ })
49
+
50
+ describe('ctx.process.exit', () => {
51
+ it('throws after populating captured.exit with code 1', () => {
52
+ const {ctx, captured} = createCapturedCtx()
53
+ let threw = false
54
+ try {
55
+ ctx.process.exit(1)
56
+ } catch {
57
+ threw = true
58
+ }
59
+ expect(threw).toBe(true)
60
+ expect(captured.exit).toEqual({code: 1})
61
+ })
62
+
63
+ it('also throws for exit code 0', () => {
64
+ const {ctx, captured} = createCapturedCtx()
65
+ let threw = false
66
+ try {
67
+ ctx.process.exit(0)
68
+ } catch {
69
+ threw = true
70
+ }
71
+ expect(threw).toBe(true)
72
+ expect(captured.exit).toEqual({code: 0})
73
+ })
74
+ })
75
+
76
+ it('starts with empty stdout, stderr, and null exit', () => {
77
+ const {captured} = createCapturedCtx()
78
+ expect(captured.stdout).toEqual([])
79
+ expect(captured.stderr).toEqual([])
80
+ expect(captured.exit).toBeNull()
81
+ })
82
+ })
83
+
84
+ describe('expectCapturedToInclude', () => {
85
+ it('returns true when stdout contains the string marker', () => {
86
+ const {ctx, captured} = createCapturedCtx()
87
+ ctx.console.log('hello world')
88
+ expect(expectCapturedToInclude(captured, 'hello')).toBe(true)
89
+ })
90
+
91
+ it('returns false when stdout does not contain the string marker', () => {
92
+ const {ctx, captured} = createCapturedCtx()
93
+ ctx.console.log('goodbye')
94
+ expect(expectCapturedToInclude(captured, 'hello')).toBe(false)
95
+ })
96
+
97
+ it('returns true when stdout matches a regex marker', () => {
98
+ const {ctx, captured} = createCapturedCtx()
99
+ ctx.console.log('hello world')
100
+ expect(expectCapturedToInclude(captured, /^hello/)).toBe(true)
101
+ })
102
+
103
+ it('returns false when stdout does not match a regex marker', () => {
104
+ const {ctx, captured} = createCapturedCtx()
105
+ ctx.console.log('hello world')
106
+ expect(expectCapturedToInclude(captured, /^world/)).toBe(false)
107
+ })
108
+ })
@@ -0,0 +1,104 @@
1
+ import {format} from 'node:util'
2
+
3
+ export type {ActionCtx} from '../lib/action-ctx'
4
+
5
+ /**
6
+ * Captured output from a `createCapturedCtx()` invocation.
7
+ */
8
+ export interface CapturedOutput {
9
+ stdout: string[]
10
+ stderr: string[]
11
+ exit: {code: number} | null
12
+ }
13
+
14
+ /**
15
+ * Error thrown by `ctx.process.exit()` in the mock context.
16
+ * Mirrors the `GokeProcessExit` pattern from goke — exit always throws
17
+ * so action code cannot silently swallow it.
18
+ */
19
+ export class MockProcessExit extends Error {
20
+ readonly code: number
21
+
22
+ constructor(code: number) {
23
+ super(`MockProcessExit: process.exit(${code})`)
24
+ this.name = 'MockProcessExit'
25
+ this.code = code
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Minimal surface of `GokeExecutionContext` that the capture fixture provides.
31
+ * Matches `ctx.console.{log,error}`, `ctx.process.{stdout,stderr}.write`,
32
+ * and `ctx.process.exit` — the same shape produced by
33
+ * `createCallToolExecutionContext` in `@goke/mcp`.
34
+ */
35
+ export interface CapturedCtx {
36
+ console: {
37
+ log: (...args: unknown[]) => void
38
+ error: (...args: unknown[]) => void
39
+ }
40
+ process: {
41
+ stdout: {write: (chunk: string | Uint8Array) => void}
42
+ stderr: {write: (chunk: string | Uint8Array) => void}
43
+ exit: (code: number) => never
44
+ }
45
+ }
46
+
47
+ function decodeChunk(chunk: string | Uint8Array): string {
48
+ if (typeof chunk === 'string') return chunk
49
+ return new TextDecoder().decode(chunk)
50
+ }
51
+
52
+ /**
53
+ * Create a fresh mock `GokeExecutionContext` with output capture.
54
+ *
55
+ * Each call returns an independent `{ctx, captured}` pair — no shared state.
56
+ * Use one call per test to keep tests isolated.
57
+ */
58
+ export function createCapturedCtx(): {ctx: CapturedCtx; captured: CapturedOutput} {
59
+ const captured: CapturedOutput = {
60
+ stdout: [],
61
+ stderr: [],
62
+ exit: null,
63
+ }
64
+
65
+ const ctx: CapturedCtx = {
66
+ console: {
67
+ log(...args: unknown[]) {
68
+ captured.stdout.push(format(...args))
69
+ },
70
+ error(...args: unknown[]) {
71
+ captured.stderr.push(format(...args))
72
+ },
73
+ },
74
+ process: {
75
+ stdout: {
76
+ write(chunk: string | Uint8Array) {
77
+ captured.stdout.push(decodeChunk(chunk))
78
+ },
79
+ },
80
+ stderr: {
81
+ write(chunk: string | Uint8Array) {
82
+ captured.stderr.push(decodeChunk(chunk))
83
+ },
84
+ },
85
+ exit(code: number): never {
86
+ captured.exit = {code}
87
+ throw new MockProcessExit(code)
88
+ },
89
+ },
90
+ }
91
+
92
+ return {ctx, captured}
93
+ }
94
+
95
+ /**
96
+ * Returns `true` when the concatenated stdout contains `marker`.
97
+ *
98
+ * Composable — does not throw. Use with `expect(...).toBe(true)`.
99
+ */
100
+ export function expectCapturedToInclude(captured: CapturedOutput, marker: string | RegExp): boolean {
101
+ const text = captured.stdout.join('')
102
+ if (typeof marker === 'string') return text.includes(marker)
103
+ return marker.test(text)
104
+ }
@@ -1,10 +1,36 @@
1
- import {chmodSync, existsSync, statSync} from 'node:fs'
1
+ import {existsSync, statSync} from 'node:fs'
2
2
  import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
3
3
 
4
- import {buildSetRequest, formatConfigAsColumns, parseBoolean, parseNumber, resolveManagementKey} from './config'
4
+ import {createCapturedCtx, expectCapturedToInclude} from '../../__test__/mcp-ctx-fixture'
5
+ import {
6
+ buildSetRequest,
7
+ cliproxyConfigGetAction,
8
+ cliproxyConfigSetAction,
9
+ formatConfigAsColumns,
10
+ parseBoolean,
11
+ parseNumber,
12
+ resolveManagementKey,
13
+ } from './config'
5
14
  import {toStringArray} from './keys'
6
15
  import {requireSshAuthSock, resolveHost} from './login'
7
16
 
17
+ const originalFetch = globalThis.fetch
18
+
19
+ type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
20
+
21
+ function createFetchImplementation(handler: FetchReplacement): typeof fetch {
22
+ return Object.assign(
23
+ (input: string | URL | Request, init?: RequestInit) => {
24
+ if (typeof input !== 'string') {
25
+ throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
26
+ }
27
+
28
+ return handler(input, init)
29
+ },
30
+ {preconnect: originalFetch.preconnect},
31
+ )
32
+ }
33
+
8
34
  describe('cliproxy config helpers', () => {
9
35
  describe('parseBoolean', () => {
10
36
  it('parses true values case-insensitively', () => {
@@ -100,95 +126,6 @@ describe('cliproxy config helpers', () => {
100
126
  expect(() => resolveManagementKey()).toThrow()
101
127
  })
102
128
  })
103
-
104
- describe('config get --output', () => {
105
- it('writes config JSON to file with 0600 permissions', async () => {
106
- const testFile = '/tmp/test-config-output.json'
107
- const mockConfig = {debug: true, 'api-keys': ['key1', 'key2']}
108
-
109
- const originalFetch = globalThis.fetch as typeof fetch
110
- ;(globalThis.fetch as unknown) = async () => {
111
- return new Response(JSON.stringify(mockConfig), {status: 200})
112
- }
113
-
114
- try {
115
- const baseUrl = 'https://cliproxy.example.com'
116
- const managementKey = 'test-key'
117
- const endpoint = `${baseUrl}/v0/management/config`
118
- const response = await fetch(endpoint, {
119
- method: 'GET',
120
- headers: new Headers({
121
- 'x-management-key': managementKey,
122
- 'content-type': 'application/json',
123
- }),
124
- })
125
- const payload = await response.json()
126
- const jsonOutput = JSON.stringify(payload, null, 2)
127
-
128
- await Bun.write(testFile, jsonOutput)
129
- chmodSync(testFile, 0o600)
130
- const {mode} = statSync(testFile)
131
- const permissions = mode & 0o777
132
-
133
- expect(existsSync(testFile)).toBe(true)
134
- expect(permissions).toBe(0o600)
135
-
136
- const content = await Bun.file(testFile).text()
137
- expect(JSON.parse(content)).toEqual(mockConfig)
138
- } finally {
139
- ;(globalThis.fetch as unknown) = originalFetch
140
- if (existsSync(testFile)) {
141
- const fs = await import('node:fs/promises')
142
- await fs.unlink(testFile).catch(() => {})
143
- }
144
- }
145
- })
146
-
147
- it('prints API key warning to stderr when writing to stdout', async () => {
148
- const mockConfig = {debug: true, 'api-keys': ['secret-key']}
149
- const stderrLines: string[] = []
150
- const stdoutLines: string[] = []
151
-
152
- const originalError = console.error
153
- const originalLog = console.log
154
- console.error = (...args: unknown[]) => {
155
- stderrLines.push(String(args[0]))
156
- }
157
- console.log = (...args: unknown[]) => {
158
- stdoutLines.push(String(args[0]))
159
- }
160
-
161
- const originalFetch = globalThis.fetch as typeof fetch
162
- ;(globalThis.fetch as unknown) = async () => {
163
- return new Response(JSON.stringify(mockConfig), {status: 200})
164
- }
165
-
166
- try {
167
- const baseUrl = 'https://cliproxy.example.com'
168
- const managementKey = 'test-key'
169
- const endpoint = `${baseUrl}/v0/management/config`
170
- const response = await fetch(endpoint, {
171
- method: 'GET',
172
- headers: new Headers({
173
- 'x-management-key': managementKey,
174
- 'content-type': 'application/json',
175
- }),
176
- })
177
- const payload = await response.json()
178
- const jsonOutput = JSON.stringify(payload, null, 2)
179
-
180
- console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
181
- console.log(jsonOutput)
182
-
183
- expect(stderrLines.some(line => line.includes('API keys'))).toBe(true)
184
- expect(stdoutLines.some(line => line.includes('debug'))).toBe(true)
185
- } finally {
186
- console.error = originalError
187
- console.log = originalLog
188
- ;(globalThis.fetch as unknown) = originalFetch
189
- }
190
- })
191
- })
192
129
  })
193
130
 
194
131
  describe('formatConfigAsColumns', () => {
@@ -217,6 +154,239 @@ describe('formatConfigAsColumns', () => {
217
154
  })
218
155
  })
219
156
 
157
+ describe('cliproxyConfigGetAction (Mode C, Tier-2 ctx capture)', () => {
158
+ afterEach(() => {
159
+ globalThis.fetch = originalFetch
160
+ })
161
+
162
+ it('Mode C: captures formatted config to ctx.stdout and returns config object', async () => {
163
+ const mockConfig = {debug: true, 'request-retry': 3}
164
+ globalThis.fetch = createFetchImplementation(
165
+ async () =>
166
+ new Response(JSON.stringify(mockConfig), {
167
+ status: 200,
168
+ headers: {'content-type': 'application/json'},
169
+ }),
170
+ )
171
+
172
+ const {ctx, captured} = createCapturedCtx()
173
+ const result = await cliproxyConfigGetAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
174
+
175
+ // Tier-2: stdout contains formatted config
176
+ expect(expectCapturedToInclude(captured, 'debug')).toBe(true)
177
+ expect(expectCapturedToInclude(captured, 'request-retry')).toBe(true)
178
+
179
+ // Mode C: action returns the config object
180
+ expect(result).toEqual(mockConfig)
181
+ })
182
+
183
+ it('Mode C: security warning goes to ctx.stderr', async () => {
184
+ const mockConfig = {debug: false}
185
+ globalThis.fetch = createFetchImplementation(
186
+ async () =>
187
+ new Response(JSON.stringify(mockConfig), {
188
+ status: 200,
189
+ headers: {'content-type': 'application/json'},
190
+ }),
191
+ )
192
+
193
+ const {ctx, captured} = createCapturedCtx()
194
+ await cliproxyConfigGetAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
195
+
196
+ const stderrText = captured.stderr.join('')
197
+ expect(stderrText).toContain('API keys')
198
+ })
199
+
200
+ it('Mode C: --json flag outputs raw JSON to ctx.stdout', async () => {
201
+ const mockConfig = {debug: true}
202
+ globalThis.fetch = createFetchImplementation(
203
+ async () =>
204
+ new Response(JSON.stringify(mockConfig), {
205
+ status: 200,
206
+ headers: {'content-type': 'application/json'},
207
+ }),
208
+ )
209
+
210
+ const {ctx, captured} = createCapturedCtx()
211
+ const result = await cliproxyConfigGetAction(
212
+ {url: 'https://cliproxy.example.com', key: 'mgmt-key', json: true},
213
+ ctx,
214
+ )
215
+
216
+ const stdoutText = captured.stdout.join('')
217
+ expect(JSON.parse(stdoutText)).toEqual(mockConfig)
218
+ expect(result).toEqual(mockConfig)
219
+ })
220
+
221
+ it('Mode C: --output writes file and prints confirmation to ctx.stdout', async () => {
222
+ const testFile = '/tmp/test-cliproxy-config-get-action.json'
223
+ const mockConfig = {debug: true, 'api-keys': ['key1', 'key2']}
224
+ globalThis.fetch = createFetchImplementation(
225
+ async () =>
226
+ new Response(JSON.stringify(mockConfig), {
227
+ status: 200,
228
+ headers: {'content-type': 'application/json'},
229
+ }),
230
+ )
231
+
232
+ const {ctx, captured} = createCapturedCtx()
233
+ try {
234
+ const result = await cliproxyConfigGetAction(
235
+ {url: 'https://cliproxy.example.com', key: 'mgmt-key', output: testFile},
236
+ ctx,
237
+ )
238
+
239
+ expect(existsSync(testFile)).toBe(true)
240
+ const {mode} = statSync(testFile)
241
+ expect(mode & 0o777).toBe(0o600)
242
+ expect(JSON.parse(await Bun.file(testFile).text())).toEqual(mockConfig)
243
+ expect(expectCapturedToInclude(captured, '✓ Config written to')).toBe(true)
244
+ expect(result).toEqual(mockConfig)
245
+ } finally {
246
+ if (existsSync(testFile)) {
247
+ const fs = await import('node:fs/promises')
248
+ await fs.unlink(testFile).catch(() => {})
249
+ }
250
+ }
251
+ })
252
+ })
253
+
254
+ describe('cliproxyConfigSetAction (Mode A, two positional args, Tier-2 ctx capture)', () => {
255
+ afterEach(() => {
256
+ globalThis.fetch = originalFetch
257
+ })
258
+
259
+ it('Mode A: captures PUT response to ctx.stdout', async () => {
260
+ globalThis.fetch = createFetchImplementation(
261
+ async () =>
262
+ new Response(JSON.stringify({value: true}), {
263
+ status: 200,
264
+ headers: {'content-type': 'application/json'},
265
+ }),
266
+ )
267
+
268
+ const {ctx, captured} = createCapturedCtx()
269
+ await cliproxyConfigSetAction('debug', 'true', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
270
+
271
+ expect(expectCapturedToInclude(captured, 'value')).toBe(true)
272
+ expect(expectCapturedToInclude(captured, 'true')).toBe(true)
273
+ })
274
+
275
+ it('Mode A: routes unsupported field error through ctx.console.error + exit(1)', async () => {
276
+ const {ctx, captured} = createCapturedCtx()
277
+ await expect(
278
+ cliproxyConfigSetAction('unsupported-field', 'val', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
279
+ ).rejects.toMatchObject({name: 'MockProcessExit', code: 1})
280
+ expect(captured.stderr.join('')).toContain('not a supported mutable field')
281
+ expect(captured.exit).toEqual({code: 1})
282
+ })
283
+ })
284
+
285
+ describe('cliproxyConfigGetAction (Tier-2 failure-path parity)', () => {
286
+ const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
287
+
288
+ afterEach(() => {
289
+ globalThis.fetch = originalFetch
290
+ if (originalManagementKey === undefined) {
291
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
292
+ } else {
293
+ process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
294
+ }
295
+ })
296
+
297
+ it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
298
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
299
+
300
+ const {ctx, captured} = createCapturedCtx()
301
+ await expect(cliproxyConfigGetAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
302
+ name: 'MockProcessExit',
303
+ code: 1,
304
+ })
305
+ expect(captured.stderr.join('')).toContain('Management API key')
306
+ expect(captured.exit).toEqual({code: 1})
307
+ })
308
+
309
+ it('Tier-2: HTTP 500 response routes to ctx.console.error + exit(1)', async () => {
310
+ globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
311
+
312
+ const {ctx, captured} = createCapturedCtx()
313
+ await expect(
314
+ cliproxyConfigGetAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
315
+ ).rejects.toMatchObject({
316
+ name: 'MockProcessExit',
317
+ code: 1,
318
+ })
319
+ expect(captured.stderr.join('')).toContain('HTTP 500')
320
+ expect(captured.exit).toEqual({code: 1})
321
+ })
322
+
323
+ it('Tier-2: --output write failure routes to ctx.console.error + exit(1)', async () => {
324
+ const mockConfig = {debug: true}
325
+ globalThis.fetch = createFetchImplementation(
326
+ async () =>
327
+ new Response(JSON.stringify(mockConfig), {
328
+ status: 200,
329
+ headers: {'content-type': 'application/json'},
330
+ }),
331
+ )
332
+
333
+ // Use a path that cannot be written (directory as file path)
334
+ const {ctx, captured} = createCapturedCtx()
335
+ await expect(
336
+ cliproxyConfigGetAction(
337
+ {url: 'https://cliproxy.example.com', key: 'mgmt-key', output: '/dev/null/cannot-write'},
338
+ ctx,
339
+ ),
340
+ ).rejects.toMatchObject({
341
+ name: 'MockProcessExit',
342
+ code: 1,
343
+ })
344
+ expect(captured.stderr.join('')).toContain('Failed to write config')
345
+ expect(captured.exit).toEqual({code: 1})
346
+ })
347
+ })
348
+
349
+ describe('cliproxyConfigSetAction (Tier-2 failure-path parity)', () => {
350
+ const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
351
+
352
+ afterEach(() => {
353
+ globalThis.fetch = originalFetch
354
+ if (originalManagementKey === undefined) {
355
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
356
+ } else {
357
+ process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
358
+ }
359
+ })
360
+
361
+ it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
362
+ delete process.env.CLIPROXY_MANAGEMENT_KEY
363
+
364
+ const {ctx, captured} = createCapturedCtx()
365
+ await expect(
366
+ cliproxyConfigSetAction('debug', 'true', {url: 'https://cliproxy.example.com'}, ctx),
367
+ ).rejects.toMatchObject({
368
+ name: 'MockProcessExit',
369
+ code: 1,
370
+ })
371
+ expect(captured.stderr.join('')).toContain('Management API key')
372
+ expect(captured.exit).toEqual({code: 1})
373
+ })
374
+
375
+ it('Tier-2: HTTP 500 response routes to ctx.console.error + exit(1)', async () => {
376
+ globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
377
+
378
+ const {ctx, captured} = createCapturedCtx()
379
+ await expect(
380
+ cliproxyConfigSetAction('debug', 'true', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
381
+ ).rejects.toMatchObject({
382
+ name: 'MockProcessExit',
383
+ code: 1,
384
+ })
385
+ expect(captured.stderr.join('')).toContain('HTTP 500')
386
+ expect(captured.exit).toEqual({code: 1})
387
+ })
388
+ })
389
+
220
390
  describe('cliproxy keys helpers', () => {
221
391
  describe('toStringArray', () => {
222
392
  it('returns string arrays filtered to strings only', () => {