refactor: split config module
This commit is contained in:
188
src/config/io.ts
Normal file
188
src/config/io.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import JSON5 from "json5";
|
||||
|
||||
import {
|
||||
applyIdentityDefaults,
|
||||
applySessionDefaults,
|
||||
applyTalkApiKey,
|
||||
} from "./defaults.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
resolveConfigPath,
|
||||
resolveStateDir,
|
||||
} from "./paths.js";
|
||||
import type {
|
||||
ClawdisConfig,
|
||||
ConfigFileSnapshot,
|
||||
LegacyConfigIssue,
|
||||
} from "./types.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
import { ClawdisSchema } from "./zod-schema.js";
|
||||
|
||||
export type ParseConfigJson5Result =
|
||||
| { ok: true; parsed: unknown }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type ConfigIoDeps = {
|
||||
fs?: typeof fs;
|
||||
json5?: typeof JSON5;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
configPath?: string;
|
||||
logger?: Pick<typeof console, "error" | "warn">;
|
||||
};
|
||||
|
||||
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
|
||||
if (deps.configPath) return deps.configPath;
|
||||
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
|
||||
}
|
||||
|
||||
function normalizeDeps(overrides: ConfigIoDeps = {}): Required<ConfigIoDeps> {
|
||||
return {
|
||||
fs: overrides.fs ?? fs,
|
||||
json5: overrides.json5 ?? JSON5,
|
||||
env: overrides.env ?? process.env,
|
||||
homedir: overrides.homedir ?? os.homedir,
|
||||
configPath: overrides.configPath ?? "",
|
||||
logger: overrides.logger ?? console,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConfigJson5(
|
||||
raw: string,
|
||||
json5: { parse: (value: string) => unknown } = JSON5,
|
||||
): ParseConfigJson5Result {
|
||||
try {
|
||||
return { ok: true, parsed: json5.parse(raw) as unknown };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const deps = normalizeDeps(overrides);
|
||||
const configPath = resolveConfigPathForDeps(deps);
|
||||
|
||||
function loadConfig(): ClawdisConfig {
|
||||
try {
|
||||
if (!deps.fs.existsSync(configPath)) return {};
|
||||
const raw = deps.fs.readFileSync(configPath, "utf-8");
|
||||
const parsed = deps.json5.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return {};
|
||||
const validated = ClawdisSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
deps.logger.error("Invalid config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
return applySessionDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdisConfig),
|
||||
);
|
||||
} catch (err) {
|
||||
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
const exists = deps.fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const config = applyTalkApiKey(applySessionDefaults({}));
|
||||
const legacyIssues: LegacyConfigIssue[] = [];
|
||||
return {
|
||||
path: configPath,
|
||||
exists: false,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config,
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = deps.fs.readFileSync(configPath, "utf-8");
|
||||
const parsedRes = parseConfigJson5(raw, deps.json5);
|
||||
if (!parsedRes.ok) {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [
|
||||
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
|
||||
],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
|
||||
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
|
||||
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: parsedRes.parsed,
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: validated.issues,
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw,
|
||||
parsed: parsedRes.parsed,
|
||||
valid: true,
|
||||
config: applyTalkApiKey(applySessionDefaults(validated.config)),
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfigFile(cfg: ClawdisConfig) {
|
||||
await deps.fs.promises.mkdir(path.dirname(configPath), {
|
||||
recursive: true,
|
||||
});
|
||||
const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
|
||||
await deps.fs.promises.writeFile(configPath, json, "utf-8");
|
||||
}
|
||||
|
||||
return {
|
||||
configPath,
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultIO = createConfigIO({ configPath: CONFIG_PATH_CLAWDIS });
|
||||
|
||||
export const loadConfig = defaultIO.loadConfig;
|
||||
export const readConfigFileSnapshot = defaultIO.readConfigFileSnapshot;
|
||||
export const writeConfigFile = defaultIO.writeConfigFile;
|
||||
Reference in New Issue
Block a user