@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 +2 -1
- package/src/__snapshots__/cli.test.ts.snap +1 -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/host.test.ts +73 -0
- package/src/commands/cliproxy/host.ts +31 -0
- package/src/commands/cliproxy/keys.test.ts +326 -0
- package/src/commands/cliproxy/keys.ts +116 -60
- package/src/commands/cliproxy/login.test.ts +203 -49
- package/src/commands/cliproxy/login.ts +83 -56
- package/src/commands/cliproxy/open.test.ts +20 -0
- package/src/commands/cliproxy/open.ts +4 -1
- 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 +130 -1
- package/src/commands/gateway/status.ts +50 -39
- 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
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type {goke} from 'goke'
|
|
2
2
|
|
|
3
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
3
4
|
import {chmodSync} from 'node:fs'
|
|
5
|
+
|
|
4
6
|
import {z} from 'zod'
|
|
5
7
|
|
|
8
|
+
/** Minimal ctx surface consumed by cliproxy config actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
9
|
+
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
10
|
+
|
|
6
11
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
7
12
|
const HTTP_TIMEOUT_MS = 10_000
|
|
8
13
|
|
|
@@ -132,6 +137,83 @@ export function formatConfigAsColumns(payload: unknown): string {
|
|
|
132
137
|
.join('\n')
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
export interface ConfigGetOptions {
|
|
141
|
+
url?: string
|
|
142
|
+
key?: string
|
|
143
|
+
output?: string
|
|
144
|
+
json?: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function cliproxyConfigGetAction(options: ConfigGetOptions, ctx: ActionCtx): Promise<unknown> {
|
|
148
|
+
try {
|
|
149
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
150
|
+
const managementKey = resolveManagementKey(options.key)
|
|
151
|
+
const endpoint = `${baseUrl}/v0/management/config`
|
|
152
|
+
const payload = await requestJson(endpoint, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: managementHeaders(managementKey),
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const jsonOutput = JSON.stringify(payload, null, 2)
|
|
158
|
+
|
|
159
|
+
if (options.output) {
|
|
160
|
+
try {
|
|
161
|
+
await Bun.write(options.output, jsonOutput)
|
|
162
|
+
chmodSync(options.output, 0o600)
|
|
163
|
+
ctx.console.log(`✓ Config written to ${options.output}`)
|
|
164
|
+
} catch (error: unknown) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
166
|
+
ctx.console.error(`Failed to write config to ${options.output}: ${message}`)
|
|
167
|
+
ctx.process.exit(1)
|
|
168
|
+
return undefined // unreachable
|
|
169
|
+
}
|
|
170
|
+
} else if (options.json) {
|
|
171
|
+
ctx.console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
|
|
172
|
+
ctx.console.log(jsonOutput)
|
|
173
|
+
} else {
|
|
174
|
+
ctx.console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
|
|
175
|
+
ctx.console.log(formatConfigAsColumns(payload))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return payload
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
181
|
+
ctx.console.error(message)
|
|
182
|
+
ctx.process.exit(1)
|
|
183
|
+
return undefined // unreachable; satisfies TS that all paths return
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface ConfigSetOptions {
|
|
188
|
+
url?: string
|
|
189
|
+
key?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function cliproxyConfigSetAction(
|
|
193
|
+
field: string,
|
|
194
|
+
value: string,
|
|
195
|
+
options: ConfigSetOptions,
|
|
196
|
+
ctx: ActionCtx,
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
200
|
+
const managementKey = resolveManagementKey(options.key)
|
|
201
|
+
const request = buildSetRequest(baseUrl, field, value)
|
|
202
|
+
|
|
203
|
+
const payload = await requestJson(request.endpoint, {
|
|
204
|
+
method: 'PUT',
|
|
205
|
+
headers: managementHeaders(managementKey),
|
|
206
|
+
body: request.body,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
ctx.console.log(JSON.stringify(payload, null, 2))
|
|
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
|
+
|
|
135
217
|
export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
|
|
136
218
|
cli
|
|
137
219
|
.command('cliproxy config get', 'Fetch current CLIProxyAPI config as JSON.')
|
|
@@ -154,34 +236,7 @@ export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
|
|
|
154
236
|
.describe('Write config JSON to a file instead of stdout. File permissions set to 0600 (owner-read-only).'),
|
|
155
237
|
)
|
|
156
238
|
.option('--json', 'Output raw JSON instead of aligned key: value columns.')
|
|
157
|
-
.action(
|
|
158
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
159
|
-
const managementKey = resolveManagementKey(options.key)
|
|
160
|
-
const endpoint = `${baseUrl}/v0/management/config`
|
|
161
|
-
const payload = await requestJson(endpoint, {
|
|
162
|
-
method: 'GET',
|
|
163
|
-
headers: managementHeaders(managementKey),
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
const jsonOutput = JSON.stringify(payload, null, 2)
|
|
167
|
-
|
|
168
|
-
if (options.output) {
|
|
169
|
-
try {
|
|
170
|
-
await Bun.write(options.output, jsonOutput)
|
|
171
|
-
chmodSync(options.output, 0o600)
|
|
172
|
-
console.log(`✓ Config written to ${options.output}`)
|
|
173
|
-
} catch (error: unknown) {
|
|
174
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
175
|
-
throw new Error(`Failed to write config to ${options.output}: ${message}`)
|
|
176
|
-
}
|
|
177
|
-
} else if (options.json) {
|
|
178
|
-
console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
|
|
179
|
-
console.log(jsonOutput)
|
|
180
|
-
} else {
|
|
181
|
-
console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
|
|
182
|
-
console.log(formatConfigAsColumns(payload))
|
|
183
|
-
}
|
|
184
|
-
})
|
|
239
|
+
.action(cliproxyConfigGetAction)
|
|
185
240
|
|
|
186
241
|
cli
|
|
187
242
|
.command(
|
|
@@ -203,17 +258,5 @@ export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
|
|
|
203
258
|
.example('infra cliproxy config set debug true')
|
|
204
259
|
.example('infra cliproxy config set request-retry 5')
|
|
205
260
|
.example('infra cliproxy config set proxy-url https://proxy.example.com')
|
|
206
|
-
.action(
|
|
207
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
208
|
-
const managementKey = resolveManagementKey(options.key)
|
|
209
|
-
const request = buildSetRequest(baseUrl, field, value)
|
|
210
|
-
|
|
211
|
-
const payload = await requestJson(request.endpoint, {
|
|
212
|
-
method: 'PUT',
|
|
213
|
-
headers: managementHeaders(managementKey),
|
|
214
|
-
body: request.body,
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
console.log(JSON.stringify(payload, null, 2))
|
|
218
|
-
})
|
|
261
|
+
.action(cliproxyConfigSetAction)
|
|
219
262
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {validateCliproxyHost} from './host'
|
|
4
|
+
|
|
5
|
+
// ─── validateCliproxyHost ─────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe('validateCliproxyHost', () => {
|
|
8
|
+
// ── Valid inputs ────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
it('accepts a standard FQDN', () => {
|
|
11
|
+
expect(() => validateCliproxyHost('cliproxy.fro.bot')).not.toThrow()
|
|
12
|
+
expect(validateCliproxyHost('cliproxy.fro.bot')).toBe('cliproxy.fro.bot')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('accepts localhost', () => {
|
|
16
|
+
expect(() => validateCliproxyHost('localhost')).not.toThrow()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('accepts an IPv4 address', () => {
|
|
20
|
+
expect(() => validateCliproxyHost('147.182.133.210')).not.toThrow()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('accepts a single-character hostname', () => {
|
|
24
|
+
expect(() => validateCliproxyHost('a')).not.toThrow()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('accepts a hostname with hyphens', () => {
|
|
28
|
+
expect(() => validateCliproxyHost('my-cliproxy.prod.example.com')).not.toThrow()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// ── Injection attacks ───────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
it('rejects a leading-hyphen value (ProxyCommand injection vector)', () => {
|
|
34
|
+
expect(() => validateCliproxyHost('-oProxyCommand=evil')).toThrow('Invalid CLIPROXY_DOMAIN')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects a value with shell metacharacters (semicolon)', () => {
|
|
38
|
+
expect(() => validateCliproxyHost('cliproxy.fro.bot;rm -rf')).toThrow('Invalid CLIPROXY_DOMAIN')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects a value with shell metacharacters (backtick)', () => {
|
|
42
|
+
expect(() => validateCliproxyHost('cliproxy.fro.bot`id`')).toThrow('Invalid CLIPROXY_DOMAIN')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('rejects a value with spaces', () => {
|
|
46
|
+
expect(() => validateCliproxyHost('cliproxy fro.bot')).toThrow('Invalid CLIPROXY_DOMAIN')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rejects a value with an at-sign', () => {
|
|
50
|
+
expect(() => validateCliproxyHost('user@cliproxy.fro.bot')).toThrow('Invalid CLIPROXY_DOMAIN')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ── Empty / blank ───────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
it('rejects an empty string', () => {
|
|
56
|
+
expect(() => validateCliproxyHost('')).toThrow('Invalid CLIPROXY_DOMAIN')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ── Error message sanitization ──────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
it('truncates the invalid value in the error message to ~30 chars', () => {
|
|
62
|
+
const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
|
|
63
|
+
let message = ''
|
|
64
|
+
try {
|
|
65
|
+
validateCliproxyHost(longMalicious)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
message = error instanceof Error ? error.message : String(error)
|
|
68
|
+
}
|
|
69
|
+
// The excerpt in the message should not exceed 30 chars of the original value
|
|
70
|
+
expect(message).toContain('Invalid CLIPROXY_DOMAIN')
|
|
71
|
+
expect(message.length).toBeLessThan(longMalicious.length + 50)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// ─── Cliproxy host validation ─────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Validates CLIPROXY_DOMAIN values before they are passed as ssh argv arguments.
|
|
4
|
+
// A value starting with `-` would be interpreted by ssh as an option flag,
|
|
5
|
+
// enabling ProxyCommand injection and local code execution.
|
|
6
|
+
|
|
7
|
+
const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validates a candidate CLIPROXY_DOMAIN value against a strict hostname allowlist.
|
|
11
|
+
*
|
|
12
|
+
* Accepts: hostnames, FQDNs, IPv4 addresses, `localhost`.
|
|
13
|
+
* Rejects: empty strings, values starting with `-`, and anything containing
|
|
14
|
+
* characters outside `[A-Za-z0-9.-]`.
|
|
15
|
+
*
|
|
16
|
+
* @throws {Error} with a sanitized excerpt of the invalid value.
|
|
17
|
+
* @returns The validated host string (unchanged).
|
|
18
|
+
*/
|
|
19
|
+
export function validateCliproxyHost(host: string): string {
|
|
20
|
+
if (!host) {
|
|
21
|
+
throw new Error('Invalid CLIPROXY_DOMAIN: value is empty')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!VALID_HOST_RE.test(host)) {
|
|
25
|
+
// Truncate to 30 chars and strip non-printable bytes before echoing back
|
|
26
|
+
const excerpt = host.slice(0, 30).replaceAll(/[^\u0020-\u007E]/g, '?')
|
|
27
|
+
throw new Error(`Invalid CLIPROXY_DOMAIN: "${excerpt}" — must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return host
|
|
31
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {createCapturedCtx, expectCapturedToInclude} from '../../__test__/mcp-ctx-fixture'
|
|
4
|
+
import {cliproxyKeysAddAction, cliproxyKeysListAction, cliproxyKeysRemoveAction, toStringArray} from './keys'
|
|
5
|
+
|
|
6
|
+
const originalFetch = globalThis.fetch
|
|
7
|
+
|
|
8
|
+
type FetchReplacement = (url: string, init?: RequestInit) => Promise<Response>
|
|
9
|
+
|
|
10
|
+
function createFetchImplementation(handler: FetchReplacement): typeof fetch {
|
|
11
|
+
return Object.assign(
|
|
12
|
+
(input: string | URL | Request, init?: RequestInit) => {
|
|
13
|
+
if (typeof input !== 'string') {
|
|
14
|
+
throw new TypeError(`Unexpected non-string fetch input: ${String(input)}`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return handler(input, init)
|
|
18
|
+
},
|
|
19
|
+
{preconnect: originalFetch.preconnect},
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('toStringArray', () => {
|
|
24
|
+
it('returns string array as-is', () => {
|
|
25
|
+
expect(toStringArray(['a', 'b', 'c'])).toEqual(['a', 'b', 'c'])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('filters non-string items from array', () => {
|
|
29
|
+
expect(toStringArray(['a', 1, null, 'b'])).toEqual(['a', 'b'])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('extracts api-keys from object', () => {
|
|
33
|
+
expect(toStringArray({'api-keys': ['x', 'y']})).toEqual(['x', 'y'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('extracts api_keys from object (underscore variant)', () => {
|
|
37
|
+
expect(toStringArray({api_keys: ['x', 'y']})).toEqual(['x', 'y'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns empty array for null', () => {
|
|
41
|
+
expect(toStringArray(null)).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns empty array for unrecognized shape', () => {
|
|
45
|
+
expect(toStringArray({other: 'stuff'})).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('cliproxyKeysListAction (Mode C, Tier-2 ctx capture)', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
globalThis.fetch = createFetchImplementation(async () => {
|
|
52
|
+
throw new Error('Unexpected fetch call')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
globalThis.fetch = originalFetch
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('Mode C: captures numbered list to ctx.stdout and returns key array', async () => {
|
|
61
|
+
globalThis.fetch = createFetchImplementation(
|
|
62
|
+
async () =>
|
|
63
|
+
new Response(JSON.stringify(['sk-live-aaa', 'sk-live-bbb']), {
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: {'content-type': 'application/json'},
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const {ctx, captured} = createCapturedCtx()
|
|
70
|
+
const result = await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
|
|
71
|
+
|
|
72
|
+
// Tier-2: stdout contains formatted list
|
|
73
|
+
expect(expectCapturedToInclude(captured, 'sk-live-aaa')).toBe(true)
|
|
74
|
+
expect(expectCapturedToInclude(captured, '1.')).toBe(true)
|
|
75
|
+
expect(expectCapturedToInclude(captured, '2.')).toBe(true)
|
|
76
|
+
|
|
77
|
+
// Mode C: action returns the parsed array
|
|
78
|
+
expect(Array.isArray(result)).toBe(true)
|
|
79
|
+
expect(result).toEqual(['sk-live-aaa', 'sk-live-bbb'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('Mode C: security warning goes to ctx.stderr', async () => {
|
|
83
|
+
globalThis.fetch = createFetchImplementation(
|
|
84
|
+
async () =>
|
|
85
|
+
new Response(JSON.stringify(['sk-live-aaa']), {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: {'content-type': 'application/json'},
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const {ctx, captured} = createCapturedCtx()
|
|
92
|
+
await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
|
|
93
|
+
|
|
94
|
+
const stderrText = captured.stderr.join('')
|
|
95
|
+
expect(stderrText).toContain('API keys')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('Mode C: returns empty array and prints "No API keys configured" when list is empty', async () => {
|
|
99
|
+
globalThis.fetch = createFetchImplementation(
|
|
100
|
+
async () =>
|
|
101
|
+
new Response(JSON.stringify([]), {
|
|
102
|
+
status: 200,
|
|
103
|
+
headers: {'content-type': 'application/json'},
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const {ctx, captured} = createCapturedCtx()
|
|
108
|
+
const result = await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
|
|
109
|
+
|
|
110
|
+
expect(expectCapturedToInclude(captured, 'No API keys configured')).toBe(true)
|
|
111
|
+
expect(result).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('Mode C: --json flag outputs JSON array to ctx.stdout', async () => {
|
|
115
|
+
globalThis.fetch = createFetchImplementation(
|
|
116
|
+
async () =>
|
|
117
|
+
new Response(JSON.stringify(['sk-live-aaa']), {
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: {'content-type': 'application/json'},
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const {ctx, captured} = createCapturedCtx()
|
|
124
|
+
const result = await cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key', json: true}, ctx)
|
|
125
|
+
|
|
126
|
+
const stdoutText = captured.stdout.join('')
|
|
127
|
+
const parsed = JSON.parse(stdoutText)
|
|
128
|
+
expect(parsed).toEqual(['sk-live-aaa'])
|
|
129
|
+
expect(result).toEqual(['sk-live-aaa'])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('cliproxyKeysAddAction (Mode A, positional arg, Tier-2 ctx capture)', () => {
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
globalThis.fetch = originalFetch
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('Mode A: captures success message to ctx.stdout after adding key', async () => {
|
|
139
|
+
let putCalled = false
|
|
140
|
+
globalThis.fetch = createFetchImplementation(async (_url, init) => {
|
|
141
|
+
if (init?.method === 'PUT') {
|
|
142
|
+
putCalled = true
|
|
143
|
+
return new Response(JSON.stringify(['sk-live-existing', 'sk-live-new']), {
|
|
144
|
+
status: 200,
|
|
145
|
+
headers: {'content-type': 'application/json'},
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// GET current keys
|
|
150
|
+
return new Response(JSON.stringify(['sk-live-existing']), {
|
|
151
|
+
status: 200,
|
|
152
|
+
headers: {'content-type': 'application/json'},
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const {ctx, captured} = createCapturedCtx()
|
|
157
|
+
await cliproxyKeysAddAction('sk-live-new', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
|
|
158
|
+
|
|
159
|
+
expect(putCalled).toBe(true)
|
|
160
|
+
expect(expectCapturedToInclude(captured, 'sk-live-new')).toBe(true)
|
|
161
|
+
expect(expectCapturedToInclude(captured, 'Added key')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('Mode A: skips PUT when key already present', async () => {
|
|
165
|
+
let putCalled = false
|
|
166
|
+
globalThis.fetch = createFetchImplementation(async (_url, init) => {
|
|
167
|
+
if (init?.method === 'PUT') {
|
|
168
|
+
putCalled = true
|
|
169
|
+
return new Response('{}', {status: 200})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return new Response(JSON.stringify(['sk-live-existing']), {
|
|
173
|
+
status: 200,
|
|
174
|
+
headers: {'content-type': 'application/json'},
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const {ctx, captured} = createCapturedCtx()
|
|
179
|
+
await cliproxyKeysAddAction('sk-live-existing', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
|
|
180
|
+
|
|
181
|
+
expect(putCalled).toBe(false)
|
|
182
|
+
expect(expectCapturedToInclude(captured, 'already present')).toBe(true)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('cliproxyKeysRemoveAction (Mode A, positional arg, Tier-2 ctx capture)', () => {
|
|
187
|
+
afterEach(() => {
|
|
188
|
+
globalThis.fetch = originalFetch
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('Mode A: captures DELETE response to ctx.stdout', async () => {
|
|
192
|
+
globalThis.fetch = createFetchImplementation(
|
|
193
|
+
async () =>
|
|
194
|
+
new Response(JSON.stringify({removed: true}), {
|
|
195
|
+
status: 200,
|
|
196
|
+
headers: {'content-type': 'application/json'},
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
const {ctx, captured} = createCapturedCtx()
|
|
201
|
+
await cliproxyKeysRemoveAction('sk-live-old', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx)
|
|
202
|
+
|
|
203
|
+
expect(expectCapturedToInclude(captured, 'removed')).toBe(true)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('cliproxyKeysListAction (Tier-2 failure-path parity)', () => {
|
|
208
|
+
const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
209
|
+
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
globalThis.fetch = originalFetch
|
|
212
|
+
if (originalManagementKey === undefined) {
|
|
213
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
214
|
+
} else {
|
|
215
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
|
|
220
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
221
|
+
|
|
222
|
+
const {ctx, captured} = createCapturedCtx()
|
|
223
|
+
await expect(cliproxyKeysListAction({url: 'https://cliproxy.example.com'}, ctx)).rejects.toMatchObject({
|
|
224
|
+
name: 'MockProcessExit',
|
|
225
|
+
code: 1,
|
|
226
|
+
})
|
|
227
|
+
expect(captured.stderr.join('')).toContain('Management API key')
|
|
228
|
+
expect(captured.exit).toEqual({code: 1})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('Tier-2: HTTP 500 response routes to ctx.console.error + exit(1)', async () => {
|
|
232
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
|
|
233
|
+
|
|
234
|
+
const {ctx, captured} = createCapturedCtx()
|
|
235
|
+
await expect(
|
|
236
|
+
cliproxyKeysListAction({url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
|
|
237
|
+
).rejects.toMatchObject({
|
|
238
|
+
name: 'MockProcessExit',
|
|
239
|
+
code: 1,
|
|
240
|
+
})
|
|
241
|
+
expect(captured.stderr.join('')).toContain('HTTP 500')
|
|
242
|
+
expect(captured.exit).toEqual({code: 1})
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('cliproxyKeysAddAction (Tier-2 failure-path parity)', () => {
|
|
247
|
+
const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
248
|
+
|
|
249
|
+
afterEach(() => {
|
|
250
|
+
globalThis.fetch = originalFetch
|
|
251
|
+
if (originalManagementKey === undefined) {
|
|
252
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
253
|
+
} else {
|
|
254
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
|
|
259
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
260
|
+
|
|
261
|
+
const {ctx, captured} = createCapturedCtx()
|
|
262
|
+
await expect(
|
|
263
|
+
cliproxyKeysAddAction('sk-live-new', {url: 'https://cliproxy.example.com'}, ctx),
|
|
264
|
+
).rejects.toMatchObject({
|
|
265
|
+
name: 'MockProcessExit',
|
|
266
|
+
code: 1,
|
|
267
|
+
})
|
|
268
|
+
expect(captured.stderr.join('')).toContain('Management API key')
|
|
269
|
+
expect(captured.exit).toEqual({code: 1})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('Tier-2: HTTP 500 on GET routes to ctx.console.error + exit(1)', async () => {
|
|
273
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
|
|
274
|
+
|
|
275
|
+
const {ctx, captured} = createCapturedCtx()
|
|
276
|
+
await expect(
|
|
277
|
+
cliproxyKeysAddAction('sk-live-new', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
|
|
278
|
+
).rejects.toMatchObject({
|
|
279
|
+
name: 'MockProcessExit',
|
|
280
|
+
code: 1,
|
|
281
|
+
})
|
|
282
|
+
expect(captured.stderr.join('')).toContain('HTTP 500')
|
|
283
|
+
expect(captured.exit).toEqual({code: 1})
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('cliproxyKeysRemoveAction (Tier-2 failure-path parity)', () => {
|
|
288
|
+
const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
289
|
+
|
|
290
|
+
afterEach(() => {
|
|
291
|
+
globalThis.fetch = originalFetch
|
|
292
|
+
if (originalManagementKey === undefined) {
|
|
293
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
294
|
+
} else {
|
|
295
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('Tier-2: missing CLIPROXY_MANAGEMENT_KEY routes to ctx.console.error + exit(1)', async () => {
|
|
300
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
301
|
+
|
|
302
|
+
const {ctx, captured} = createCapturedCtx()
|
|
303
|
+
await expect(
|
|
304
|
+
cliproxyKeysRemoveAction('sk-live-old', {url: 'https://cliproxy.example.com'}, ctx),
|
|
305
|
+
).rejects.toMatchObject({
|
|
306
|
+
name: 'MockProcessExit',
|
|
307
|
+
code: 1,
|
|
308
|
+
})
|
|
309
|
+
expect(captured.stderr.join('')).toContain('Management API key')
|
|
310
|
+
expect(captured.exit).toEqual({code: 1})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('Tier-2: HTTP 500 response routes to ctx.console.error + exit(1)', async () => {
|
|
314
|
+
globalThis.fetch = createFetchImplementation(async () => new Response('server error', {status: 500}))
|
|
315
|
+
|
|
316
|
+
const {ctx, captured} = createCapturedCtx()
|
|
317
|
+
await expect(
|
|
318
|
+
cliproxyKeysRemoveAction('sk-live-old', {url: 'https://cliproxy.example.com', key: 'mgmt-key'}, ctx),
|
|
319
|
+
).rejects.toMatchObject({
|
|
320
|
+
name: 'MockProcessExit',
|
|
321
|
+
code: 1,
|
|
322
|
+
})
|
|
323
|
+
expect(captured.stderr.join('')).toContain('HTTP 500')
|
|
324
|
+
expect(captured.exit).toEqual({code: 1})
|
|
325
|
+
})
|
|
326
|
+
})
|