doctor.ts275 lines · main
| 1 | import pc from 'picocolors'; |
| 2 | |
| 3 | import { readCredentials } from '../config.js'; |
| 4 | import { readProjectConfig } from '../project-config.js'; |
| 5 | import { banner, blankLine, error as printError, step, success } from '../output.js'; |
| 6 | |
| 7 | interface ReadyResponse { |
| 8 | status: 'ready' | 'not_ready'; |
| 9 | checks: Record<string, 'ok' | 'unreachable' | 'not_configured'>; |
| 10 | } |
| 11 | |
| 12 | interface HealthResponse { |
| 13 | status: string; |
| 14 | service: string; |
| 15 | env: string; |
| 16 | bootedAt: string; |
| 17 | } |
| 18 | |
| 19 | interface BuildInfoResponse { |
| 20 | service: string; |
| 21 | env: string; |
| 22 | buildSha: string; |
| 23 | buildAt: string; |
| 24 | bootedAt: string; |
| 25 | uptimeSec: number; |
| 26 | domain: string | null; |
| 27 | } |
| 28 | |
| 29 | interface AuthInfoResponse { |
| 30 | projectId: string; |
| 31 | authenticatedVia: 'api_key' | 'session'; |
| 32 | apiKeyId: string | null; |
| 33 | userId: string | null; |
| 34 | } |
| 35 | |
| 36 | export async function runDoctor(argv: readonly string[]): Promise<number> { |
| 37 | if (argv[0] === '--help' || argv[0] === '-h') { |
| 38 | banner('doctor'); |
| 39 | blankLine(); |
| 40 | step('briven doctor end-to-end health check against the linked api'); |
| 41 | step('briven doctor --origin URL override the api origin (skip stored creds)'); |
| 42 | step('briven doctor --json machine-readable output (for scripts and CI)'); |
| 43 | return 0; |
| 44 | } |
| 45 | |
| 46 | // Reset module-level state — runDoctor can be called multiple times |
| 47 | // from the same process during tests. |
| 48 | warnings = 0; |
| 49 | errors = 0; |
| 50 | checks = []; |
| 51 | |
| 52 | jsonMode = argv.includes('--json'); |
| 53 | |
| 54 | const flag = argv.findIndex((a) => a === '--origin'); |
| 55 | const cliOrigin = flag !== -1 && flag + 1 < argv.length ? argv[flag + 1] : undefined; |
| 56 | |
| 57 | if (!jsonMode) { |
| 58 | banner('doctor'); |
| 59 | blankLine(); |
| 60 | } |
| 61 | |
| 62 | let origin: string | null = cliOrigin ?? null; |
| 63 | let projectId: string | null = null; |
| 64 | let apiKey: string | null = null; |
| 65 | |
| 66 | if (!origin) { |
| 67 | const local = await readProjectConfig(); |
| 68 | if (local) { |
| 69 | check( |
| 70 | 'briven.json', |
| 71 | 'ok', |
| 72 | `name=${local.name ?? '<unset>'} projectId=${local.projectId ?? '<unset>'}`, |
| 73 | ); |
| 74 | projectId = local.projectId ?? null; |
| 75 | } else { |
| 76 | check('briven.json', 'warn', 'not found in cwd — running stack-only checks'); |
| 77 | } |
| 78 | if (projectId) { |
| 79 | const creds = await readCredentials(); |
| 80 | const cred = creds.projects[projectId]; |
| 81 | if (cred) { |
| 82 | origin = cred.apiOrigin; |
| 83 | apiKey = cred.apiKey; |
| 84 | check('credentials', 'ok', `origin=${cred.apiOrigin}`); |
| 85 | } else { |
| 86 | check('credentials', 'warn', `no api key for ${projectId}`); |
| 87 | } |
| 88 | } |
| 89 | } else { |
| 90 | check('credentials', 'ok', `using --origin ${origin}`); |
| 91 | } |
| 92 | |
| 93 | if (!origin) { |
| 94 | if (jsonMode) { |
| 95 | process.stdout.write( |
| 96 | JSON.stringify({ ok: false, error: 'no_api_origin', checks }, null, 2) + '\n', |
| 97 | ); |
| 98 | return 1; |
| 99 | } |
| 100 | blankLine(); |
| 101 | printError('no api origin to test — link a project or pass --origin <url>.'); |
| 102 | return 1; |
| 103 | } |
| 104 | |
| 105 | // Process liveness — must be ok or the host is dead. |
| 106 | const health = await fetchJson<HealthResponse>(`${origin}/health`); |
| 107 | if (!health.ok) { |
| 108 | check('api/health', 'fail', health.error); |
| 109 | } else { |
| 110 | check( |
| 111 | 'api/health', |
| 112 | 'ok', |
| 113 | `${health.body.service} env=${health.body.env} booted=${formatBoot(health.body.bootedAt)}`, |
| 114 | ); |
| 115 | } |
| 116 | |
| 117 | // Build identity — non-fatal if missing (older deploys, dev mode). |
| 118 | const info = await fetchJson<BuildInfoResponse>(`${origin}/info`); |
| 119 | if (info.ok) { |
| 120 | const sha = info.body.buildSha.slice(0, 12); |
| 121 | const uptime = formatUptime(info.body.uptimeSec); |
| 122 | check('api/info', 'ok', `build=${sha} built=${info.body.buildAt} up=${uptime}`); |
| 123 | } else { |
| 124 | check('api/info', 'warn', `unavailable (${info.error})`); |
| 125 | } |
| 126 | |
| 127 | // Dependency readiness — surfaces every sub-status. |
| 128 | const ready = await fetchJson<ReadyResponse>(`${origin}/ready`); |
| 129 | if (!ready.ok) { |
| 130 | check('api/ready', 'fail', ready.error); |
| 131 | } else { |
| 132 | const overall = ready.body.status === 'ready' ? 'ok' : 'fail'; |
| 133 | check('api/ready', overall, ready.body.status); |
| 134 | for (const [name, state] of Object.entries(ready.body.checks)) { |
| 135 | const lvl = state === 'ok' ? 'ok' : state === 'not_configured' ? 'warn' : 'fail'; |
| 136 | check(` ${name}`, lvl, state); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // Realtime is on a sibling subdomain; derive it from the api origin when |
| 141 | // the api host starts with "api.". For dev/local origins we skip this |
| 142 | // because there's nothing to derive. |
| 143 | const realtimeOrigin = origin.replace(/:\/\/api\./, '://realtime.'); |
| 144 | if (realtimeOrigin !== origin) { |
| 145 | const rt = await fetchJson<{ status: string }>(`${realtimeOrigin}/health`); |
| 146 | if (!rt.ok) check('realtime/health', 'warn', rt.error); |
| 147 | else check('realtime/health', 'ok', rt.body.status ?? 'ok'); |
| 148 | } |
| 149 | |
| 150 | // If we have stored creds, confirm the api accepts the key. /info is the |
| 151 | // dedicated whoami endpoint; it returns 401 if the key is wrong. |
| 152 | if (apiKey && projectId) { |
| 153 | const auth = await fetchJson<AuthInfoResponse>(`${origin}/v1/projects/${projectId}/info`, apiKey); |
| 154 | if (!auth.ok) { |
| 155 | check('auth (api key)', 'fail', auth.error); |
| 156 | } else if (auth.body.projectId === projectId) { |
| 157 | check('auth (api key)', 'ok', `via=${auth.body.authenticatedVia}`); |
| 158 | } else { |
| 159 | check('auth (api key)', 'warn', 'unexpected response shape'); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | if (jsonMode) { |
| 164 | const okFlag = errors === 0; |
| 165 | const buildSha = info.ok ? info.body.buildSha : null; |
| 166 | const buildAt = info.ok ? info.body.buildAt : null; |
| 167 | process.stdout.write( |
| 168 | JSON.stringify( |
| 169 | { |
| 170 | ok: okFlag, |
| 171 | origin, |
| 172 | buildSha, |
| 173 | buildAt, |
| 174 | warnings, |
| 175 | errors, |
| 176 | checks, |
| 177 | }, |
| 178 | null, |
| 179 | 2, |
| 180 | ) + '\n', |
| 181 | ); |
| 182 | return okFlag ? 0 : 1; |
| 183 | } |
| 184 | |
| 185 | blankLine(); |
| 186 | if (errors > 0) { |
| 187 | printError(`${errors} check${errors === 1 ? '' : 's'} failed`); |
| 188 | return 1; |
| 189 | } |
| 190 | if (warnings > 0) { |
| 191 | success(`required checks ok (${warnings} warning${warnings === 1 ? '' : 's'})`); |
| 192 | return 0; |
| 193 | } |
| 194 | success('all checks ok'); |
| 195 | return 0; |
| 196 | } |
| 197 | |
| 198 | let warnings = 0; |
| 199 | let errors = 0; |
| 200 | let jsonMode = false; |
| 201 | interface CheckRow { |
| 202 | name: string; |
| 203 | level: 'ok' | 'warn' | 'fail'; |
| 204 | detail: string; |
| 205 | } |
| 206 | let checks: CheckRow[] = []; |
| 207 | |
| 208 | function check(name: string, level: 'ok' | 'warn' | 'fail', detail: string): void { |
| 209 | if (level === 'warn') warnings += 1; |
| 210 | if (level === 'fail') errors += 1; |
| 211 | checks.push({ name: name.trim(), level, detail }); |
| 212 | |
| 213 | if (jsonMode) return; |
| 214 | |
| 215 | const tag = |
| 216 | level === 'ok' |
| 217 | ? pc.green('ok ') |
| 218 | : level === 'warn' |
| 219 | ? pc.yellow('warn') |
| 220 | : pc.red('fail'); |
| 221 | step(`${tag} ${name.padEnd(22)} ${pc.dim(detail)}`); |
| 222 | } |
| 223 | |
| 224 | interface ApiOk<T> { |
| 225 | ok: true; |
| 226 | body: T; |
| 227 | } |
| 228 | interface ApiErr { |
| 229 | ok: false; |
| 230 | error: string; |
| 231 | } |
| 232 | |
| 233 | async function fetchJson<T>(url: string, apiKey?: string): Promise<ApiOk<T> | ApiErr> { |
| 234 | try { |
| 235 | const headers: Record<string, string> = { accept: 'application/json' }; |
| 236 | if (apiKey) headers.authorization = `Bearer ${apiKey}`; |
| 237 | const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) }); |
| 238 | const text = await res.text(); |
| 239 | if (res.status === 401) return { ok: false, error: 'unauthorized (401)' }; |
| 240 | if (res.status === 403) return { ok: false, error: 'forbidden (403)' }; |
| 241 | if (res.status === 404) return { ok: false, error: 'not found (404)' }; |
| 242 | // /ready returns 503 when not ready; we still want the parsed body. |
| 243 | if (!text) return { ok: false, error: `http ${res.status} empty body` }; |
| 244 | try { |
| 245 | return { ok: true, body: JSON.parse(text) as T }; |
| 246 | } catch { |
| 247 | return { ok: false, error: `non-json response (${res.status})` }; |
| 248 | } |
| 249 | } catch (err) { |
| 250 | if (err instanceof Error) { |
| 251 | if (err.name === 'TimeoutError' || err.name === 'AbortError') { |
| 252 | return { ok: false, error: 'timeout (5s)' }; |
| 253 | } |
| 254 | return { ok: false, error: err.message }; |
| 255 | } |
| 256 | return { ok: false, error: 'unknown' }; |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | function formatBoot(iso: string): string { |
| 261 | const then = new Date(iso).getTime(); |
| 262 | if (!Number.isFinite(then)) return iso; |
| 263 | const sec = Math.max(0, Math.round((Date.now() - then) / 1000)); |
| 264 | if (sec < 60) return `${sec}s ago`; |
| 265 | if (sec < 3600) return `${Math.round(sec / 60)}m ago`; |
| 266 | return `${Math.round(sec / 3600)}h ago`; |
| 267 | } |
| 268 | |
| 269 | function formatUptime(sec: number): string { |
| 270 | if (!Number.isFinite(sec) || sec < 0) return '?'; |
| 271 | if (sec < 60) return `${sec}s`; |
| 272 | if (sec < 3600) return `${Math.round(sec / 60)}m`; |
| 273 | if (sec < 86400) return `${Math.round(sec / 3600)}h`; |
| 274 | return `${Math.round(sec / 86400)}d`; |
| 275 | } |