feat: add devflow doctor cli

This commit is contained in:
chungyeong
2026-05-09 22:41:38 +09:00
parent 38f3472d9c
commit 42f0fb193d
17 changed files with 931 additions and 8 deletions

20
apps/cli/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@devflow/cli",
"version": "0.0.0",
"private": true,
"type": "module",
"bin": {
"devflow": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm --clean",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"commander": "12.1.0",
"dotenv": "17.4.2",
"pg": "8.20.0",
"zod": "3.24.1"
}
}

138
apps/cli/src/doctor.test.ts Normal file
View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
type DoctorCommandRunner,
doctorExitCode,
formatDoctorJson,
formatDoctorTable,
runDoctor,
} from "./doctor.js";
const passingRunner: DoctorCommandRunner = async (command, args = []) => {
if (command === "docker" && args.join(" ") === "compose ps postgres") {
return {
exitCode: 0,
stdout: "postgres running\n",
stderr: "",
};
}
if (command === "docker" && args.includes("pg_isready")) {
return {
exitCode: 0,
stdout: "/var/run/postgresql:5432 - accepting connections\n",
stderr: "",
};
}
if (command === "df") {
return {
exitCode: 0,
stdout:
"Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/disk 20000000 1 19999999 1% /\n",
stderr: "",
};
}
const stdoutByCommand: Record<string, string> = {
pnpm: "9.15.9\n",
tmux: "tmux 3.4\n",
git: "git version 2.45.0\n",
docker: "29.3.0\n",
};
return {
exitCode: 0,
stdout: stdoutByCommand[command] ?? "",
stderr: "",
};
};
describe("doctor", () => {
it("emits the closed M1 check set in order", async () => {
const results = await runDoctor({
commandRunner: passingRunner,
connectDatabase: async () => undefined,
countAppliedMigrations: async () => 1,
countExpectedMigrations: () => 1,
dockerComposePath: "docker",
env: {
DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow",
WORKSPACE_ROOT: process.cwd(),
LOG_LEVEL: "info",
},
nodeVersion: "22.11.0",
});
expect(results.map((result) => result.name)).toEqual([
"node",
"pnpm",
"tmux",
"git",
"docker",
"postgres",
"drizzle_migrations",
"workspace_root",
"config",
"codex",
"claude",
"workspace_disk",
]);
expect(doctorExitCode(results)).toBe(0);
});
it("returns exit code 1 when any hard check fails", () => {
expect(
doctorExitCode([
{
name: "node",
status: "fail",
detail: "Node 21 is unsupported",
remediation: "Install Node 22",
},
]),
).toBe(1);
});
it("surfaces command failures before parsing version output", async () => {
const results = await runDoctor({
commandRunner: async (command, args) => {
if (command === "tmux") {
return {
exitCode: 127,
stdout: "",
stderr: "command not found: tmux",
};
}
return passingRunner(command, args);
},
connectDatabase: async () => undefined,
countAppliedMigrations: async () => 1,
countExpectedMigrations: () => 1,
env: {
DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow",
WORKSPACE_ROOT: process.cwd(),
LOG_LEVEL: "info",
},
nodeVersion: "22.11.0",
});
expect(results.find((result) => result.name === "tmux")).toMatchObject({
status: "fail",
detail: "command not found: tmux",
});
});
it("formats human and JSON output", () => {
const result = {
name: "node",
status: "pass" as const,
detail: "22.11.0",
remediation: "",
};
expect(formatDoctorTable([result])).toContain("node");
expect(JSON.parse(formatDoctorJson([result]))).toEqual([result]);
});
});

458
apps/cli/src/doctor.ts Normal file
View File

@@ -0,0 +1,458 @@
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);
}

37
apps/cli/src/index.ts Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
import { Command } from "commander";
import { doctorExitCode, formatDoctorJson, formatDoctorTable, runDoctor } from "./doctor.js";
const program = new Command();
program.name("devflow").description("Local agentic engineering workflow runner");
program
.command("doctor")
.description("Check local Devflow prerequisites")
.option("--json", "print machine-readable JSON")
.option("--quiet", "print only non-passing checks")
.action(async (options: { json?: boolean; quiet?: boolean }) => {
try {
const results = await runDoctor();
const visibleResults = options.quiet
? results.filter((result) => result.status !== "pass")
: results;
if (options.json) {
process.stdout.write(formatDoctorJson(visibleResults));
} else if (!options.quiet || visibleResults.length > 0) {
process.stdout.write(formatDoctorTable(visibleResults));
}
process.exitCode = doctorExitCode(results);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 2;
}
});
await program.parseAsync(process.argv);

10
apps/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../../packages/core" }]
}