doctor.ts275 lines · main
1import pc from 'picocolors';
2
3import { readCredentials } from '../config.js';
4import { readProjectConfig } from '../project-config.js';
5import { banner, blankLine, error as printError, step, success } from '../output.js';
6
7interface ReadyResponse {
8 status: 'ready' | 'not_ready';
9 checks: Record<string, 'ok' | 'unreachable' | 'not_configured'>;
10}
11
12interface HealthResponse {
13 status: string;
14 service: string;
15 env: string;
16 bootedAt: string;
17}
18
19interface 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
29interface AuthInfoResponse {
30 projectId: string;
31 authenticatedVia: 'api_key' | 'session';
32 apiKeyId: string | null;
33 userId: string | null;
34}
35
36export 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
198let warnings = 0;
199let errors = 0;
200let jsonMode = false;
201interface CheckRow {
202 name: string;
203 level: 'ok' | 'warn' | 'fail';
204 detail: string;
205}
206let checks: CheckRow[] = [];
207
208function 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
224interface ApiOk<T> {
225 ok: true;
226 body: T;
227}
228interface ApiErr {
229 ok: false;
230 error: string;
231}
232
233async 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
260function 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
269function 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}