@marcusrbrown/infra 0.7.0 → 0.8.1
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 +1 -1
- package/src/__snapshots__/cli.test.ts.snap +3 -2
- package/src/commands/cliproxy/config.ts +2 -26
- package/src/commands/cliproxy/keys.ts +8 -43
- package/src/commands/cliproxy/setup/gh.test.ts +218 -0
- package/src/commands/cliproxy/setup/gh.ts +250 -0
- package/src/commands/cliproxy/setup/preview.test.ts +159 -0
- package/src/commands/cliproxy/setup/preview.ts +41 -0
- package/src/commands/cliproxy/setup/prompts.test.ts +58 -0
- package/src/commands/cliproxy/setup/prompts.ts +99 -0
- package/src/commands/cliproxy/setup/providers.test.ts +245 -0
- package/src/commands/cliproxy/setup/providers.ts +136 -0
- package/src/commands/cliproxy/setup/smoke-test.test.ts +821 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +223 -0
- package/src/commands/cliproxy/setup/templates.test.ts +358 -0
- package/src/commands/cliproxy/setup/templates.ts +158 -0
- package/src/commands/cliproxy/setup/validation.test.ts +399 -0
- package/src/commands/cliproxy/setup/validation.ts +182 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +341 -0
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +137 -0
- package/src/commands/cliproxy/setup.test.ts +1948 -1944
- package/src/commands/cliproxy/setup.ts +543 -1353
- package/src/commands/cliproxy/shared.test.ts +118 -0
- package/src/commands/cliproxy/shared.ts +84 -0
- package/src/commands/cliproxy/status.ts +2 -7
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
import {managementHeaders, parseManagementKeyList, requestJson} from './shared'
|
|
3
|
+
|
|
4
|
+
describe('managementHeaders', () => {
|
|
5
|
+
test('sets x-management-key header', () => {
|
|
6
|
+
const headers = managementHeaders('mgmt-key')
|
|
7
|
+
expect(headers.get('x-management-key')).toBe('mgmt-key')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('sets content-type to application/json', () => {
|
|
11
|
+
const headers = managementHeaders('mgmt-key')
|
|
12
|
+
expect(headers.get('content-type')).toBe('application/json')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('does not set Authorization header', () => {
|
|
16
|
+
const headers = managementHeaders('mgmt-key')
|
|
17
|
+
expect(headers.get('authorization')).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('requestJson', () => {
|
|
22
|
+
test('returns parsed JSON on success', async () => {
|
|
23
|
+
const payload = {ok: true, value: 42}
|
|
24
|
+
const mockFetch = mock(() =>
|
|
25
|
+
Promise.resolve(
|
|
26
|
+
new Response(JSON.stringify(payload), {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: {'content-type': 'application/json'},
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
const original = globalThis.fetch
|
|
33
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch
|
|
34
|
+
try {
|
|
35
|
+
const result = await requestJson('https://example.com/api', {method: 'GET'})
|
|
36
|
+
expect(result).toEqual(payload)
|
|
37
|
+
} finally {
|
|
38
|
+
globalThis.fetch = original
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('throws with HTTP status and body on non-200 response', async () => {
|
|
43
|
+
const mockFetch = mock(() => Promise.resolve(new Response('Unauthorized', {status: 401})))
|
|
44
|
+
const original = globalThis.fetch
|
|
45
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch
|
|
46
|
+
try {
|
|
47
|
+
await expect(requestJson('https://example.com/api', {method: 'POST'})).rejects.toThrow(
|
|
48
|
+
'POST https://example.com/api failed with HTTP 401: Unauthorized',
|
|
49
|
+
)
|
|
50
|
+
} finally {
|
|
51
|
+
globalThis.fetch = original
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('throws on 200 with malformed JSON body so mutating callers fail closed', async () => {
|
|
56
|
+
const mockFetch = mock(() =>
|
|
57
|
+
Promise.resolve(
|
|
58
|
+
new Response('not-json-content', {
|
|
59
|
+
status: 200,
|
|
60
|
+
headers: {'content-type': 'text/plain'},
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
const original = globalThis.fetch
|
|
65
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch
|
|
66
|
+
try {
|
|
67
|
+
await expect(requestJson('https://example.com/api', {method: 'GET'})).rejects.toThrow(/returned malformed JSON/)
|
|
68
|
+
} finally {
|
|
69
|
+
globalThis.fetch = original
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('returns null on 204 No Content', async () => {
|
|
74
|
+
const mockFetch = mock(() => Promise.resolve(new Response(null, {status: 204})))
|
|
75
|
+
const original = globalThis.fetch
|
|
76
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch
|
|
77
|
+
try {
|
|
78
|
+
const result = await requestJson('https://example.com/api', {method: 'DELETE'})
|
|
79
|
+
expect(result).toBeNull()
|
|
80
|
+
} finally {
|
|
81
|
+
globalThis.fetch = original
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('parseManagementKeyList', () => {
|
|
87
|
+
test('accepts top-level string array', () => {
|
|
88
|
+
expect(parseManagementKeyList(['k1', 'k2'])).toEqual(['k1', 'k2'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('accepts {api-keys: string[]}', () => {
|
|
92
|
+
expect(parseManagementKeyList({'api-keys': ['k1']})).toEqual(['k1'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('accepts {api_keys: string[]}', () => {
|
|
96
|
+
expect(parseManagementKeyList({api_keys: ['k1']})).toEqual(['k1'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('throws on null payload so destructive PUTs fail closed', () => {
|
|
100
|
+
expect(() => parseManagementKeyList(null)).toThrow(/Unexpected management key-list shape/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('throws on empty object', () => {
|
|
104
|
+
expect(() => parseManagementKeyList({})).toThrow(/Unexpected management key-list shape/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('throws on array of non-strings', () => {
|
|
108
|
+
expect(() => parseManagementKeyList([1, 2, 3])).toThrow(/Unexpected management key-list shape/)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('throws on string scalar', () => {
|
|
112
|
+
expect(() => parseManagementKeyList('not-an-array')).toThrow(/Unexpected management key-list shape/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('throws on object with non-array api-keys field', () => {
|
|
116
|
+
expect(() => parseManagementKeyList({'api-keys': 'k1'})).toThrow(/Unexpected management key-list shape/)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Shared HTTP helpers for cliproxy commands.
|
|
2
|
+
|
|
3
|
+
export const HTTP_TIMEOUT_MS = 10_000
|
|
4
|
+
|
|
5
|
+
// Permissive parser for /v0/management/api-keys list responses. Returns [] on every
|
|
6
|
+
// unknown shape. Use ONLY for display paths (e.g. `cliproxy keys list`) where
|
|
7
|
+
// empty-on-malformed is acceptable. Mutating callers (createManagementApiKey,
|
|
8
|
+
// deleteManagementApiKey, `cliproxy keys add`) must use parseManagementKeyList
|
|
9
|
+
// below — the permissive default would cause a destructive PUT to replace the
|
|
10
|
+
// entire key list with just the new key.
|
|
11
|
+
export function toStringArray(payload: unknown): string[] {
|
|
12
|
+
if (Array.isArray(payload)) {
|
|
13
|
+
return payload.filter((item): item is string => typeof item === 'string')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (payload !== null && typeof payload === 'object') {
|
|
17
|
+
const obj = payload as Record<string, unknown>
|
|
18
|
+
const value = obj['api-keys'] ?? obj.api_keys
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
return value.filter((item): item is string => typeof item === 'string')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Strict parser for /v0/management/api-keys list responses used by mutating callers.
|
|
28
|
+
// Falls back to throw on any unknown shape — never returns [] on malformed input.
|
|
29
|
+
// Accepts string[], {api-keys: string[]}, or {api_keys: string[]}. Throws on every other shape.
|
|
30
|
+
export function parseManagementKeyList(payload: unknown): string[] {
|
|
31
|
+
if (Array.isArray(payload)) {
|
|
32
|
+
if (!payload.every((item): item is string => typeof item === 'string')) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Unexpected management key-list shape: top-level array contains non-string entries (got ${JSON.stringify(payload).slice(0, 100)})`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
return payload
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (payload !== null && typeof payload === 'object') {
|
|
41
|
+
const obj = payload as Record<string, unknown>
|
|
42
|
+
const value = obj['api-keys'] ?? obj.api_keys
|
|
43
|
+
if (Array.isArray(value) && value.every((item): item is string => typeof item === 'string')) {
|
|
44
|
+
return value
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Unexpected management key-list shape: expected string[] or {api-keys: string[]} (got ${JSON.stringify(payload).slice(0, 100)})`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function managementHeaders(key: string): Headers {
|
|
54
|
+
const headers = new Headers()
|
|
55
|
+
headers.set('x-management-key', key)
|
|
56
|
+
headers.set('content-type', 'application/json')
|
|
57
|
+
return headers
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
|
|
61
|
+
const response = await fetch(endpoint, {
|
|
62
|
+
...init,
|
|
63
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const body = await response.text()
|
|
68
|
+
throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 204 No Content is a valid empty response for some mutations.
|
|
72
|
+
if (response.status === 204) return null
|
|
73
|
+
|
|
74
|
+
// JSON parse failures must surface — permissive parsing here caused a data-loss
|
|
75
|
+
// class bug (PR #312 Fro Bot review): bad management JSON would silently become
|
|
76
|
+
// null, then toStringArray(null) → [], then a destructive PUT would replace the
|
|
77
|
+
// entire key list with just the new key.
|
|
78
|
+
try {
|
|
79
|
+
return await response.json()
|
|
80
|
+
} catch (parseError) {
|
|
81
|
+
const message = parseError instanceof Error ? parseError.message : String(parseError)
|
|
82
|
+
throw new Error(`${init.method ?? 'GET'} ${endpoint} returned malformed JSON: ${message}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -4,6 +4,8 @@ import type {StatusSummary} from '../status'
|
|
|
4
4
|
|
|
5
5
|
import {z} from 'zod'
|
|
6
6
|
|
|
7
|
+
import {HTTP_TIMEOUT_MS, managementHeaders} from './shared'
|
|
8
|
+
|
|
7
9
|
/** Minimal ctx surface consumed by cliproxy status actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
8
10
|
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
9
11
|
|
|
@@ -13,7 +15,6 @@ declare const process: {
|
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
16
|
-
const HTTP_TIMEOUT_MS = 10_000
|
|
17
18
|
|
|
18
19
|
type CheckLevel = 'ok' | 'warning' | 'error'
|
|
19
20
|
|
|
@@ -44,12 +45,6 @@ export function stripTrailingSlash(value: string): string {
|
|
|
44
45
|
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function managementHeaders(key: string): Headers {
|
|
48
|
-
const headers = new Headers()
|
|
49
|
-
headers.set('x-management-key', key)
|
|
50
|
-
return headers
|
|
51
|
-
}
|
|
52
|
-
|
|
53
48
|
async function parseJsonResponse(response: Response): Promise<unknown> {
|
|
54
49
|
try {
|
|
55
50
|
return await response.json()
|