feat: add devflow doctor cli
This commit is contained in:
18
packages/core/package.json
Normal file
18
packages/core/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
74
packages/core/src/config.test.ts
Normal file
74
packages/core/src/config.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
93
packages/core/src/config.ts
Normal file
93
packages/core/src/config.ts
Normal 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;
|
||||
}
|
||||
5
packages/core/src/enums.ts
Normal file
5
packages/core/src/enums.ts
Normal 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>;
|
||||
2
packages/core/src/index.ts
Normal file
2
packages/core/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./config.js";
|
||||
export * from "./enums.js";
|
||||
9
packages/core/tsconfig.json
Normal file
9
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user