Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

preflight.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

preflight.tsBlame500 lines · 1 contributor
febd4f0Claude1#!/usr/bin/env bun
2/**
3 * Preflight — run a sequence of deploy-readiness checks.
4 *
5 * Usage:
6 * bun scripts/preflight.ts
7 * PREFLIGHT_BACKUP_DRILL=1 bun scripts/preflight.ts
8 *
9 * Exit code 0 iff every non-skipped check passes.
10 */
11
12import { access, mkdir, writeFile, readFile, rm, stat } from "fs/promises";
13import { constants as fsConstants } from "fs";
14import { join } from "path";
15import { tmpdir } from "os";
16
17type Status = "pass" | "fail" | "warn" | "skip";
18interface Result {
19 n: number;
20 name: string;
21 status: Status;
22 reason?: string;
23 notes?: string[];
24}
25
26const TOTAL = 7;
27const results: Result[] = [];
28
29const GREEN = "\x1b[32m";
30const RED = "\x1b[31m";
31const YELLOW = "\x1b[33m";
32const DIM = "\x1b[2m";
33const RESET = "\x1b[0m";
34
35function icon(s: Status): string {
36 switch (s) {
37 case "pass":
38 return `${GREEN}✅${RESET}`;
39 case "fail":
40 return `${RED}❌${RESET}`;
41 case "warn":
42 return `${YELLOW}⚠️${RESET} `;
43 case "skip":
44 return `${DIM}⏭${RESET} `;
45 }
46}
47
48function record(r: Result) {
49 results.push(r);
50 const tag = `[${r.n}/${TOTAL}]`;
51 const tail = r.reason ? ` — ${r.reason}` : "";
52 console.log(`${tag} ${icon(r.status)} ${r.name}${tail}`);
53 if (r.notes) for (const line of r.notes) console.log(` ${DIM}${line}${RESET}`);
54}
55
56async function pathExists(p: string): Promise<boolean> {
57 try {
58 await access(p, fsConstants.F_OK);
59 return true;
60 } catch {
61 return false;
62 }
63}
64
65async function isWritable(p: string): Promise<boolean> {
66 try {
67 await access(p, fsConstants.W_OK);
68 return true;
69 } catch {
70 return false;
71 }
72}
73
74// ---- Check 1 — env sanity ---------------------------------------------------
75async function checkEnv(n: number) {
76 const notes: string[] = [];
77 const missing: string[] = [];
78
79 if (!process.env.DATABASE_URL) missing.push("DATABASE_URL");
80
81 if (!process.env.GIT_REPOS_PATH) {
82 notes.push("GIT_REPOS_PATH not set — defaulting to ./repos");
83 }
84 if (!process.env.ANTHROPIC_API_KEY) {
85 notes.push("ANTHROPIC_API_KEY missing — AI features will degrade");
86 }
87 if (!process.env.ERROR_WEBHOOK_URL && !process.env.SENTRY_DSN) {
88 notes.push("No ERROR_WEBHOOK_URL or SENTRY_DSN — errors will not be reported upstream");
89 }
90
91 if (missing.length > 0) {
92 record({
93 n,
94 name: "Env sanity",
95 status: "fail",
96 reason: `missing required env: ${missing.join(", ")}`,
97 notes,
98 });
99 return;
100 }
101
102 record({
103 n,
104 name: "Env sanity",
105 status: notes.length > 0 ? "warn" : "pass",
106 notes,
107 });
108}
109
110// ---- Check 2 — migrations ---------------------------------------------------
111async function checkMigrations(n: number) {
112 if (!process.env.DATABASE_URL) {
113 record({
114 n,
115 name: "Migrations",
116 status: "fail",
117 reason: "DATABASE_URL not set — cannot run migrations",
118 });
119 return;
120 }
121
122 const cmd = ["bun", "run", "src/db/migrate.ts"];
123 const proc = Bun.spawn(cmd, {
124 cwd: process.cwd(),
125 stdout: "pipe",
126 stderr: "pipe",
127 env: process.env,
128 });
129 const exitCode = await proc.exited;
130
131 if (exitCode !== 0) {
132 const errText = await new Response(proc.stderr).text();
133 const tail = errText.trim().split("\n").slice(-3).join(" | ");
134 record({
135 n,
136 name: "Migrations",
137 status: "fail",
138 reason: `bun run db:migrate exited ${exitCode}: ${tail.slice(0, 200)}`,
139 });
140 return;
141 }
142
143 record({ n, name: "Migrations", status: "pass" });
144}
145
146// ---- Check 3 — repo dir -----------------------------------------------------
147async function checkRepoDir(n: number) {
148 const repoDir =
149 process.env.GIT_REPOS_PATH || join(process.cwd(), "repos");
150
151 try {
152 if (!(await pathExists(repoDir))) {
153 await mkdir(repoDir, { recursive: true });
154 }
155 const s = await stat(repoDir);
156 if (!s.isDirectory()) {
157 record({
158 n,
159 name: "Repo dir",
160 status: "fail",
161 reason: `${repoDir} exists but is not a directory`,
162 });
163 return;
164 }
165 if (!(await isWritable(repoDir))) {
166 record({
167 n,
168 name: "Repo dir",
169 status: "fail",
170 reason: `${repoDir} is not writable`,
171 });
172 return;
173 }
174 // Prove write by touching a sentinel.
175 const sentinel = join(repoDir, ".preflight-touch");
176 await writeFile(sentinel, String(Date.now()));
177 await rm(sentinel);
178 record({
179 n,
180 name: "Repo dir",
181 status: "pass",
182 notes: [`path=${repoDir}`],
183 });
184 } catch (err) {
185 record({
186 n,
187 name: "Repo dir",
188 status: "fail",
189 reason: err instanceof Error ? err.message : String(err),
190 });
191 }
192}
193
194// ---- Shared: spawn server for smoke tests ----------------------------------
195async function spawnServer(port: number) {
196 const proc = Bun.spawn(["bun", "src/index.ts"], {
197 cwd: process.cwd(),
198 stdout: "pipe",
199 stderr: "pipe",
200 env: { ...process.env, PORT: String(port) },
201 });
202 // Wait up to ~3s for it to start accepting connections.
203 const deadline = Date.now() + 3000;
204 while (Date.now() < deadline) {
205 try {
206 const r = await fetch(`http://127.0.0.1:${port}/healthz`, {
207 signal: AbortSignal.timeout(500),
208 });
209 // Any response — even 404 — means the server is up.
210 void r.text();
211 break;
212 } catch {
213 await new Promise((r) => setTimeout(r, 200));
214 }
215 }
216 return proc;
217}
218
219async function hitEndpoint(port: number, path: string) {
220 const res = await fetch(`http://127.0.0.1:${port}${path}`, {
221 signal: AbortSignal.timeout(3000),
222 });
223 const text = await res.text();
224 let body: unknown = text;
225 try {
226 body = JSON.parse(text);
227 } catch {
228 /* leave as text */
229 }
230 return { status: res.status, body, text };
231}
232
233// ---- Check 4 — /healthz smoke ----------------------------------------------
234async function checkHealthz(n: number) {
235 const port = Number(process.env.PREFLIGHT_PORT || 3999);
236 let proc: ReturnType<typeof Bun.spawn> | undefined;
237 try {
238 proc = await spawnServer(port);
239 const { status, body, text } = await hitEndpoint(port, "/healthz");
240 if (status !== 200) {
241 record({
242 n,
243 name: "Healthz smoke",
244 status: "fail",
245 reason: `status ${status}`,
246 });
247 return;
248 }
249 const ok =
250 (typeof body === "object" &&
251 body !== null &&
252 (body as { status?: string }).status === "ok") ||
253 /"?status"?\s*:\s*"?ok"?/i.test(text);
254 if (!ok) {
255 record({
256 n,
257 name: "Healthz smoke",
258 status: "fail",
259 reason: `200 but body did not look healthy: ${text.slice(0, 100)}`,
260 });
261 return;
262 }
263 record({ n, name: "Healthz smoke", status: "pass" });
264 } catch (err) {
265 record({
266 n,
267 name: "Healthz smoke",
268 status: "fail",
269 reason: err instanceof Error ? err.message : String(err),
270 });
271 } finally {
272 if (proc) {
273 try {
274 proc.kill();
275 await proc.exited;
276 } catch {
277 /* ignore */
278 }
279 }
280 }
281}
282
283// ---- Check 5 — /readyz smoke -----------------------------------------------
284async function checkReadyz(n: number) {
285 const port = Number(process.env.PREFLIGHT_PORT || 3999) + 1;
286 let proc: ReturnType<typeof Bun.spawn> | undefined;
287 try {
288 proc = await spawnServer(port);
289 const { status, body, text } = await hitEndpoint(port, "/readyz");
290 if (status === 200) {
291 const ok =
292 (typeof body === "object" &&
293 body !== null &&
294 (body as { status?: string }).status === "ok") ||
295 /ok|ready/i.test(text);
296 if (!ok) {
297 record({
298 n,
299 name: "Readyz smoke",
300 status: "warn",
301 reason: `200 but body looked degraded: ${text.slice(0, 100)}`,
302 });
303 return;
304 }
305 record({ n, name: "Readyz smoke", status: "pass" });
306 return;
307 }
308 if (status === 503) {
309 record({
310 n,
311 name: "Readyz smoke",
312 status: "warn",
313 reason: "503 — dependency reported degraded (DB?)",
314 });
315 return;
316 }
317 record({
318 n,
319 name: "Readyz smoke",
320 status: "warn",
321 reason: `unexpected status ${status}`,
322 });
323 } catch (err) {
324 record({
325 n,
326 name: "Readyz smoke",
327 status: "warn",
328 reason: err instanceof Error ? err.message : String(err),
329 });
330 } finally {
331 if (proc) {
332 try {
333 proc.kill();
334 await proc.exited;
335 } catch {
336 /* ignore */
337 }
338 }
339 }
340}
341
342// ---- Check 6 — test suite ---------------------------------------------------
343const BASELINE_PASS = 154;
344const BASELINE_FAIL = 55;
345
346async function checkTests(n: number) {
347 const proc = Bun.spawn(["bun", "test"], {
348 cwd: process.cwd(),
349 stdout: "pipe",
350 stderr: "pipe",
351 env: process.env,
352 });
353 const [outText, errText] = await Promise.all([
354 new Response(proc.stdout).text(),
355 new Response(proc.stderr).text(),
356 ]);
357 await proc.exited;
358 const combined = `${outText}\n${errText}`;
359
360 // Bun test summary lines look like " 42 pass" / " 3 fail".
361 const passMatch = combined.match(/(\d+)\s+pass\b/);
362 const failMatch = combined.match(/(\d+)\s+fail\b/);
363 const pass = passMatch ? Number(passMatch[1]) : 0;
364 const fail = failMatch ? Number(failMatch[1]) : 0;
365
366 const notes = [
367 `baseline: ${BASELINE_PASS} pass / ${BASELINE_FAIL} fail (known sandbox hono/jsx-dev-runtime errors)`,
368 `observed: ${pass} pass / ${fail} fail`,
369 ];
370
371 if (!passMatch && !failMatch) {
372 record({
373 n,
374 name: "Test suite",
375 status: "fail",
376 reason: "could not parse bun test output",
377 notes,
378 });
379 return;
380 }
381
382 if (pass < BASELINE_PASS) {
383 record({
384 n,
385 name: "Test suite",
386 status: "fail",
387 reason: `pass count dropped below baseline (${pass} < ${BASELINE_PASS})`,
388 notes,
389 });
390 return;
391 }
392
393 if (fail > BASELINE_FAIL) {
394 record({
395 n,
396 name: "Test suite",
397 status: "warn",
398 reason: `fail count above baseline (${fail} > ${BASELINE_FAIL}) but pass held`,
399 notes,
400 });
401 return;
402 }
403
404 record({ n, name: "Test suite", status: "pass", notes });
405}
406
407// ---- Check 7 — backup restore drill ----------------------------------------
408async function checkBackupDrill(n: number) {
409 if (process.env.PREFLIGHT_BACKUP_DRILL !== "1") {
410 record({
411 n,
412 name: "Backup restore drill",
413 status: "skip",
414 reason: "set PREFLIGHT_BACKUP_DRILL=1 to enable",
415 });
416 return;
417 }
418 const root = join(tmpdir(), `preflight-backup-${Date.now()}`);
419 const src = join(root, "src");
420 const dst = join(root, "dst");
421 try {
422 await mkdir(src, { recursive: true });
423 await mkdir(dst, { recursive: true });
424 const payload = `preflight ${new Date().toISOString()}`;
425 const srcFile = join(src, "hello.txt");
426 const dstFile = join(dst, "hello.txt");
427 await writeFile(srcFile, payload);
428
429 // Prefer rsync; fall back to raw copy.
430 const rsync = Bun.spawn(["rsync", "-a", `${src}/`, `${dst}/`], {
431 stdout: "pipe",
432 stderr: "pipe",
433 });
434 const code = await rsync.exited;
435 if (code !== 0) {
436 const buf = await readFile(srcFile);
437 await writeFile(dstFile, buf);
438 }
439
440 const got = await readFile(dstFile, "utf8");
441 if (got !== payload) {
442 record({
443 n,
444 name: "Backup restore drill",
445 status: "fail",
446 reason: "restored file differs from source",
447 });
448 return;
449 }
450 record({ n, name: "Backup restore drill", status: "pass" });
451 } catch (err) {
452 record({
453 n,
454 name: "Backup restore drill",
455 status: "fail",
456 reason: err instanceof Error ? err.message : String(err),
457 });
458 } finally {
459 await rm(root, { recursive: true, force: true }).catch(() => {});
460 }
461}
462
463// ---- Driver -----------------------------------------------------------------
464async function main() {
465 console.log(`${DIM}gluecron preflight — ${new Date().toISOString()}${RESET}`);
466
467 await checkEnv(1);
468 await checkMigrations(2);
469 await checkRepoDir(3);
470 await checkHealthz(4);
471 await checkReadyz(5);
472 await checkTests(6);
473 await checkBackupDrill(7);
474
475 const passed = results.filter((r) => r.status === "pass").length;
476 const failed = results.filter((r) => r.status === "fail").length;
477 const warned = results.filter((r) => r.status === "warn").length;
478 const skipped = results.filter((r) => r.status === "skip").length;
479
480 console.log("");
481 console.log(
482 `${passed} passed, ${failed} failed, ${warned} warned, ${skipped} skipped`
483 );
484
485 if (failed > 0) {
486 console.log(`${RED}preflight FAILED — do not deploy${RESET}`);
487 process.exit(1);
488 }
489 if (warned > 0) {
490 console.log(`${YELLOW}preflight passed with warnings${RESET}`);
491 } else {
492 console.log(`${GREEN}preflight clean — ready to deploy${RESET}`);
493 }
494 process.exit(0);
495}
496
497main().catch((err) => {
498 console.error("preflight crashed:", err);
499 process.exit(1);
500});