import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { randomUUID } from "node:crypto"; import type { ChiConfigContract, JsonObject, Scope } from "../contract"; interface StoredEntry { schemaVersion: number; data: JsonObject; } type ConfigFile = Record; interface ScopeState { scope: Scope; path: string; file: ConfigFile; loaded: boolean; } interface ModuleConfig { contract: ChiConfigContract; parsed: JsonObject; globalData: JsonObject; projectData: JsonObject; } export class ChiConfigError extends Error { constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "ChiConfigError"; } } export interface ConfigStoreOptions { homeDir?: string; cwd: string; configDirName: string; projectTrusted: boolean; } export interface LoadedConfig { parsed: TConfig; globalData: JsonObject; projectData: JsonObject; } function isRecord(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export class ConfigStore { private readonly projectTrusted: boolean; private readonly scopes: Record; private readonly modules = new Map(); constructor(options: ConfigStoreOptions) { const homeDir = options.homeDir ?? homedir(); this.projectTrusted = options.projectTrusted; this.scopes = { global: { scope: "global", path: join(homeDir, ".pi", "agent", "chi", "config.json"), file: {}, loaded: false, }, project: { scope: "project", path: join(options.cwd, options.configDirName, "chi", "config.json"), file: {}, loaded: false, }, }; } load( id: string, contract: ChiConfigContract, ): LoadedConfig { const global = this.prepareScope(id, contract, this.readScope("global")); const project = this.projectTrusted ? this.prepareScope(id, contract, this.readScope("project")) : { data: {}, migrated: false }; const parsed = this.parseEffective(id, contract, global.data, project.data); if (global.migrated) this.persistScope(id, "global", global.data, contract.schemaVersion); if (project.migrated) this.persistScope(id, "project", project.data, contract.schemaVersion); const loaded = { contract, parsed, globalData: global.data, projectData: project.data, }; this.modules.set(id, loaded); return loaded; } get(id: string): TConfig | undefined { return this.modules.get(id)?.parsed as TConfig | undefined; } async setValue( id: string, contract: ChiConfigContract, scope: Scope, key: string, value: unknown | undefined, ): Promise { if (scope === "project" && !this.projectTrusted) { throw new ChiConfigError("cannot write project config for " + id + ": project is untrusted"); } const module = this.modules.get(id); if (!module) throw new ChiConfigError("cannot update config for unknown module " + id); const globalData = { ...module.globalData }; const projectData = { ...module.projectData }; const selected = scope === "global" ? globalData : projectData; if (value === undefined) delete selected[key]; else selected[key] = value; const parsed = this.parseEffective(id, contract, globalData, projectData); this.persistScope(id, scope, selected, contract.schemaVersion); module.contract = contract; module.parsed = parsed; module.globalData = globalData; module.projectData = projectData; return parsed; } private readScope(scope: Scope): ScopeState { const state = this.scopes[scope]; if (state.loaded) return state; state.loaded = true; try { const text = readFileSync(state.path, "utf8"); const file = JSON.parse(text); if (!isRecord(file)) throw new Error("top-level value must be an object"); state.file = file; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return state; throw new ChiConfigError( "could not read " + scope + " config at " + state.path + ": " + errorMessage(error), { cause: error }, ); } return state; } private prepareScope( id: string, contract: ChiConfigContract, state: ScopeState, ): { data: JsonObject; migrated: boolean } { const raw = state.file[id]; if (raw === undefined) return { data: {}, migrated: false }; if (!isRecord(raw)) { throw new ChiConfigError("invalid " + state.scope + " config for " + id + ": module entry must be an object"); } const data = raw.data; if (!isRecord(data)) { throw new ChiConfigError("invalid " + state.scope + " config for " + id + ": data must be an object"); } if (Object.keys(data).length === 0) return { data: {}, migrated: false }; const schemaVersion = raw.schemaVersion; if (typeof schemaVersion !== "number" || !Number.isInteger(schemaVersion)) { throw new ChiConfigError("invalid " + state.scope + " config for " + id + ": schemaVersion must be an integer"); } if (schemaVersion > contract.schemaVersion) { throw new ChiConfigError( "invalid " + state.scope + " config for " + id + ": future schemaVersion " + schemaVersion, ); } if (schemaVersion === contract.schemaVersion) return { data: { ...data }, migrated: false }; if (!contract.migrate) { throw new ChiConfigError( "cannot migrate " + state.scope + " config for " + id + " from schemaVersion " + schemaVersion, ); } try { const migrated = contract.migrate({ ...data }, schemaVersion, state.scope); if (!isRecord(migrated)) throw new Error("migration must return an object"); return { data: { ...migrated }, migrated: true }; } catch (error) { throw new ChiConfigError( "could not migrate " + state.scope + " config for " + id + ": " + errorMessage(error), { cause: error }, ); } } private parseEffective( id: string, contract: ChiConfigContract, globalData: JsonObject, projectData: JsonObject, ): TConfig { try { return contract.schema.parse({ ...globalData, ...(this.projectTrusted ? projectData : {}) }) as TConfig; } catch (error) { throw new ChiConfigError("invalid effective config for " + id + ": " + errorMessage(error), { cause: error }); } } private persistScope( id: string, scope: Scope, data: JsonObject, schemaVersion: number, ): void { const state = this.scopes[scope]; if (Object.keys(data).length === 0) delete state.file[id]; else state.file[id] = { schemaVersion, data: { ...data } } satisfies StoredEntry; mkdirSync(dirname(state.path), { recursive: true }); const temporaryPath = join( dirname(state.path), "." + basename(state.path) + "." + randomUUID() + ".tmp", ); try { writeFileSync(temporaryPath, JSON.stringify(state.file, null, 2) + "\n", { mode: 0o600 }); renameSync(temporaryPath, state.path); } catch (error) { try { unlinkSync(temporaryPath); } catch { // Preserve the original write error. } throw new ChiConfigError( "could not write " + scope + " config for " + id + ": " + errorMessage(error), { cause: error }, ); } } }