singularity-forge/packages/daemon/src/config.ts
2026-05-05 14:46:18 +02:00

150 lines
4.4 KiB
TypeScript

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { parse as parseYaml } from "yaml";
import type { DaemonConfig, LogLevel } from "./types.js";
const VALID_LOG_LEVELS: ReadonlySet<string> = new Set([
"debug",
"info",
"warn",
"error",
]);
/** Expand leading ~ to the user's home directory. */
function expandTilde(p: string): string {
if (p.startsWith("~/") || p === "~") {
return resolve(homedir(), p.slice(2) || ".");
}
return p;
}
/** Default config values when no file is present or fields are missing. */
function defaults(): DaemonConfig {
return {
discord: undefined,
projects: { scan_roots: [] },
log: {
file: resolve(homedir(), ".sf", "daemon.log"),
level: "info",
max_size_mb: 50,
},
};
}
/**
* Resolve the config file path.
* Priority: explicit CLI arg → SF_DAEMON_CONFIG env → ~/.sf/daemon.yaml
*/
export function resolveConfigPath(cliPath?: string): string {
if (cliPath) return expandTilde(cliPath);
const envPath = process.env["SF_DAEMON_CONFIG"];
if (envPath) return expandTilde(envPath);
return resolve(homedir(), ".sf", "daemon.yaml");
}
/**
* Validate and normalise a raw parsed object into a DaemonConfig.
* Missing/invalid fields are filled with defaults. Invalid log level falls back to 'info'.
*/
export function validateConfig(raw: unknown): DaemonConfig {
const def = defaults();
if (raw == null || typeof raw !== "object") return def;
const obj = raw as Record<string, unknown>;
// --- discord ---
let discord: DaemonConfig["discord"];
if (obj["discord"] != null && typeof obj["discord"] === "object") {
const d = obj["discord"] as Record<string, unknown>;
discord = {
token: typeof d["token"] === "string" ? d["token"] : "",
guild_id: typeof d["guild_id"] === "string" ? d["guild_id"] : "",
owner_id: typeof d["owner_id"] === "string" ? d["owner_id"] : "",
...(typeof d["dm_on_blocker"] === "boolean"
? { dm_on_blocker: d["dm_on_blocker"] }
: {}),
...(typeof d["control_channel_id"] === "string"
? { control_channel_id: d["control_channel_id"] }
: {}),
};
// Parse orchestrator sub-block
if (d["orchestrator"] != null && typeof d["orchestrator"] === "object") {
const orc = d["orchestrator"] as Record<string, unknown>;
discord.orchestrator = {
...(typeof orc["model"] === "string" ? { model: orc["model"] } : {}),
...(typeof orc["max_tokens"] === "number" && orc["max_tokens"] > 0
? { max_tokens: orc["max_tokens"] }
: {}),
};
}
}
// --- projects ---
let scanRoots: string[] = [];
if (obj["projects"] != null && typeof obj["projects"] === "object") {
const p = obj["projects"] as Record<string, unknown>;
if (Array.isArray(p["scan_roots"])) {
scanRoots = (p["scan_roots"] as unknown[])
.filter((s): s is string => typeof s === "string")
.map(expandTilde);
}
}
// --- log ---
let logFile = def.log.file;
let logLevel: LogLevel = def.log.level;
let maxSizeMb = def.log.max_size_mb;
if (obj["log"] != null && typeof obj["log"] === "object") {
const l = obj["log"] as Record<string, unknown>;
if (typeof l["file"] === "string") logFile = expandTilde(l["file"]);
if (typeof l["level"] === "string") {
logLevel = VALID_LOG_LEVELS.has(l["level"])
? (l["level"] as LogLevel)
: "info";
}
if (typeof l["max_size_mb"] === "number" && l["max_size_mb"] > 0) {
maxSizeMb = l["max_size_mb"];
}
}
// --- env override: DISCORD_BOT_TOKEN ---
const envToken = process.env["DISCORD_BOT_TOKEN"];
if (envToken) {
if (!discord) {
discord = { token: envToken, guild_id: "", owner_id: "" };
} else {
discord = { ...discord, token: envToken };
}
}
return {
discord,
projects: { scan_roots: scanRoots },
log: { file: logFile, level: logLevel, max_size_mb: maxSizeMb },
};
}
/**
* Load and validate a DaemonConfig from a YAML file.
* If the file doesn't exist, returns defaults. If the file is malformed YAML, throws.
*/
export function loadConfig(configPath: string): DaemonConfig {
if (!existsSync(configPath)) {
// Still apply env-var overrides even when file is missing
return validateConfig(null);
}
const raw = readFileSync(configPath, "utf-8");
let parsed: unknown;
try {
parsed = parseYaml(raw);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse YAML config at ${configPath}: ${msg}`);
}
return validateConfig(parsed);
}