diff --git a/.env.example b/.env.example index 8d0df30..3bc656d 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ DATABASE_URL=postgres://devflow:devflow@127.0.0.1:55432/devflow WORKSPACE_ROOT=./data/workspace LOG_LEVEL=info DEVFLOW_POSTGRES_PORT=55432 +DEVFLOW_BACKENDS_JSON=[{"id":"fake","enabled":true}] diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..efb5934 --- /dev/null +++ b/apps/cli/package.json @@ -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" + } +} diff --git a/apps/cli/src/doctor.test.ts b/apps/cli/src/doctor.test.ts new file mode 100644 index 0000000..300392e --- /dev/null +++ b/apps/cli/src/doctor.test.ts @@ -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 = { + 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]); + }); +}); diff --git a/apps/cli/src/doctor.ts b/apps/cli/src/doctor.ts new file mode 100644 index 0000000..82c50f8 --- /dev/null +++ b/apps/cli/src/doctor.ts @@ -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; + +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); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 0000000..e5b3d6f --- /dev/null +++ b/apps/cli/src/index.ts @@ -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); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..5f16ab1 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node", "vitest"] + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../../packages/core" }] +} diff --git a/package.json b/package.json index 7d05929..ce3047d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "build": "tsc -b", "db:generate": "drizzle-kit generate", "db:migrate": "tsx scripts/migrate.ts", - "typecheck": "tsc -b --noEmit", + "devflow": "tsx apps/cli/src/index.ts", + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", "test": "vitest run", "test:watch": "vitest", "coverage": "vitest run --coverage", @@ -33,8 +34,10 @@ "vitest": "2.1.8" }, "dependencies": { + "commander": "12.1.0", "dotenv": "17.4.2", "drizzle-orm": "0.45.2", - "pg": "8.20.0" + "pg": "8.20.0", + "zod": "3.24.1" } } diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..ac8e2b0 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,18 @@ +{ + "name": "@devflow/core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts", + "typecheck": "tsc -b --noEmit", + "test": "vitest run" + }, + "dependencies": { + "dotenv": "17.4.2", + "zod": "3.24.1" + } +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts new file mode 100644 index 0000000..2d64053 --- /dev/null +++ b/packages/core/src/config.test.ts @@ -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" }, + ]); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..d2da01a --- /dev/null +++ b/packages/core/src/config.ts @@ -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; +export type Config = z.infer; + +export interface LoadConfigOptions { + cwd?: string; + env?: Record; +} + +function readEnvFile(cwd: string, fileName: string): Record { + 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): Record { + 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; +} diff --git a/packages/core/src/enums.ts b/packages/core/src/enums.ts new file mode 100644 index 0000000..fa98a33 --- /dev/null +++ b/packages/core/src/enums.ts @@ -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; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..896ec92 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,2 @@ +export * from "./config.js"; +export * from "./enums.js"; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..851f8d7 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node", "vitest"] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddcb578..0857958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,15 +8,21 @@ importers: .: dependencies: + commander: + specifier: 12.1.0 + version: 12.1.0 dotenv: - specifier: ^17.4.2 + specifier: 17.4.2 version: 17.4.2 drizzle-orm: - specifier: ^0.45.2 + specifier: 0.45.2 version: 0.45.2(@types/pg@8.20.0)(pg@8.20.0) pg: - specifier: ^8.20.0 + specifier: 8.20.0 version: 8.20.0 + zod: + specifier: 3.24.1 + version: 3.24.1 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -25,13 +31,13 @@ importers: specifier: 22.10.2 version: 22.10.2 '@types/pg': - specifier: ^8.20.0 + specifier: 8.20.0 version: 8.20.0 '@vitest/coverage-v8': specifier: 2.1.8 version: 2.1.8(vitest@2.1.8(@types/node@22.10.2)) drizzle-kit: - specifier: ^0.31.10 + specifier: 0.31.10 version: 0.31.10 lefthook: specifier: 2.1.6 @@ -52,6 +58,15 @@ importers: specifier: 2.1.8 version: 2.1.8(@types/node@22.10.2) + packages/db: + dependencies: + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@types/pg@8.20.0)(pg@8.20.0) + pg: + specifier: 8.20.0 + version: 8.20.0 + packages: '@ampproject/remapping@2.3.0': @@ -1290,6 +1305,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2039,6 +2058,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + snapshots: '@ampproject/remapping@2.3.0': @@ -2780,6 +2802,8 @@ snapshots: color-name@1.1.4: {} + commander@12.1.0: {} + commander@4.1.1: {} consola@3.4.2: {} @@ -3506,3 +3530,5 @@ snapshots: strip-ansi: 7.2.0 xtend@4.0.2: {} + + zod@3.24.1: {} diff --git a/tsconfig.json b/tsconfig.json index f9d0995..8af8015 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,9 @@ "types": ["node", "vitest"] }, "include": ["vitest.workspace.ts", "tests/**/*.ts"], - "references": [{ "path": "./packages/db" }] + "references": [ + { "path": "./packages/core" }, + { "path": "./packages/db" }, + { "path": "./apps/cli" } + ] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..266eacd --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "noEmit": true, + "types": ["node", "vitest"] + }, + "include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts", "*.ts"] +} diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 3799e80..b8011fc 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -15,4 +15,18 @@ export default defineWorkspace([ environment: "node", }, }, + { + test: { + name: "packages/core", + include: ["packages/core/src/**/*.test.ts"], + environment: "node", + }, + }, + { + test: { + name: "apps/cli", + include: ["apps/cli/src/**/*.test.ts"], + environment: "node", + }, + }, ]);