import { execFile } from "node:child_process"; import { constants } from "node:fs"; import { access, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { promisify } from "node:util"; import { Pool } from "pg"; import { type Config, loadConfigFromSources } from "../../../packages/core/src/config.js"; const execFileAsync = promisify(execFile); export type DoctorStatus = "pass" | "fail" | "warn"; export interface DoctorResult { name: string; status: DoctorStatus; detail: string; remediation: string; } export interface CommandResult { exitCode: number; stdout: string; stderr: string; } export type DoctorCommandRunner = ( command: string, args?: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }, ) => Promise; export interface DoctorOptions { cwd?: string; env?: Record; nodeVersion?: string; commandRunner?: DoctorCommandRunner; connectDatabase?: (databaseUrl: string) => Promise; countAppliedMigrations?: (databaseUrl: string) => Promise; countExpectedMigrations?: (cwd: string) => Promise | number; dockerComposePath?: string; } const MIN_DISK_KB = 5 * 1024 * 1024; const WARN_DISK_KB = 10 * 1024 * 1024; const FAIL_DISK_KB = 2 * 1024 * 1024; export async function runDoctor(options: DoctorOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; const commandRunner = options.commandRunner ?? defaultCommandRunner; const dockerCommand = options.dockerComposePath ?? "docker"; const results: DoctorResult[] = []; const configResult = loadDoctorConfig(cwd, env); results.push(checkNodeVersion(options.nodeVersion ?? process.versions.node)); results.push(await checkCommandVersion("pnpm", ["--version"], "9.0.0", commandRunner)); results.push(await checkCommandVersion("tmux", ["-V"], "3.3.0", commandRunner)); results.push(await checkCommandVersion("git", ["--version"], "2.40.0", commandRunner)); results.push(await checkDocker(commandRunner)); results.push( await checkPostgres({ commandRunner, config: configResult.config, configError: configResult.error, connectDatabase: options.connectDatabase ?? defaultConnectDatabase, dockerCommand, cwd, }), ); results.push( await checkMigrations({ config: configResult.config, configError: configResult.error, countAppliedMigrations: options.countAppliedMigrations ?? defaultCountAppliedMigrations, countExpectedMigrations: options.countExpectedMigrations ?? defaultCountExpectedMigrations, cwd, }), ); results.push(await checkWorkspaceRoot(configResult.config, configResult.error)); results.push(checkConfig(configResult.config, configResult.error)); results.push(await checkOptionalBinary("codex", commandRunner)); results.push(await checkOptionalBinary("claude", commandRunner)); results.push(await checkWorkspaceDisk(configResult.config, commandRunner)); return results; } function loadDoctorConfig( cwd: string, env: Record, ): { config?: Config; error?: unknown } { try { return { config: loadConfigFromSources({ cwd, env }) }; } catch (error) { return { error }; } } function checkNodeVersion(version: string): DoctorResult { if (satisfiesMin(version, "22.0.0") && compareVersions(version, "23.0.0") < 0) { return pass("node", version, "Node is within >=22.0.0 <23"); } return fail("node", version, "Install Node 22 LTS and rerun doctor"); } async function checkCommandVersion( name: string, args: string[], minimum: string, commandRunner: DoctorCommandRunner, ): Promise { const result = await safeRun(commandRunner, name, args); if (!result.ok) { return fail(name, result.error, `Install ${name} >= ${minimum}`); } if (result.value.exitCode !== 0) { return fail( name, (result.value.stderr || result.value.stdout).trim(), `Install ${name} >= ${minimum}`, ); } const version = extractVersion(result.value.stdout); if (!version) { return fail(name, result.value.stdout.trim(), `Ensure ${name} reports a version`); } return satisfiesMin(version, minimum) ? pass(name, version, `${name} satisfies >= ${minimum}`) : fail(name, version, `Upgrade ${name} to >= ${minimum}`); } async function checkDocker(commandRunner: DoctorCommandRunner): Promise { const result = await safeRun(commandRunner, "docker", ["info", "--format", "{{.ServerVersion}}"]); if (!result.ok) { return fail("docker", result.error, "Start Docker and ensure docker is in PATH"); } return result.value.exitCode === 0 ? pass("docker", result.value.stdout.trim(), "Docker daemon is reachable") : fail("docker", result.value.stderr.trim(), "Start Docker and rerun doctor"); } async function checkPostgres(input: { commandRunner: DoctorCommandRunner; config: Config | undefined; configError: unknown; connectDatabase: (databaseUrl: string) => Promise; dockerCommand: string; cwd: string; }): Promise { if (!input.config) { return fail("postgres", errorDetail(input.configError), "Fix Config before checking Postgres"); } const compose = await safeRun( input.commandRunner, input.dockerCommand, ["compose", "ps", "postgres"], { cwd: input.cwd, }, ); if (!compose.ok || compose.value.exitCode !== 0 || !compose.value.stdout.includes("postgres")) { return fail( "postgres", compose.ok ? compose.value.stderr : compose.error, "Run docker compose up -d postgres", ); } const ready = await safeRun(input.commandRunner, input.dockerCommand, [ "compose", "exec", "-T", "postgres", "pg_isready", "-U", "devflow", "-d", "devflow", ]); if (!ready.ok || ready.value.exitCode !== 0) { return fail( "postgres", ready.ok ? ready.value.stderr : ready.error, "Wait for Postgres healthcheck to pass", ); } try { await input.connectDatabase(input.config.DATABASE_URL); return pass("postgres", "connected", "Postgres is reachable and accepts DATABASE_URL"); } catch (error) { return fail("postgres", errorDetail(error), "Check DATABASE_URL and container health"); } } async function checkMigrations(input: { config: Config | undefined; configError: unknown; countAppliedMigrations: (databaseUrl: string) => Promise; countExpectedMigrations: (cwd: string) => Promise | number; cwd: string; }): Promise { if (!input.config) { return fail( "drizzle_migrations", errorDetail(input.configError), "Fix Config before checking migrations", ); } try { const [applied, expected] = await Promise.all([ input.countAppliedMigrations(input.config.DATABASE_URL), input.countExpectedMigrations(input.cwd), ]); if (applied >= expected) { return pass("drizzle_migrations", `${applied}/${expected}`, "No pending migrations"); } return fail("drizzle_migrations", `${applied}/${expected}`, "Run pnpm db:migrate"); } catch (error) { return fail( "drizzle_migrations", errorDetail(error), "Run pnpm db:migrate after Postgres is healthy", ); } } async function checkWorkspaceRoot(config?: Config, configError?: unknown): Promise { if (!config) { return fail( "workspace_root", errorDetail(configError), "Set WORKSPACE_ROOT to an existing writable path", ); } try { await access(config.WORKSPACE_ROOT, constants.W_OK); return pass("workspace_root", config.WORKSPACE_ROOT, "Workspace root is writable"); } catch (error) { return fail("workspace_root", errorDetail(error), "Create WORKSPACE_ROOT and make it writable"); } } function checkConfig(config?: Config, configError?: unknown): DoctorResult { return config ? pass("config", "valid", ".env resolved to a valid Config") : fail("config", errorDetail(configError), "Set DATABASE_URL, WORKSPACE_ROOT, and LOG_LEVEL"); } async function checkOptionalBinary( name: "codex" | "claude", commandRunner: DoctorCommandRunner, ): Promise { const result = await safeRun(commandRunner, name, ["--version"]); return result.ok && result.value.exitCode === 0 ? pass(name, result.value.stdout.trim(), `${name} backend can be enabled`) : warn(name, "not found", `${name} is only required for real backend opt-in`); } async function checkWorkspaceDisk( config: Config | undefined, commandRunner: DoctorCommandRunner, ): Promise { if (!config) { return warn("workspace_disk", "unknown", "Fix Config before checking free disk"); } const result = await safeRun(commandRunner, "df", ["-Pk", config.WORKSPACE_ROOT]); if (!result.ok || result.value.exitCode !== 0) { return warn( "workspace_disk", result.ok ? result.value.stderr : result.error, "Ensure df is available", ); } const availableKb = Number.parseInt(result.value.stdout.trim().split(/\s+/).at(-3) ?? "", 10); if (Number.isNaN(availableKb)) { return warn("workspace_disk", result.value.stdout.trim(), "Unable to parse df output"); } if (availableKb < FAIL_DISK_KB) { return fail( "workspace_disk", `${availableKb} KB free`, "Free at least 5GB under WORKSPACE_ROOT", ); } if (availableKb < WARN_DISK_KB || availableKb < MIN_DISK_KB) { return warn( "workspace_disk", `${availableKb} KB free`, "Free space is below the recommended 10GB", ); } return pass( "workspace_disk", `${availableKb} KB free`, "Workspace partition has enough free space", ); } export function doctorExitCode(results: DoctorResult[]): 0 | 1 { return results.some((result) => result.status === "fail") ? 1 : 0; } export function formatDoctorJson(results: DoctorResult[]): string { return `${JSON.stringify(results, null, 2)}\n`; } export function formatDoctorTable(results: DoctorResult[]): string { const nameWidth = Math.max(...results.map((result) => result.name.length), "check".length); const statusWidth = "status".length; const lines = [ `${"check".padEnd(nameWidth)} ${"status".padEnd(statusWidth)} detail`, `${"-".repeat(nameWidth)} ${"-".repeat(statusWidth)} ${"-".repeat(6)}`, ...results.map( (result) => `${result.name.padEnd(nameWidth)} ${result.status.padEnd(statusWidth)} ${result.detail}${ result.remediation ? ` (${result.remediation})` : "" }`, ), ]; return `${lines.join("\n")}\n`; } async function defaultCommandRunner( command: string, args: string[] = [], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, ): Promise { try { const result = await execFileAsync(command, args, { cwd: options.cwd, env: options.env, timeout: 15_000, }); return { exitCode: 0, stdout: result.stdout, stderr: result.stderr, }; } catch (error) { const maybeError = error as { code?: string | number; stdout?: string; stderr?: string; message?: string; }; return { exitCode: typeof maybeError.code === "number" ? maybeError.code : 1, stdout: maybeError.stdout ?? "", stderr: maybeError.stderr || maybeError.message || String(error), }; } } async function defaultConnectDatabase(databaseUrl: string): Promise { const pool = new Pool({ connectionString: databaseUrl }); try { await pool.query("select 1"); } finally { await pool.end(); } } async function defaultCountAppliedMigrations(databaseUrl: string): Promise { const pool = new Pool({ connectionString: databaseUrl }); try { const result = await pool.query<{ count: string }>( "select count(*)::text as count from drizzle.__drizzle_migrations", ); return Number.parseInt(result.rows[0]?.count ?? "0", 10); } catch { return 0; } finally { await pool.end(); } } async function defaultCountExpectedMigrations(cwd: string): Promise { const journalPath = resolve(cwd, "packages/db/src/migrations/meta/_journal.json"); const journal = JSON.parse(await readFile(journalPath, "utf8")) as { entries?: unknown[] }; return journal.entries?.length ?? 0; } async function safeRun( commandRunner: DoctorCommandRunner, command: string, args: string[] = [], options?: { cwd?: string; env?: NodeJS.ProcessEnv }, ): Promise<{ ok: true; value: CommandResult } | { ok: false; error: string }> { try { return { ok: true, value: await commandRunner(command, args, options) }; } catch (error) { return { ok: false, error: errorDetail(error) }; } } function extractVersion(output: string): string | undefined { return output.match(/\d+\.\d+\.\d+/)?.[0] ?? output.match(/\d+\.\d+/)?.[0]; } function satisfiesMin(version: string, minimum: string): boolean { return compareVersions(version, minimum) >= 0; } function compareVersions(left: string, right: string): number { const leftParts = left.split(".").map((part) => Number.parseInt(part, 10)); const rightParts = right.split(".").map((part) => Number.parseInt(part, 10)); for (let index = 0; index < 3; index += 1) { const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); if (diff !== 0) { return diff; } } return 0; } function pass(name: string, detail: string, remediation: string): DoctorResult { return { name, status: "pass", detail, remediation }; } function fail(name: string, detail: string, remediation: string): DoctorResult { return { name, status: "fail", detail, remediation }; } function warn(name: string, detail: string, remediation: string): DoctorResult { return { name, status: "warn", detail, remediation }; } function errorDetail(error: unknown): string { return error instanceof Error ? error.message : String(error); }