Files
dev-puppeteer/apps/cli/src/doctor.ts
2026-05-09 22:41:38 +09:00

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);
}