@marcusrbrown/infra 0.13.1 → 0.13.2
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/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import type {ActionCtx} from '../../lib/action-ctx'
|
|
4
|
-
import {chmodSync} from 'node:fs'
|
|
5
|
-
|
|
6
|
-
import {z} from 'zod'
|
|
7
|
-
|
|
8
|
-
import {managementHeaders, requestJson} from './shared'
|
|
9
|
-
|
|
10
|
-
/** Minimal ctx surface consumed by cliproxy config actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
|
|
11
|
-
// ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
|
|
12
|
-
|
|
13
|
-
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
14
|
-
|
|
15
|
-
function stripTrailingSlash(value: string): string {
|
|
16
|
-
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function resolveBaseUrl(input?: string): string {
|
|
20
|
-
return stripTrailingSlash(input ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function resolveManagementKey(input?: string): string {
|
|
24
|
-
const key = input ?? process.env.CLIPROXY_MANAGEMENT_KEY
|
|
25
|
-
|
|
26
|
-
if (!key) {
|
|
27
|
-
throw new Error('Management API key is required. Pass --key or set CLIPROXY_MANAGEMENT_KEY.')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return key
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function parseBoolean(value: string): boolean {
|
|
34
|
-
const normalized = value.toLowerCase()
|
|
35
|
-
if (normalized === 'true') {
|
|
36
|
-
return true
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (normalized === 'false') {
|
|
40
|
-
return false
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
throw new Error(`Expected a boolean value (true/false), got: "${value}"`)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function parseNumber(value: string, field: string): number {
|
|
47
|
-
const parsed = Number(value)
|
|
48
|
-
if (!Number.isFinite(parsed)) {
|
|
49
|
-
throw new TypeError(`${field} expects a numeric value, got: "${value}"`)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return parsed
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
type FieldType = 'boolean' | 'number' | 'string'
|
|
56
|
-
|
|
57
|
-
interface FieldSpec {
|
|
58
|
-
path: string
|
|
59
|
-
type: FieldType
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const MUTABLE_FIELDS: Record<string, FieldSpec> = {
|
|
63
|
-
debug: {path: '/debug', type: 'boolean'},
|
|
64
|
-
'request-retry': {path: '/request-retry', type: 'number'},
|
|
65
|
-
'max-retry-interval': {path: '/max-retry-interval', type: 'number'},
|
|
66
|
-
'proxy-url': {path: '/proxy-url', type: 'string'},
|
|
67
|
-
'request-log': {path: '/request-log', type: 'boolean'},
|
|
68
|
-
'ws-auth': {path: '/ws-auth', type: 'boolean'},
|
|
69
|
-
'logging-to-file': {path: '/logging-to-file', type: 'boolean'},
|
|
70
|
-
'usage-statistics-enabled': {path: '/usage-statistics-enabled', type: 'boolean'},
|
|
71
|
-
'force-model-prefix': {path: '/force-model-prefix', type: 'boolean'},
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function buildSetRequest(baseUrl: string, field: string, rawValue: string): {endpoint: string; body: string} {
|
|
75
|
-
const spec = MUTABLE_FIELDS[field]
|
|
76
|
-
if (!spec) {
|
|
77
|
-
const supported = Object.keys(MUTABLE_FIELDS).join(', ')
|
|
78
|
-
throw new Error(`"${field}" is not a supported mutable field. Supported: ${supported}`)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
let value: boolean | number | string
|
|
82
|
-
if (spec.type === 'boolean') {
|
|
83
|
-
value = parseBoolean(rawValue)
|
|
84
|
-
} else if (spec.type === 'number') {
|
|
85
|
-
value = parseNumber(rawValue, field)
|
|
86
|
-
} else {
|
|
87
|
-
value = rawValue
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
endpoint: `${baseUrl}/v0/management${spec.path}`,
|
|
92
|
-
body: JSON.stringify({value}),
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function formatConfigAsColumns(payload: unknown): string {
|
|
97
|
-
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
98
|
-
return JSON.stringify(payload, null, 2)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const entries = Object.entries(payload as Record<string, unknown>)
|
|
102
|
-
if (entries.length === 0) {
|
|
103
|
-
return ''
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const maxKeyLength = Math.max(...entries.map(([k]) => k.length))
|
|
107
|
-
return entries
|
|
108
|
-
.map(([key, value]) => {
|
|
109
|
-
const paddedKey = key.padEnd(maxKeyLength)
|
|
110
|
-
const displayValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
111
|
-
return `${paddedKey}: ${displayValue}`
|
|
112
|
-
})
|
|
113
|
-
.join('\n')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface ConfigGetOptions {
|
|
117
|
-
url?: string
|
|
118
|
-
key?: string
|
|
119
|
-
output?: string
|
|
120
|
-
json?: boolean
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export async function cliproxyConfigGetAction(options: ConfigGetOptions, ctx: ActionCtx): Promise<unknown> {
|
|
124
|
-
try {
|
|
125
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
126
|
-
const managementKey = resolveManagementKey(options.key)
|
|
127
|
-
const endpoint = `${baseUrl}/v0/management/config`
|
|
128
|
-
const payload = await requestJson(endpoint, {
|
|
129
|
-
method: 'GET',
|
|
130
|
-
headers: managementHeaders(managementKey),
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
const jsonOutput = JSON.stringify(payload, null, 2)
|
|
134
|
-
|
|
135
|
-
if (options.output) {
|
|
136
|
-
try {
|
|
137
|
-
await Bun.write(options.output, jsonOutput)
|
|
138
|
-
chmodSync(options.output, 0o600)
|
|
139
|
-
ctx.console.log(`✓ Config written to ${options.output}`)
|
|
140
|
-
} catch (error: unknown) {
|
|
141
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
142
|
-
ctx.console.error(`Failed to write config to ${options.output}: ${message}`)
|
|
143
|
-
ctx.process.exit(1)
|
|
144
|
-
return undefined // unreachable
|
|
145
|
-
}
|
|
146
|
-
} else if (options.json) {
|
|
147
|
-
ctx.console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
|
|
148
|
-
ctx.console.log(jsonOutput)
|
|
149
|
-
} else {
|
|
150
|
-
ctx.console.error('⚠️ Output may contain API keys — avoid logging or storing in shared locations')
|
|
151
|
-
ctx.console.log(formatConfigAsColumns(payload))
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return payload
|
|
155
|
-
} catch (error) {
|
|
156
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
157
|
-
ctx.console.error(message)
|
|
158
|
-
ctx.process.exit(1)
|
|
159
|
-
return undefined // unreachable; satisfies TS that all paths return
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export interface ConfigSetOptions {
|
|
164
|
-
url?: string
|
|
165
|
-
key?: string
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export async function cliproxyConfigSetAction(
|
|
169
|
-
field: string,
|
|
170
|
-
value: string,
|
|
171
|
-
options: ConfigSetOptions,
|
|
172
|
-
ctx: ActionCtx,
|
|
173
|
-
): Promise<void> {
|
|
174
|
-
try {
|
|
175
|
-
const baseUrl = resolveBaseUrl(options.url)
|
|
176
|
-
const managementKey = resolveManagementKey(options.key)
|
|
177
|
-
const request = buildSetRequest(baseUrl, field, value)
|
|
178
|
-
|
|
179
|
-
const payload = await requestJson(request.endpoint, {
|
|
180
|
-
method: 'PUT',
|
|
181
|
-
headers: managementHeaders(managementKey),
|
|
182
|
-
body: request.body,
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
ctx.console.log(JSON.stringify(payload, null, 2))
|
|
186
|
-
} catch (error) {
|
|
187
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
188
|
-
ctx.console.error(message)
|
|
189
|
-
ctx.process.exit(1)
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function registerCliproxyConfig(cli: ReturnType<typeof goke>): void {
|
|
194
|
-
cli
|
|
195
|
-
.command('cliproxy config get', 'Fetch current CLIProxyAPI config as JSON.')
|
|
196
|
-
.option(
|
|
197
|
-
'--url [url]',
|
|
198
|
-
z
|
|
199
|
-
.string()
|
|
200
|
-
.describe(
|
|
201
|
-
'Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.',
|
|
202
|
-
),
|
|
203
|
-
)
|
|
204
|
-
.option(
|
|
205
|
-
'--key [key]',
|
|
206
|
-
z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
207
|
-
)
|
|
208
|
-
.option(
|
|
209
|
-
'--output [file]',
|
|
210
|
-
z
|
|
211
|
-
.string()
|
|
212
|
-
.describe('Write config JSON to a file instead of stdout. File permissions set to 0600 (owner-read-only).'),
|
|
213
|
-
)
|
|
214
|
-
.option('--json', 'Output raw JSON instead of aligned key: value columns.')
|
|
215
|
-
.action(cliproxyConfigGetAction)
|
|
216
|
-
|
|
217
|
-
cli
|
|
218
|
-
.command(
|
|
219
|
-
'cliproxy config set <field> <value>',
|
|
220
|
-
'Update a mutable CLIProxyAPI config field via its dedicated management endpoint.',
|
|
221
|
-
)
|
|
222
|
-
.option(
|
|
223
|
-
'--url [url]',
|
|
224
|
-
z
|
|
225
|
-
.string()
|
|
226
|
-
.describe(
|
|
227
|
-
'Base URL for CLIProxyAPI management requests. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.',
|
|
228
|
-
),
|
|
229
|
-
)
|
|
230
|
-
.option(
|
|
231
|
-
'--key [key]',
|
|
232
|
-
z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
233
|
-
)
|
|
234
|
-
.example('infra cliproxy config set debug true')
|
|
235
|
-
.example('infra cliproxy config set request-retry 5')
|
|
236
|
-
.example('infra cliproxy config set proxy-url https://proxy.example.com')
|
|
237
|
-
.action(cliproxyConfigSetAction)
|
|
238
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import {resolve} from 'node:path'
|
|
2
|
-
import {afterEach, beforeEach, describe, expect, it, spyOn} from 'bun:test'
|
|
3
|
-
|
|
4
|
-
import {getLocalDeployEnv, resolveLocalDeployScriptPath, validateRemotePreconditions} from './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
|
-
})
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {existsSync} from 'node:fs'
|
|
4
|
-
import {resolve} from 'node:path'
|
|
5
|
-
import {z} from 'zod'
|
|
6
|
-
|
|
7
|
-
const REPO = 'marcusrbrown/infra'
|
|
8
|
-
const WORKFLOW_NAME = 'Deploy CLIProxy'
|
|
9
|
-
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-cliproxy.yaml'
|
|
10
|
-
|
|
11
|
-
type CliInstance = ReturnType<typeof goke>
|
|
12
|
-
|
|
13
|
-
export function resolveLocalDeployScriptPath(): string {
|
|
14
|
-
const primary = resolve(import.meta.dir, '../../../../../apps/cliproxy/src/deploy.ts')
|
|
15
|
-
if (existsSync(primary)) {
|
|
16
|
-
return primary
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return resolve(import.meta.dir, '../../../../apps/cliproxy/src/deploy.ts')
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function getLocalDeployEnv(): Record<string, string> {
|
|
23
|
-
const path = process.env.PATH
|
|
24
|
-
const home = process.env.HOME
|
|
25
|
-
const sshAuthSock = process.env.SSH_AUTH_SOCK
|
|
26
|
-
|
|
27
|
-
if (!path) {
|
|
28
|
-
throw new Error('PATH is required for local deploy')
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (!home) {
|
|
32
|
-
throw new Error('HOME is required for local deploy')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (!sshAuthSock) {
|
|
36
|
-
throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
PATH: path,
|
|
41
|
-
HOME: home,
|
|
42
|
-
SSH_AUTH_SOCK: sshAuthSock,
|
|
43
|
-
CLIPROXY_DOMAIN: process.env.CLIPROXY_DOMAIN ?? '',
|
|
44
|
-
CLIPROXY_MANAGEMENT_KEY: process.env.CLIPROXY_MANAGEMENT_KEY ?? '',
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function validateLocalPreconditions(): {deployScriptPath: string} {
|
|
49
|
-
const deployScriptPath = resolveLocalDeployScriptPath()
|
|
50
|
-
if (!existsSync(deployScriptPath)) {
|
|
51
|
-
throw new Error(`Local deploy script not found at expected path: ${deployScriptPath}`)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
getLocalDeployEnv()
|
|
55
|
-
|
|
56
|
-
return {deployScriptPath}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function validateRemotePreconditions(): void {
|
|
60
|
-
if (!Bun.which('gh')) {
|
|
61
|
-
throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function registerCliproxyDeploy(cli: CliInstance): void {
|
|
66
|
-
cli
|
|
67
|
-
.command(
|
|
68
|
-
'cliproxy deploy',
|
|
69
|
-
'Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy CLIProxy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.',
|
|
70
|
-
)
|
|
71
|
-
.option(
|
|
72
|
-
'--local',
|
|
73
|
-
z
|
|
74
|
-
.boolean()
|
|
75
|
-
.default(false)
|
|
76
|
-
.describe(
|
|
77
|
-
'Run local deployment with Bun using apps/cliproxy/src/deploy.ts instead of triggering GitHub Actions.',
|
|
78
|
-
),
|
|
79
|
-
)
|
|
80
|
-
.option(
|
|
81
|
-
'--force-config',
|
|
82
|
-
z
|
|
83
|
-
.boolean()
|
|
84
|
-
.default(false)
|
|
85
|
-
.describe(
|
|
86
|
-
'Force upload config.yaml even if it exists on the server. WARNING: overwrites runtime API keys and settings.',
|
|
87
|
-
),
|
|
88
|
-
)
|
|
89
|
-
.option(
|
|
90
|
-
'--dry-run',
|
|
91
|
-
z
|
|
92
|
-
.boolean()
|
|
93
|
-
.default(false)
|
|
94
|
-
.describe(
|
|
95
|
-
'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
|
|
96
|
-
),
|
|
97
|
-
)
|
|
98
|
-
.example('# Trigger remote GitHub Actions deploy (default mode)')
|
|
99
|
-
.example('infra cliproxy deploy')
|
|
100
|
-
.example('# Validate local deploy preconditions and planned command')
|
|
101
|
-
.example('infra cliproxy deploy --local --dry-run')
|
|
102
|
-
.example('# Run local deploy with explicit SSH agent context')
|
|
103
|
-
.example('infra cliproxy deploy --local')
|
|
104
|
-
.action(async options => {
|
|
105
|
-
if (options.local) {
|
|
106
|
-
const {deployScriptPath} = validateLocalPreconditions()
|
|
107
|
-
const env = getLocalDeployEnv()
|
|
108
|
-
const command = ['bun', deployScriptPath, ...(options.forceConfig ? ['--force-config'] : [])]
|
|
109
|
-
|
|
110
|
-
if (options.dryRun) {
|
|
111
|
-
console.log('Dry run: local CLIProxyAPI deploy')
|
|
112
|
-
console.log(`- deploy script: ${deployScriptPath}`)
|
|
113
|
-
console.log(`- command: ${command.join(' ')}`)
|
|
114
|
-
if (options.forceConfig) {
|
|
115
|
-
console.log('- WARNING: --force-config will overwrite server config.yaml (wipes API keys)')
|
|
116
|
-
}
|
|
117
|
-
console.log(`- CLIPROXY_DOMAIN=${env.CLIPROXY_DOMAIN || '(unset)'}`)
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const child = Bun.spawn(command, {
|
|
122
|
-
env,
|
|
123
|
-
stdout: 'inherit',
|
|
124
|
-
stderr: 'inherit',
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
const exitCode = await child.exited
|
|
128
|
-
if (exitCode !== 0) {
|
|
129
|
-
throw new Error(`Local deploy failed with exit code ${exitCode}`)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
validateRemotePreconditions()
|
|
136
|
-
|
|
137
|
-
if (options.dryRun) {
|
|
138
|
-
console.log('Dry run: remote CLIProxyAPI deploy')
|
|
139
|
-
console.log(`- command: gh workflow run ${WORKFLOW_NAME} --repo ${REPO}`)
|
|
140
|
-
console.log(`- workflow URL: ${WORKFLOW_URL}`)
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
145
|
-
stdout: 'inherit',
|
|
146
|
-
stderr: 'inherit',
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
const exitCode = await child.exited
|
|
150
|
-
if (exitCode !== 0) {
|
|
151
|
-
throw new Error(`Failed to trigger ${WORKFLOW_NAME} workflow (exit code ${exitCode})`)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
155
|
-
console.log('Approve the cliproxy environment deployment in GitHub Actions to continue.')
|
|
156
|
-
})
|
|
157
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|
-
}
|