459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
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<CommandResult>;
|
|
|
|
export interface DoctorOptions {
|
|
cwd?: string;
|
|
env?: Record<string, string | undefined>;
|
|
nodeVersion?: string;
|
|
commandRunner?: DoctorCommandRunner;
|
|
connectDatabase?: (databaseUrl: string) => Promise<void>;
|
|
countAppliedMigrations?: (databaseUrl: string) => Promise<number>;
|
|
countExpectedMigrations?: (cwd: string) => Promise<number> | 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<DoctorResult[]> {
|
|
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<string, string | undefined>,
|
|
): { 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<DoctorResult> {
|
|
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<DoctorResult> {
|
|
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<void>;
|
|
dockerCommand: string;
|
|
cwd: string;
|
|
}): Promise<DoctorResult> {
|
|
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<number>;
|
|
countExpectedMigrations: (cwd: string) => Promise<number> | number;
|
|
cwd: string;
|
|
}): Promise<DoctorResult> {
|
|
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<DoctorResult> {
|
|
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<DoctorResult> {
|
|
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<DoctorResult> {
|
|
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<CommandResult> {
|
|
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<void> {
|
|
const pool = new Pool({ connectionString: databaseUrl });
|
|
try {
|
|
await pool.query("select 1");
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
}
|
|
|
|
async function defaultCountAppliedMigrations(databaseUrl: string): Promise<number> {
|
|
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<number> {
|
|
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);
|
|
}
|