@marcusrbrown/infra 0.2.0 → 0.3.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 +1 -1
- package/src/__snapshots__/cli.test.ts.snap +53 -6
- package/src/cli.ts +10 -0
- package/src/commands/cliproxy-config.test.ts +181 -0
- package/src/commands/cliproxy-config.ts +164 -0
- package/src/commands/cliproxy-deploy.test.ts +181 -0
- package/src/commands/cliproxy-deploy.ts +145 -0
- package/src/commands/cliproxy-keys.ts +168 -0
- package/src/commands/cliproxy-login.ts +81 -0
- package/src/commands/cliproxy-status.test.ts +271 -0
- package/src/commands/cliproxy-status.ts +274 -0
package/package.json
CHANGED
|
@@ -9,17 +9,64 @@ Usage:
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
Commands:
|
|
12
|
-
keeweb status
|
|
12
|
+
keeweb status Show operational health of the KeeWeb deployment
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
keeweb deploy
|
|
15
|
+
keeweb deploy Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.
|
|
16
16
|
|
|
17
|
-
--local
|
|
18
|
-
--nginx
|
|
19
|
-
--dry-run
|
|
17
|
+
--local Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions. (default: false)
|
|
18
|
+
--nginx Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx. (default: false)
|
|
19
|
+
--dry-run Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions. (default: false)
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
cliproxy status Show operational health of CLIProxyAPI and its management endpoints.
|
|
23
|
+
|
|
24
|
+
--url [url] Base URL for CLIProxyAPI health checks. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
25
|
+
--key [key] Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
cliproxy deploy Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.
|
|
29
|
+
|
|
30
|
+
--local Run local deployment with Bun using apps/cliproxy/src/deploy.ts instead of triggering GitHub Actions. (default: false)
|
|
31
|
+
--dry-run Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow. (default: false)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
cliproxy config get Fetch current CLIProxyAPI management config and print it as formatted JSON.
|
|
35
|
+
|
|
36
|
+
--url [url] Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
37
|
+
--key [key] Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
cliproxy config set <key> <value> Update mutable CLIProxyAPI config values through management endpoints (debug, request-retry, proxy-url).
|
|
41
|
+
|
|
42
|
+
--url [url] Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
43
|
+
--key [key] Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
cliproxy keys list List CLIProxyAPI API keys from the management API.
|
|
47
|
+
|
|
48
|
+
--url [url] Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
49
|
+
--key [key] Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
cliproxy keys add <key> Add an API key by fetching current keys, appending the value, and replacing full key set.
|
|
53
|
+
|
|
54
|
+
--url [url] Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
55
|
+
--key [key] Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
cliproxy keys remove <key> Remove an API key via management API endpoint query parameter.
|
|
59
|
+
|
|
60
|
+
--url [url] Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.
|
|
61
|
+
--key [key] Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
cliproxy login <provider> Run provider login on the remote CLIProxyAPI host and print OAuth URL output.
|
|
65
|
+
|
|
66
|
+
--host [host] CLIProxyAPI droplet host for SSH execution. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
mcp Start a stdio MCP server exposing all CLI commands as tools for coding agents
|
|
23
70
|
|
|
24
71
|
|
|
25
72
|
Options:
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import {goke} from 'goke'
|
|
4
4
|
import pkg from '../package.json' with {type: 'json'}
|
|
5
|
+
import {registerCliproxyConfig} from './commands/cliproxy-config'
|
|
6
|
+
import {registerCliproxyDeploy} from './commands/cliproxy-deploy'
|
|
7
|
+
import {registerCliproxyKeys} from './commands/cliproxy-keys'
|
|
8
|
+
import {registerCliproxyLogin} from './commands/cliproxy-login'
|
|
9
|
+
import {registerCliproxyStatus} from './commands/cliproxy-status'
|
|
5
10
|
import {registerKeewebDeploy} from './commands/keeweb-deploy'
|
|
6
11
|
import {registerKeewebStatus} from './commands/keeweb-status'
|
|
7
12
|
import {registerMcp} from './commands/mcp'
|
|
@@ -12,6 +17,11 @@ cli.option('--verbose', 'Enable verbose output for all commands')
|
|
|
12
17
|
|
|
13
18
|
registerKeewebStatus(cli)
|
|
14
19
|
registerKeewebDeploy(cli)
|
|
20
|
+
registerCliproxyStatus(cli)
|
|
21
|
+
registerCliproxyDeploy(cli)
|
|
22
|
+
registerCliproxyConfig(cli)
|
|
23
|
+
registerCliproxyKeys(cli)
|
|
24
|
+
registerCliproxyLogin(cli)
|
|
15
25
|
registerMcp(cli)
|
|
16
26
|
|
|
17
27
|
cli.help()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {buildSetRequest, parseBoolean, parseNumber, resolveManagementKey} from './cliproxy-config'
|
|
4
|
+
import {toStringArray} from './cliproxy-keys'
|
|
5
|
+
import {requireSshAuthSock, resolveHost} from './cliproxy-login'
|
|
6
|
+
|
|
7
|
+
describe('cliproxy config helpers', () => {
|
|
8
|
+
describe('parseBoolean', () => {
|
|
9
|
+
it('parses true values case-insensitively', () => {
|
|
10
|
+
expect(parseBoolean('true')).toBe(true)
|
|
11
|
+
expect(parseBoolean('True')).toBe(true)
|
|
12
|
+
expect(parseBoolean('TRUE')).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('parses false values case-insensitively', () => {
|
|
16
|
+
expect(parseBoolean('false')).toBe(false)
|
|
17
|
+
expect(parseBoolean('False')).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('throws for invalid values', () => {
|
|
21
|
+
expect(() => parseBoolean('wat')).toThrow()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('parseNumber', () => {
|
|
26
|
+
it('parses integers and floats', () => {
|
|
27
|
+
expect(parseNumber('42', 'request-retry')).toBe(42)
|
|
28
|
+
expect(parseNumber('3.14', 'request-retry')).toBe(3.14)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('throws TypeError for non-numeric values', () => {
|
|
32
|
+
expect(() => parseNumber('abc', 'request-retry')).toThrow(TypeError)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('throws TypeError for NaN', () => {
|
|
36
|
+
expect(() => parseNumber('NaN', 'request-retry')).toThrow(TypeError)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('throws TypeError for Infinity', () => {
|
|
40
|
+
expect(() => parseNumber('Infinity', 'request-retry')).toThrow(TypeError)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('buildSetRequest', () => {
|
|
45
|
+
it('builds a boolean debug request', () => {
|
|
46
|
+
const request = buildSetRequest('https://cliproxy.example.com', 'debug', 'true')
|
|
47
|
+
|
|
48
|
+
expect(request.endpoint.endsWith('/debug')).toBe(true)
|
|
49
|
+
expect(JSON.parse(request.body)).toEqual({debug: true})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('builds a numeric request-retry request', () => {
|
|
53
|
+
const request = buildSetRequest('https://cliproxy.example.com', 'request-retry', '3')
|
|
54
|
+
|
|
55
|
+
expect(request.endpoint.endsWith('/request-retry')).toBe(true)
|
|
56
|
+
expect(JSON.parse(request.body)).toEqual({request_retry: 3})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('builds a string proxy-url request', () => {
|
|
60
|
+
const request = buildSetRequest('https://cliproxy.example.com', 'proxy-url', 'https://x.com')
|
|
61
|
+
|
|
62
|
+
expect(request.endpoint.endsWith('/proxy-url')).toBe(true)
|
|
63
|
+
expect(JSON.parse(request.body)).toEqual({proxy_url: 'https://x.com'})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('throws for immutable fields', () => {
|
|
67
|
+
expect(() => buildSetRequest('https://cliproxy.example.com', 'provider', 'claude')).toThrow()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('resolveManagementKey', () => {
|
|
72
|
+
const originalManagementKey = process.env.CLIPROXY_MANAGEMENT_KEY
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
if (originalManagementKey === undefined) {
|
|
80
|
+
delete process.env.CLIPROXY_MANAGEMENT_KEY
|
|
81
|
+
} else {
|
|
82
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = originalManagementKey
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns explicit input when provided', () => {
|
|
87
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = 'env-key'
|
|
88
|
+
|
|
89
|
+
expect(resolveManagementKey('explicit-key')).toBe('explicit-key')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('falls back to CLIPROXY_MANAGEMENT_KEY', () => {
|
|
93
|
+
process.env.CLIPROXY_MANAGEMENT_KEY = 'env-key'
|
|
94
|
+
|
|
95
|
+
expect(resolveManagementKey()).toBe('env-key')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('throws when no key is available', () => {
|
|
99
|
+
expect(() => resolveManagementKey()).toThrow()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('cliproxy keys helpers', () => {
|
|
105
|
+
describe('toStringArray', () => {
|
|
106
|
+
it('returns string arrays filtered to strings only', () => {
|
|
107
|
+
expect(toStringArray(['a', 'b'])).toEqual(['a', 'b'])
|
|
108
|
+
expect(toStringArray(['a', 1, 'b', false])).toEqual(['a', 'b'])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('reads api_keys from objects', () => {
|
|
112
|
+
expect(toStringArray({api_keys: ['a', 'b']})).toEqual(['a', 'b'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('returns an empty array for unsupported payloads', () => {
|
|
116
|
+
expect(toStringArray(null)).toEqual([])
|
|
117
|
+
expect(toStringArray(undefined)).toEqual([])
|
|
118
|
+
expect(toStringArray(123)).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('cliproxy login helpers', () => {
|
|
124
|
+
describe('resolveHost', () => {
|
|
125
|
+
const originalHost = process.env.CLIPROXY_DOMAIN
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
delete process.env.CLIPROXY_DOMAIN
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
if (originalHost === undefined) {
|
|
133
|
+
delete process.env.CLIPROXY_DOMAIN
|
|
134
|
+
} else {
|
|
135
|
+
process.env.CLIPROXY_DOMAIN = originalHost
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('returns explicit input when provided', () => {
|
|
140
|
+
process.env.CLIPROXY_DOMAIN = 'env.example.com'
|
|
141
|
+
|
|
142
|
+
expect(resolveHost('explicit.example.com')).toBe('explicit.example.com')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('falls back to CLIPROXY_DOMAIN', () => {
|
|
146
|
+
process.env.CLIPROXY_DOMAIN = 'env.example.com'
|
|
147
|
+
|
|
148
|
+
expect(resolveHost()).toBe('env.example.com')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('falls back to the default host', () => {
|
|
152
|
+
expect(resolveHost()).toBe('cliproxy.fro.bot')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('requireSshAuthSock', () => {
|
|
157
|
+
const originalSshAuthSock = process.env.SSH_AUTH_SOCK
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
delete process.env.SSH_AUTH_SOCK
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
if (originalSshAuthSock === undefined) {
|
|
165
|
+
delete process.env.SSH_AUTH_SOCK
|
|
166
|
+
} else {
|
|
167
|
+
process.env.SSH_AUTH_SOCK = originalSshAuthSock
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('returns SSH_AUTH_SOCK when set', () => {
|
|
172
|
+
process.env.SSH_AUTH_SOCK = '/tmp/agent.sock'
|
|
173
|
+
|
|
174
|
+
expect(requireSshAuthSock()).toBe('/tmp/agent.sock')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('throws when SSH_AUTH_SOCK is missing', () => {
|
|
178
|
+
expect(() => requireSshAuthSock()).toThrow()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
|
|
3
|
+
import {z} from 'zod'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
6
|
+
const HTTP_TIMEOUT_MS = 10_000
|
|
7
|
+
|
|
8
|
+
function stripTrailingSlash(value: string): string {
|
|
9
|
+
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveBaseUrl(input?: string): string {
|
|
13
|
+
return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveManagementKey(input?: string): string {
|
|
17
|
+
const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY
|
|
18
|
+
|
|
19
|
+
if (!key) {
|
|
20
|
+
throw new Error('Management API key is required. Pass --key or set CLIPROXY_MANAGEMENT_KEY.')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return key
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function managementHeaders(key: string): Headers {
|
|
27
|
+
const headers = new Headers()
|
|
28
|
+
headers.set('authorization', `Bearer ${key}`)
|
|
29
|
+
headers.set('x-management-key', key)
|
|
30
|
+
headers.set('content-type', 'application/json')
|
|
31
|
+
return headers
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
|
|
35
|
+
const response = await fetch(endpoint, {
|
|
36
|
+
...init,
|
|
37
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const body = await response.text()
|
|
42
|
+
throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return await response.json()
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseBoolean(value: string): boolean {
|
|
53
|
+
const normalized = value.toLowerCase()
|
|
54
|
+
if (normalized === 'true') {
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (normalized === 'false') {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error('debug expects a boolean value: true or false')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseNumber(value: string, field: string): number {
|
|
66
|
+
const parsed = Number(value)
|
|
67
|
+
if (!Number.isFinite(parsed)) {
|
|
68
|
+
throw new TypeError(`${field} expects a numeric value`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return parsed
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildSetRequest(baseUrl: string, field: string, rawValue: string): {endpoint: string; body: string} {
|
|
75
|
+
if (field === 'debug') {
|
|
76
|
+
return {
|
|
77
|
+
endpoint: `${baseUrl}/v0/management/debug`,
|
|
78
|
+
body: JSON.stringify({debug: parseBoolean(rawValue)}),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (field === 'request-retry') {
|
|
83
|
+
return {
|
|
84
|
+
endpoint: `${baseUrl}/v0/management/request-retry`,
|
|
85
|
+
body: JSON.stringify({request_retry: parseNumber(rawValue, 'request-retry')}),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (field === 'proxy-url') {
|
|
90
|
+
return {
|
|
91
|
+
endpoint: `${baseUrl}/v0/management/proxy-url`,
|
|
92
|
+
body: JSON.stringify({proxy_url: rawValue}),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Key "${field}" is not mutable via API. Only debug, request-retry, and proxy-url are supported. Edit config.yaml directly for other keys.`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
|
|
102
|
+
cli
|
|
103
|
+
.command('cliproxy config get', 'Fetch current CLIProxyAPI management config and print it as formatted JSON.')
|
|
104
|
+
.option(
|
|
105
|
+
'--url [url]',
|
|
106
|
+
z
|
|
107
|
+
.string()
|
|
108
|
+
.describe(
|
|
109
|
+
'Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.',
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
.option(
|
|
113
|
+
'--key [key]',
|
|
114
|
+
z.string().describe('Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
115
|
+
)
|
|
116
|
+
.action(async options => {
|
|
117
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
118
|
+
const managementKey = resolveManagementKey(options.key)
|
|
119
|
+
const endpoint = `${baseUrl}/v0/management/config`
|
|
120
|
+
const payload = await requestJson(endpoint, {
|
|
121
|
+
method: 'GET',
|
|
122
|
+
headers: managementHeaders(managementKey),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
console.log(JSON.stringify(payload, null, 2))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
cli
|
|
129
|
+
.command(
|
|
130
|
+
'cliproxy config set <key> <value>',
|
|
131
|
+
'Update mutable CLIProxyAPI config values through management endpoints (debug, request-retry, proxy-url).',
|
|
132
|
+
)
|
|
133
|
+
.option(
|
|
134
|
+
'--url [url]',
|
|
135
|
+
z
|
|
136
|
+
.string()
|
|
137
|
+
.describe(
|
|
138
|
+
'Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.',
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
.option(
|
|
142
|
+
'--key [key]',
|
|
143
|
+
z.string().describe('Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
144
|
+
)
|
|
145
|
+
.example('# Enable debug mode via management API')
|
|
146
|
+
.example('infra cliproxy config set debug true')
|
|
147
|
+
.example('# Update request retry budget to 3')
|
|
148
|
+
.example('infra cliproxy config set request-retry 3')
|
|
149
|
+
.example('# Point proxy upstream to a different URL')
|
|
150
|
+
.example('infra cliproxy config set proxy-url https://example.com')
|
|
151
|
+
.action(async (field, value, options) => {
|
|
152
|
+
const baseUrl = resolveBaseUrl(options.url)
|
|
153
|
+
const managementKey = resolveManagementKey(options.key)
|
|
154
|
+
const request = buildSetRequest(baseUrl, field, value)
|
|
155
|
+
|
|
156
|
+
const payload = await requestJson(request.endpoint, {
|
|
157
|
+
method: 'PUT',
|
|
158
|
+
headers: managementHeaders(managementKey),
|
|
159
|
+
body: request.body,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
console.log(JSON.stringify(payload, null, 2))
|
|
163
|
+
})
|
|
164
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {resolve} from 'node:path'
|
|
2
|
+
import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
|
|
3
|
+
|
|
4
|
+
import {getLocalDeployEnv, resolveLocalDeployScriptPath, validateRemotePreconditions} from './cliproxy-deploy'
|
|
5
|
+
|
|
6
|
+
const cliDir = resolve(import.meta.dir, '../..')
|
|
7
|
+
|
|
8
|
+
const envKeys = ['CLIPROXY_DOMAIN', 'CLIPROXY_MANAGEMENT_KEY', 'HOME', 'PATH', 'SSH_AUTH_SOCK'] as const
|
|
9
|
+
|
|
10
|
+
type ManagedEnvKey = (typeof envKeys)[number]
|
|
11
|
+
|
|
12
|
+
let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
|
|
13
|
+
|
|
14
|
+
function restoreManagedEnv(): void {
|
|
15
|
+
for (const key of envKeys) {
|
|
16
|
+
const value = originalEnv[key]
|
|
17
|
+
|
|
18
|
+
if (value === undefined) {
|
|
19
|
+
delete process.env[key]
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
process.env[key] = value
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setManagedEnv(overrides: Partial<Record<ManagedEnvKey, string | undefined>>): void {
|
|
28
|
+
restoreManagedEnv()
|
|
29
|
+
|
|
30
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
31
|
+
if (value === undefined) {
|
|
32
|
+
delete process.env[key]
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
process.env[key as ManagedEnvKey] = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function runDeployCommand(
|
|
41
|
+
args: string[],
|
|
42
|
+
envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
|
|
43
|
+
): Promise<{stdout: string; stderr: string; exitCode: number}> {
|
|
44
|
+
const env = {...process.env}
|
|
45
|
+
|
|
46
|
+
for (const [key, value] of Object.entries(envOverrides)) {
|
|
47
|
+
if (value === undefined) {
|
|
48
|
+
delete env[key]
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
env[key] = value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const proc = Bun.spawn(['bun', 'src/cli.ts', 'cliproxy', 'deploy', ...args], {
|
|
56
|
+
cwd: cliDir,
|
|
57
|
+
env: {
|
|
58
|
+
...env,
|
|
59
|
+
HOME: env.HOME ?? '/tmp/test-home',
|
|
60
|
+
NO_COLOR: '1',
|
|
61
|
+
PATH: env.PATH ?? '/usr/bin:/bin',
|
|
62
|
+
SSH_AUTH_SOCK: env.SSH_AUTH_SOCK ?? '/tmp/test-sock',
|
|
63
|
+
},
|
|
64
|
+
stdout: 'pipe',
|
|
65
|
+
stderr: 'pipe',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
69
|
+
new Response(proc.stdout).text(),
|
|
70
|
+
new Response(proc.stderr).text(),
|
|
71
|
+
proc.exited,
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
return {stdout, stderr, exitCode}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('cliproxy deploy', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
restoreManagedEnv()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('resolveLocalDeployScriptPath', () => {
|
|
87
|
+
it('returns a string path containing deploy.ts', () => {
|
|
88
|
+
const deployScriptPath = resolveLocalDeployScriptPath()
|
|
89
|
+
|
|
90
|
+
expect(typeof deployScriptPath).toBe('string')
|
|
91
|
+
expect(deployScriptPath).toContain('deploy.ts')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('getLocalDeployEnv', () => {
|
|
96
|
+
it('returns the expected deploy environment when required variables are present', () => {
|
|
97
|
+
setManagedEnv({
|
|
98
|
+
CLIPROXY_DOMAIN: 'cliproxy.example.com',
|
|
99
|
+
CLIPROXY_MANAGEMENT_KEY: 'test-management-key',
|
|
100
|
+
HOME: '/tmp/test-home',
|
|
101
|
+
PATH: '/usr/bin:/bin',
|
|
102
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
expect(getLocalDeployEnv()).toEqual({
|
|
106
|
+
CLIPROXY_DOMAIN: 'cliproxy.example.com',
|
|
107
|
+
CLIPROXY_MANAGEMENT_KEY: 'test-management-key',
|
|
108
|
+
HOME: '/tmp/test-home',
|
|
109
|
+
PATH: '/usr/bin:/bin',
|
|
110
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('throws when PATH is missing', () => {
|
|
115
|
+
setManagedEnv({
|
|
116
|
+
HOME: '/tmp/test-home',
|
|
117
|
+
PATH: undefined,
|
|
118
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(() => getLocalDeployEnv()).toThrow(/PATH/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('throws when HOME is missing', () => {
|
|
125
|
+
setManagedEnv({
|
|
126
|
+
HOME: undefined,
|
|
127
|
+
PATH: '/usr/bin:/bin',
|
|
128
|
+
SSH_AUTH_SOCK: '/tmp/test-sock',
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(() => getLocalDeployEnv()).toThrow(/HOME/)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('throws when SSH_AUTH_SOCK is missing', () => {
|
|
135
|
+
setManagedEnv({
|
|
136
|
+
HOME: '/tmp/test-home',
|
|
137
|
+
PATH: '/usr/bin:/bin',
|
|
138
|
+
SSH_AUTH_SOCK: undefined,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(() => getLocalDeployEnv()).toThrow(/SSH_AUTH_SOCK/)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('validateRemotePreconditions', () => {
|
|
146
|
+
it('does not throw when gh is available', () => {
|
|
147
|
+
const whichSpy = spyOn(Bun, 'which').mockReturnValue('/opt/homebrew/bin/gh')
|
|
148
|
+
|
|
149
|
+
expect(() => validateRemotePreconditions()).not.toThrow()
|
|
150
|
+
|
|
151
|
+
whichSpy.mockRestore()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('throws when gh is unavailable', () => {
|
|
155
|
+
const whichSpy = spyOn(Bun, 'which').mockReturnValue(null)
|
|
156
|
+
|
|
157
|
+
expect(() => validateRemotePreconditions()).toThrow(/gh CLI is required/)
|
|
158
|
+
|
|
159
|
+
whichSpy.mockRestore()
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('CLI flag interactions', () => {
|
|
164
|
+
it('prints the local dry-run plan', async () => {
|
|
165
|
+
const deployScriptPath = resolveLocalDeployScriptPath()
|
|
166
|
+
const {stdout, stderr, exitCode} = await runDeployCommand(['--local', '--dry-run'])
|
|
167
|
+
|
|
168
|
+
expect(exitCode).toBe(0)
|
|
169
|
+
expect(stderr).toBe('')
|
|
170
|
+
expect(stdout).toContain('Dry run: local CLIProxyAPI deploy')
|
|
171
|
+
expect(stdout).toContain(deployScriptPath)
|
|
172
|
+
expect(stdout).toContain('CLIPROXY_DOMAIN=')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('rejects invalid options with a non-zero exit code', async () => {
|
|
176
|
+
const {exitCode} = await runDeployCommand(['--bogus'])
|
|
177
|
+
|
|
178
|
+
expect(exitCode).not.toBe(0)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
})
|