@marcusrbrown/infra 0.4.11 → 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 +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,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
|
+
})
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type {goke} from 'goke'
|
|
2
2
|
|
|
3
|
+
import type {ActionCtx} from '../../lib/action-ctx'
|
|
4
|
+
|
|
3
5
|
import {z} from 'zod'
|
|
4
6
|
|
|
7
|
+
/** Minimal ctx surface consumed by cliproxy keys actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
8
|
+
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
9
|
+
|
|
5
10
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
6
11
|
const HTTP_TIMEOUT_MS = 10_000
|
|
7
12
|
|
|
@@ -64,6 +69,114 @@ export function toStringArray(payload: unknown): string[] {
|
|
|
64
69
|
return []
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
export interface KeysListOptions {
|
|
73
|
+
url?: string
|
|
74
|
+
key?: string
|
|
75
|
+
json?: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function cliproxyKeysListAction(options: KeysListOptions, ctx: ActionCtx): Promise<string[]> {
|
|
79
|
+
try {
|
|
80
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
81
|
+
const managementKey = resolveManagementKey(options.key)
|
|
82
|
+
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
83
|
+
const payload = await requestJson(endpoint, {
|
|
84
|
+
method: 'GET',
|
|
85
|
+
headers: managementHeaders(managementKey),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const keys = toStringArray(payload)
|
|
89
|
+
ctx.console.error('⚠️ Output contains API keys — avoid logging or storing in shared locations')
|
|
90
|
+
|
|
91
|
+
if (options.json) {
|
|
92
|
+
ctx.console.log(JSON.stringify(keys, null, 2))
|
|
93
|
+
} else if (keys.length === 0) {
|
|
94
|
+
ctx.console.log('No API keys configured')
|
|
95
|
+
} else {
|
|
96
|
+
for (const [index, apiKey] of keys.entries()) {
|
|
97
|
+
ctx.console.log(`${index + 1}. ${apiKey}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return keys
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
104
|
+
ctx.console.error(message)
|
|
105
|
+
ctx.process.exit(1)
|
|
106
|
+
return [] // unreachable; satisfies TS that all paths return
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface KeysAddOptions {
|
|
111
|
+
url?: string
|
|
112
|
+
key?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function cliproxyKeysAddAction(
|
|
116
|
+
apiKeyToAdd: string,
|
|
117
|
+
options: KeysAddOptions,
|
|
118
|
+
ctx: ActionCtx,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
try {
|
|
121
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
122
|
+
const managementKey = resolveManagementKey(options.key)
|
|
123
|
+
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
124
|
+
|
|
125
|
+
const currentPayload = await requestJson(endpoint, {
|
|
126
|
+
method: 'GET',
|
|
127
|
+
headers: managementHeaders(managementKey),
|
|
128
|
+
})
|
|
129
|
+
const currentKeys = toStringArray(currentPayload)
|
|
130
|
+
|
|
131
|
+
if (currentKeys.includes(apiKeyToAdd)) {
|
|
132
|
+
ctx.console.log('Key already present; no update required.')
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const nextKeys = [...currentKeys, apiKeyToAdd]
|
|
137
|
+
await requestJson(endpoint, {
|
|
138
|
+
method: 'PUT',
|
|
139
|
+
headers: managementHeaders(managementKey),
|
|
140
|
+
body: JSON.stringify(nextKeys),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
ctx.console.log(`Added key "${apiKeyToAdd}". Current key count: ${nextKeys.length}.`)
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
146
|
+
ctx.console.error(message)
|
|
147
|
+
ctx.process.exit(1)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface KeysRemoveOptions {
|
|
152
|
+
url?: string
|
|
153
|
+
key?: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function cliproxyKeysRemoveAction(
|
|
157
|
+
apiKeyToRemove: string,
|
|
158
|
+
options: KeysRemoveOptions,
|
|
159
|
+
ctx: ActionCtx,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
163
|
+
const managementKey = resolveManagementKey(options.key)
|
|
164
|
+
const params = new URLSearchParams({value: apiKeyToRemove})
|
|
165
|
+
const endpoint = `${baseUrl}/v0/management/api-keys?${params.toString()}`
|
|
166
|
+
|
|
167
|
+
const payload = await requestJson(endpoint, {
|
|
168
|
+
method: 'DELETE',
|
|
169
|
+
headers: managementHeaders(managementKey),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
ctx.console.log(JSON.stringify(payload, null, 2))
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
175
|
+
ctx.console.error(message)
|
|
176
|
+
ctx.process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
67
180
|
export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
68
181
|
cli
|
|
69
182
|
.command('cliproxy keys list', 'List CLIProxyAPI API keys from the management API.')
|
|
@@ -80,28 +193,7 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
|
80
193
|
z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
81
194
|
)
|
|
82
195
|
.option('--json', 'Output raw JSON array instead of a numbered list.')
|
|
83
|
-
.action(
|
|
84
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
85
|
-
const managementKey = resolveManagementKey(options.key)
|
|
86
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
87
|
-
const payload = await requestJson(endpoint, {
|
|
88
|
-
method: 'GET',
|
|
89
|
-
headers: managementHeaders(managementKey),
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
const keys = toStringArray(payload)
|
|
93
|
-
console.error('⚠️ Output contains API keys — avoid logging or storing in shared locations')
|
|
94
|
-
|
|
95
|
-
if (options.json) {
|
|
96
|
-
console.log(JSON.stringify(keys, null, 2))
|
|
97
|
-
} else if (keys.length === 0) {
|
|
98
|
-
console.log('No API keys configured')
|
|
99
|
-
} else {
|
|
100
|
-
for (const [index, key] of keys.entries()) {
|
|
101
|
-
console.log(`${index + 1}. ${key}`)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
})
|
|
196
|
+
.action(cliproxyKeysListAction)
|
|
105
197
|
|
|
106
198
|
cli
|
|
107
199
|
.command(
|
|
@@ -122,31 +214,7 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
|
122
214
|
)
|
|
123
215
|
.example('# Add a new API key to the current key set')
|
|
124
216
|
.example('infra cliproxy keys add sk-live-123')
|
|
125
|
-
.action(
|
|
126
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
127
|
-
const managementKey = resolveManagementKey(options.key)
|
|
128
|
-
const endpoint = `${baseUrl}/v0/management/api-keys`
|
|
129
|
-
|
|
130
|
-
const currentPayload = await requestJson(endpoint, {
|
|
131
|
-
method: 'GET',
|
|
132
|
-
headers: managementHeaders(managementKey),
|
|
133
|
-
})
|
|
134
|
-
const currentKeys = toStringArray(currentPayload)
|
|
135
|
-
|
|
136
|
-
if (currentKeys.includes(apiKeyToAdd)) {
|
|
137
|
-
console.log('Key already present; no update required.')
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const nextKeys = [...currentKeys, apiKeyToAdd]
|
|
142
|
-
await requestJson(endpoint, {
|
|
143
|
-
method: 'PUT',
|
|
144
|
-
headers: managementHeaders(managementKey),
|
|
145
|
-
body: JSON.stringify(nextKeys),
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
console.log(`Added key "${apiKeyToAdd}". Current key count: ${nextKeys.length}.`)
|
|
149
|
-
})
|
|
217
|
+
.action(cliproxyKeysAddAction)
|
|
150
218
|
|
|
151
219
|
cli
|
|
152
220
|
.command('cliproxy keys remove <key>', 'Remove an API key via management API endpoint query parameter.')
|
|
@@ -164,17 +232,5 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
|
|
|
164
232
|
)
|
|
165
233
|
.example('# Remove an API key from CLIProxyAPI')
|
|
166
234
|
.example('infra cliproxy keys remove sk-live-123')
|
|
167
|
-
.action(
|
|
168
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
169
|
-
const managementKey = resolveManagementKey(options.key)
|
|
170
|
-
const params = new URLSearchParams({value: apiKeyToRemove})
|
|
171
|
-
const endpoint = `${baseUrl}/v0/management/api-keys?${params.toString()}`
|
|
172
|
-
|
|
173
|
-
const payload = await requestJson(endpoint, {
|
|
174
|
-
method: 'DELETE',
|
|
175
|
-
headers: managementHeaders(managementKey),
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
console.log(JSON.stringify(payload, null, 2))
|
|
179
|
-
})
|
|
235
|
+
.action(cliproxyKeysRemoveAction)
|
|
180
236
|
}
|