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.
| febd4f0 | 1 | #!/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 | ||
| 12 | import { access, mkdir, writeFile, readFile, rm, stat } from "fs/promises"; | |
| 13 | import { constants as fsConstants } from "fs"; | |
| 14 | import { join } from "path"; | |
| 15 | import { tmpdir } from "os"; | |
| 16 | ||
| 17 | type Status = "pass" | "fail" | "warn" | "skip"; | |
| 18 | interface Result { | |
| 19 | n: number; | |
| 20 | name: string; | |
| 21 | status: Status; | |
| 22 | reason?: string; | |
| 23 | notes?: string[]; | |
| 24 | } | |
| 25 | ||
| 26 | const TOTAL = 7; | |
| 27 | const results: Result[] = []; | |
| 28 | ||
| 29 | const GREEN = "\x1b[32m"; | |
| 30 | const RED = "\x1b[31m"; | |
| 31 | const YELLOW = "\x1b[33m"; | |
| 32 | const DIM = "\x1b[2m"; | |
| 33 | const RESET = "\x1b[0m"; | |
| 34 | ||
| 35 | function 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 | ||
| 48 | function 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 | ||
| 56 | async 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 | ||
| 65 | async 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 --------------------------------------------------- | |
| 75 | async 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 --------------------------------------------------- | |
| 111 | async 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 ----------------------------------------------------- | |
| 147 | async 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 ---------------------------------- | |
| 195 | async 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 | ||
| 219 | async 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 ---------------------------------------------- | |
| 234 | async 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 ----------------------------------------------- | |
| 284 | async 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 --------------------------------------------------- | |
| 343 | const BASELINE_PASS = 154; | |
| 344 | const BASELINE_FAIL = 55; | |
| 345 | ||
| 346 | async 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 ---------------------------------------- | |
| 408 | async 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 ----------------------------------------------------------------- | |
| 464 | async 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 | ||
| 497 | main().catch((err) => { | |
| 498 | console.error("preflight crashed:", err); | |
| 499 | process.exit(1); | |
| 500 | }); |