@marcusrbrown/infra 0.4.10 → 0.5.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 +2 -1
- package/src/__test__/mcp-ctx-fixture.test.ts +108 -0
- package/src/__test__/mcp-ctx-fixture.ts +104 -0
- package/src/commands/cliproxy/config.test.ts +261 -91
- package/src/commands/cliproxy/config.ts +84 -41
- package/src/commands/cliproxy/keys.test.ts +326 -0
- package/src/commands/cliproxy/keys.ts +116 -60
- package/src/commands/cliproxy/status.test.ts +80 -0
- package/src/commands/cliproxy/status.ts +74 -54
- package/src/commands/gateway/backup.test.ts +66 -1
- package/src/commands/gateway/backup.ts +39 -24
- package/src/commands/gateway/status.test.ts +225 -1
- package/src/commands/gateway/status.ts +69 -41
- package/src/commands/keeweb/status.test.ts +146 -0
- package/src/commands/keeweb/status.ts +32 -32
- package/src/commands/mcp.test.ts +210 -0
- package/src/commands/mcp.ts +32 -3
- package/src/commands/status.test.ts +95 -111
- package/src/commands/status.ts +46 -28
- package/src/lib/action-ctx.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcusrbrown/infra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|
|
@@ -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 {
|
|
1
|
+
import {existsSync, statSync} from 'node:fs'
|
|
2
2
|
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
3
3
|
|
|
4
|
-
import {
|
|
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', () => {
|