@marcusrbrown/infra 0.2.0 → 0.3.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 +4 -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 +82 -0
- package/src/commands/cliproxy-status.test.ts +271 -0
- package/src/commands/cliproxy-status.ts +274 -0
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
type CheckLevel = 'ok' | 'warning' | 'error'
|
|
9
|
+
|
|
10
|
+
interface CheckResult {
|
|
11
|
+
title: string
|
|
12
|
+
level: CheckLevel
|
|
13
|
+
summary: string
|
|
14
|
+
details?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function levelLabel(level: CheckLevel): string {
|
|
18
|
+
if (level === 'ok') {
|
|
19
|
+
return 'OK'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (level === 'warning') {
|
|
23
|
+
return 'WARN'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return 'ERROR'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatDurationMs(durationMs: number): string {
|
|
30
|
+
return `${Math.max(0, Math.round(durationMs))}ms`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function stripTrailingSlash(value: string): string {
|
|
34
|
+
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function managementHeaders(key: string): Headers {
|
|
38
|
+
const headers = new Headers()
|
|
39
|
+
headers.set('authorization', `Bearer ${key}`)
|
|
40
|
+
headers.set('x-management-key', key)
|
|
41
|
+
return headers
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function parseJsonResponse(response: Response): Promise<unknown> {
|
|
45
|
+
try {
|
|
46
|
+
return await response.json()
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function toNumber(value: unknown): number | null {
|
|
53
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function checkHttpReachability(url: string, verbose: boolean): Promise<CheckResult> {
|
|
57
|
+
const startedAt = performance.now()
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetch(url, {
|
|
61
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
62
|
+
})
|
|
63
|
+
const elapsedMs = performance.now() - startedAt
|
|
64
|
+
const details: string[] = []
|
|
65
|
+
|
|
66
|
+
if (verbose) {
|
|
67
|
+
details.push(`URL: ${url}`)
|
|
68
|
+
details.push(`Status text: ${response.statusText || '(none)'}`)
|
|
69
|
+
if (response.headers.get('content-type')) {
|
|
70
|
+
details.push(`Content-Type: ${response.headers.get('content-type')}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
title: 'HTTP reachability',
|
|
76
|
+
level: response.ok ? 'ok' : 'error',
|
|
77
|
+
summary: `GET ${url} → ${response.status} (${formatDurationMs(elapsedMs)})`,
|
|
78
|
+
details,
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const elapsedMs = performance.now() - startedAt
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
title: 'HTTP reachability',
|
|
86
|
+
level: 'error',
|
|
87
|
+
summary: `Request failed after ${formatDurationMs(elapsedMs)}: ${message}`,
|
|
88
|
+
details: verbose ? [`URL: ${url}`, `Timeout: ${HTTP_TIMEOUT_MS}ms`] : undefined,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function checkUsageStats(baseUrl: string, key: string): Promise<CheckResult> {
|
|
94
|
+
const endpoint = `${baseUrl}/v0/management/usage`
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch(endpoint, {
|
|
98
|
+
headers: managementHeaders(key),
|
|
99
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if (response.status === 429) {
|
|
103
|
+
return {
|
|
104
|
+
title: 'Usage stats',
|
|
105
|
+
level: 'warning',
|
|
106
|
+
summary: 'Rate limited by management API (HTTP 429). Retry in a few moments.',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
return {
|
|
112
|
+
title: 'Usage stats',
|
|
113
|
+
level: 'error',
|
|
114
|
+
summary: `GET /v0/management/usage failed with HTTP ${response.status}`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const payload = await parseJsonResponse(response)
|
|
119
|
+
const record = payload && typeof payload === 'object' ? payload : {}
|
|
120
|
+
const totalRequests = toNumber((record as Record<string, unknown>).total_requests)
|
|
121
|
+
const failureCount = toNumber((record as Record<string, unknown>).failure_count)
|
|
122
|
+
|
|
123
|
+
if (totalRequests === null || failureCount === null) {
|
|
124
|
+
return {
|
|
125
|
+
title: 'Usage stats',
|
|
126
|
+
level: 'warning',
|
|
127
|
+
summary: 'Management usage payload is missing expected numeric fields.',
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
title: 'Usage stats',
|
|
133
|
+
level: failureCount > 0 ? 'warning' : 'ok',
|
|
134
|
+
summary:
|
|
135
|
+
failureCount > 0
|
|
136
|
+
? `total_requests=${totalRequests}, failure_count=${failureCount} (token refresh likely needed)`
|
|
137
|
+
: `total_requests=${totalRequests}, failure_count=${failureCount}`,
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
141
|
+
return {
|
|
142
|
+
title: 'Usage stats',
|
|
143
|
+
level: 'error',
|
|
144
|
+
summary: `Unable to read usage stats: ${message}`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function checkVersion(baseUrl: string, key: string): Promise<CheckResult> {
|
|
150
|
+
const endpoint = `${baseUrl}/v0/management/latest-version`
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(endpoint, {
|
|
154
|
+
headers: managementHeaders(key),
|
|
155
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
if (response.status === 429) {
|
|
159
|
+
return {
|
|
160
|
+
title: 'Current version',
|
|
161
|
+
level: 'warning',
|
|
162
|
+
summary: 'Rate limited by management API (HTTP 429). Retry in a few moments.',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
return {
|
|
168
|
+
title: 'Current version',
|
|
169
|
+
level: 'error',
|
|
170
|
+
summary: `GET /v0/management/latest-version failed with HTTP ${response.status}`,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const payload = await parseJsonResponse(response)
|
|
175
|
+
|
|
176
|
+
if (typeof payload === 'string' && payload.length > 0) {
|
|
177
|
+
return {
|
|
178
|
+
title: 'Current version',
|
|
179
|
+
level: 'ok',
|
|
180
|
+
summary: payload,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (payload && typeof payload === 'object') {
|
|
185
|
+
const version = (payload as Record<string, unknown>).version
|
|
186
|
+
if (typeof version === 'string' && version.length > 0) {
|
|
187
|
+
return {
|
|
188
|
+
title: 'Current version',
|
|
189
|
+
level: 'ok',
|
|
190
|
+
summary: version,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
title: 'Current version',
|
|
197
|
+
level: 'warning',
|
|
198
|
+
summary: 'Management version payload did not include a usable version string.',
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
202
|
+
return {
|
|
203
|
+
title: 'Current version',
|
|
204
|
+
level: 'error',
|
|
205
|
+
summary: `Unable to read current version: ${message}`,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function printCheckResult(result: CheckResult): void {
|
|
211
|
+
console.log(`[${levelLabel(result.level)}] ${result.title}`)
|
|
212
|
+
console.log(` ${result.summary}`)
|
|
213
|
+
|
|
214
|
+
if (result.details && result.details.length > 0) {
|
|
215
|
+
for (const detail of result.details) {
|
|
216
|
+
console.log(` - ${detail}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function registerCliproxyStatus(cli: ReturnType<typeof goke>): void {
|
|
222
|
+
cli
|
|
223
|
+
.command('cliproxy status', 'Show operational health of CLIProxyAPI and its management endpoints.')
|
|
224
|
+
.option(
|
|
225
|
+
'--url [url]',
|
|
226
|
+
z
|
|
227
|
+
.string()
|
|
228
|
+
.describe('Base URL for CLIProxyAPI health checks. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.'),
|
|
229
|
+
)
|
|
230
|
+
.option(
|
|
231
|
+
'--key [key]',
|
|
232
|
+
z.string().describe('Management API bearer token. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
|
|
233
|
+
)
|
|
234
|
+
.action(async options => {
|
|
235
|
+
const verbose = options.verbose === true
|
|
236
|
+
const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
237
|
+
const managementKey = options.key ?? process.env.CLIPROXY_MANAGEMENT_KEY
|
|
238
|
+
|
|
239
|
+
console.log('CLIProxyAPI status')
|
|
240
|
+
console.log('')
|
|
241
|
+
|
|
242
|
+
const results: CheckResult[] = [await checkHttpReachability(baseUrl, verbose)]
|
|
243
|
+
|
|
244
|
+
if (managementKey) {
|
|
245
|
+
const [usageResult, versionResult] = await Promise.all([
|
|
246
|
+
checkUsageStats(baseUrl, managementKey),
|
|
247
|
+
checkVersion(baseUrl, managementKey),
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
results.push(usageResult, versionResult)
|
|
251
|
+
} else {
|
|
252
|
+
results.push({
|
|
253
|
+
title: 'Management checks',
|
|
254
|
+
level: 'warning',
|
|
255
|
+
summary:
|
|
256
|
+
'CLIPROXY_MANAGEMENT_KEY is not set. Skipping usage stats and version checks. Provide --key or set env var.',
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const result of results) {
|
|
261
|
+
printCheckResult(result)
|
|
262
|
+
console.log('')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const errorCount = results.filter(result => result.level === 'error').length
|
|
266
|
+
const warningCount = results.filter(result => result.level === 'warning').length
|
|
267
|
+
|
|
268
|
+
console.log(`Summary: ${results.length} checks, ${errorCount} errors, ${warningCount} warnings`)
|
|
269
|
+
|
|
270
|
+
if (errorCount > 0) {
|
|
271
|
+
process.exitCode = 1
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
}
|