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

View File

@@ -0,0 +1,74 @@
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfigFromSources } from "./config.js";
describe("config loader", () => {
it("loads .env, .env.local, then process env in descending precedence", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
writeFileSync(
join(root, ".env"),
[
"DATABASE_URL=postgres://env:env@localhost:5432/env",
"WORKSPACE_ROOT=workspace",
"LOG_LEVEL=warn",
].join("\n"),
);
writeFileSync(join(root, ".env.local"), "LOG_LEVEL=debug\n");
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://process:process@localhost:5432/process",
},
});
expect(config.DATABASE_URL).toBe("postgres://process:process@localhost:5432/process");
expect(config.LOG_LEVEL).toBe("debug");
expect(config.WORKSPACE_ROOT).toBe(realpathSync(workspace));
});
it("always exposes the fake backend as enabled", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
},
});
expect(config.backends).toContainEqual({ id: "fake", enabled: true });
});
it("parses backend registration from DEVFLOW_BACKENDS_JSON", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
DEVFLOW_BACKENDS_JSON: JSON.stringify([
{ id: "codex", enabled: true, binaryPath: "/usr/local/bin/codex" },
]),
},
});
expect(config.backends).toEqual([
{ id: "fake", enabled: true },
{ id: "codex", enabled: true, binaryPath: "/usr/local/bin/codex" },
]);
});
});

View File

@@ -0,0 +1,93 @@
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { resolve } from "node:path";
import { parse } from "dotenv";
import { z } from "zod";
import { Backend } from "./enums.js";
const LogLevel = z.enum(["trace", "debug", "info", "warn", "error"]);
export const BackendConfig = z.object({
id: Backend,
enabled: z.boolean(),
binaryPath: z.string().optional(),
});
export const ConfigSchema = z
.object({
DATABASE_URL: z.string().min(1),
WORKSPACE_ROOT: z.string().min(1),
LOG_LEVEL: LogLevel.default("info"),
TEMPORAL_ADDRESS: z.string().optional(),
MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4),
backends: z.array(BackendConfig).default([{ id: "fake", enabled: true }]),
})
.transform((value) => {
const canonicalWorkspaceRoot = realpathSync(resolve(value.WORKSPACE_ROOT));
const hasFakeBackend = value.backends.some((backend) => backend.id === "fake");
return Object.freeze({
...value,
WORKSPACE_ROOT: canonicalWorkspaceRoot,
backends: Object.freeze(
hasFakeBackend
? value.backends
: [{ id: "fake" as const, enabled: true }, ...value.backends],
),
});
});
export type BackendConfig = z.infer<typeof BackendConfig>;
export type Config = z.infer<typeof ConfigSchema>;
export interface LoadConfigOptions {
cwd?: string;
env?: Record<string, string | undefined>;
}
function readEnvFile(cwd: string, fileName: string): Record<string, string> {
const path = resolve(cwd, fileName);
if (!existsSync(path)) {
return {};
}
return parse(readFileSync(path));
}
export function loadConfigFromSources(options: LoadConfigOptions = {}): Config {
const cwd = options.cwd ?? process.cwd();
const env = options.env ?? process.env;
const raw = {
...readEnvFile(cwd, ".env"),
...readEnvFile(cwd, ".env.local"),
...env,
};
const normalizedRaw = normalizeRawConfig(raw);
if (typeof normalizedRaw.WORKSPACE_ROOT === "string") {
normalizedRaw.WORKSPACE_ROOT = resolve(cwd, normalizedRaw.WORKSPACE_ROOT);
}
return ConfigSchema.parse(normalizedRaw);
}
function normalizeRawConfig(raw: Record<string, string | undefined>): Record<string, unknown> {
const backendsJson = raw.DEVFLOW_BACKENDS_JSON;
if (!backendsJson) {
return raw;
}
return {
...raw,
backends: JSON.parse(backendsJson) as unknown,
};
}
let cachedConfig: Config | undefined;
export function getConfig(): Config {
cachedConfig ??= loadConfigFromSources();
return cachedConfig;
}

View File

@@ -0,0 +1,5 @@
import { z } from "zod";
export const BackendValues = ["codex", "claude", "fake"] as const;
export const Backend = z.enum(BackendValues);
export type Backend = z.infer<typeof Backend>;

View File

@@ -0,0 +1,2 @@
export * from "./config.js";
export * from "./enums.js";