import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs"; import { dirname } from "node:path"; import type { AppConfig } from "../config.js"; export interface Settings { workspace?: string; /** Legacy input fields are ignored when loading/evaluating installation settings. */ provider?: string; model?: string; thinking?: string; } export interface SettingsDurability { syncDirectory(directory: string): void; } export interface SettingsSnapshot { exists: boolean; rawBase64: string; } /** * Settings files are installation defaults only. Personal workspace/model/thinking choices * belong to the browser and must never be written back here by request handlers. */ /** Read settings from cfg.settingsFile. Returns {} if missing or invalid. */ export function loadSettings(cfg: AppConfig): Settings { try { const raw = readFileSync(cfg.settingsFile, "utf8"); const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { const value = parsed as Record; return { ...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}), ...(typeof value.thinking === "string" ? { thinking: value.thinking } : {}), }; } return {}; } catch { return {}; } } /** Capture exact file existence and bytes so host-side configuration can compensate losslessly. */ export function captureSettingsSnapshot(cfg: AppConfig): SettingsSnapshot { try { return { exists: true, rawBase64: readFileSync(cfg.settingsFile).toString("base64") }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return { exists: false, rawBase64: "" }; } throw error; } } /** Restore a previously captured settings file exactly, including the clean absent state. */ export function restoreSettingsSnapshot( cfg: AppConfig, snapshot: SettingsSnapshot, durability: SettingsDurability = defaultDurability, ): void { if (typeof snapshot.exists !== "boolean" || typeof snapshot.rawBase64 !== "string") { throw new Error("invalid settings snapshot"); } const raw = Buffer.from(snapshot.rawBase64, "base64"); if (raw.toString("base64") !== snapshot.rawBase64 || (!snapshot.exists && raw.length !== 0)) { throw new Error("invalid settings snapshot"); } const directory = dirname(cfg.settingsFile); mkdirSync(directory, { recursive: true }); if (snapshot.exists) { replaceSettingsFile(cfg.settingsFile, raw); } else { try { unlinkSync(cfg.settingsFile); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } durability.syncDirectory(directory); } /** Persist settings (pretty JSON). Creates the parent directory if needed. */ export function saveSettings( cfg: AppConfig, s: Settings, durability: SettingsDurability = defaultDurability, ): Settings { mkdirSync(dirname(cfg.settingsFile), { recursive: true }); const directory = dirname(cfg.settingsFile); let previous: Buffer | undefined; try { previous = readFileSync(cfg.settingsFile); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } replaceSettingsFile(cfg.settingsFile, Buffer.from(JSON.stringify(s, null, 2) + "\n", "utf8")); try { durability.syncDirectory(directory); } catch (durabilityError) { try { if (previous === undefined) unlinkSync(cfg.settingsFile); else replaceSettingsFile(cfg.settingsFile, previous); durability.syncDirectory(directory); } catch { throw new Error("settings durability failed and previous settings could not be restored", { cause: durabilityError, }); } throw durabilityError; } return s; } let temporarySequence = 0; function replaceSettingsFile(path: string, contents: Buffer): void { const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${temporarySequence++}`; const fd = openSync(temporary, "wx", 0o600); try { writeFileSync(fd, contents); fsyncSync(fd); } catch (error) { try { closeSync(fd); } catch { /* preserve the write error */ } try { unlinkSync(temporary); } catch { /* best effort */ } throw error; } closeSync(fd); try { renameSync(temporary, path); } catch (error) { try { unlinkSync(temporary); } catch { /* best effort */ } throw error; } } const defaultDurability: SettingsDurability = { syncDirectory(directory: string): void { // The core image runs Linux. Windows durability is owned by the host-side Go executable. if (process.platform === "win32") return; const dirFd = openSync(directory, "r"); try { fsyncSync(dirFd); } finally { closeSync(dirFd); } }, };