import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import type { Settings } from "./types.js"; export const DEFAULT_SETTINGS: Settings = { managementUrl: "", selectionMode: "auto", refreshMinutes: 5, maxVisibleAccounts: 4, providers: { claude: true, codex: true, grok: true, deepseek: true }, accounts: {}, hideEmails: false, }; // Local auth discovery is retired; account selection now uses Management API auth_index values. const RETIRED_FIELDS = ["accountsDir"]; type JsonObject = Record; export type LoadedSettings = { settings: Settings; raw: JsonObject; path: string; warnings: string[]; writable: boolean; }; function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } export function normalizeSettings(value: unknown): { settings: Settings; raw: JsonObject; warnings: string[]; } { if (!isObject(value)) throw new Error("settings file must contain a JSON object"); const warnings: string[] = []; const providers = isObject(value.providers) ? value.providers : {}; const settings: Settings = structuredClone(DEFAULT_SETTINGS); if (value.selectionMode === "auto" || value.selectionMode === "manual") { settings.selectionMode = value.selectionMode; } else if (value.selectionMode !== undefined) { warnings.push("ignored invalid selectionMode"); } if (typeof value.managementUrl === "string") { settings.managementUrl = value.managementUrl.trim(); } else if (value.managementUrl !== undefined) { warnings.push("ignored invalid managementUrl"); } if (typeof value.managementKey === "string" && value.managementKey.trim()) { settings.managementKey = value.managementKey; } else if (value.managementKey !== undefined && value.managementKey !== "") { warnings.push("ignored invalid managementKey"); } if ( typeof value.refreshMinutes === "number" && Number.isInteger(value.refreshMinutes) && value.refreshMinutes >= 1 ) { settings.refreshMinutes = value.refreshMinutes; } else if (value.refreshMinutes !== undefined) { warnings.push("ignored invalid refreshMinutes"); } if ( typeof value.maxVisibleAccounts === "number" && Number.isInteger(value.maxVisibleAccounts) && value.maxVisibleAccounts > 0 ) { settings.maxVisibleAccounts = value.maxVisibleAccounts; } else if (value.maxVisibleAccounts !== undefined) { warnings.push("ignored invalid maxVisibleAccounts"); } if (typeof value.hideEmails === "boolean") { settings.hideEmails = value.hideEmails; } else if (value.hideEmails !== undefined) { warnings.push("ignored invalid hideEmails"); } if (isObject(value.accounts)) { for (const [id, enabled] of Object.entries(value.accounts)) { if (typeof enabled === "boolean") settings.accounts[id] = enabled; else warnings.push(`ignored invalid accounts.${id}`); } } else if (value.accounts !== undefined) { warnings.push("ignored invalid accounts"); } for (const provider of ["claude", "codex", "grok", "deepseek"] as const) { if (typeof providers[provider] === "boolean") { settings.providers[provider] = providers[provider]; } else if (providers[provider] !== undefined) { warnings.push(`ignored invalid providers.${provider}`); } } return { settings, raw: value, warnings }; } async function migrateLegacyFile(settingsPath: string, legacyPath: string): Promise { try { await stat(settingsPath); return; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } let bytes: Buffer; try { bytes = await readFile(legacyPath); normalizeSettings(JSON.parse(bytes.toString("utf8"))); } catch { return; } await mkdir(dirname(settingsPath), { recursive: true }); try { await writeFile(settingsPath, bytes, { flag: "wx", mode: 0o600 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; return; } const current = await readFile(legacyPath).catch(() => undefined); if (current?.equals(bytes)) await rm(legacyPath); } export async function loadSettings( settingsPath: string, legacyPath: string, ): Promise { await migrateLegacyFile(settingsPath, legacyPath); let text: string; try { text = await readFile(settingsPath, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return { settings: structuredClone(DEFAULT_SETTINGS), raw: {}, path: settingsPath, warnings: [], writable: true, }; } throw error; } try { return { ...normalizeSettings(JSON.parse(text)), path: settingsPath, writable: true, }; } catch (error) { return { settings: structuredClone(DEFAULT_SETTINGS), raw: {}, path: settingsPath, warnings: [(error as Error).message], writable: false, }; } } export async function saveSettings( settings: Settings, raw: JsonObject, settingsPath: string, ): Promise { const next: JsonObject = { ...raw, ...settings, providers: { ...(isObject(raw.providers) ? raw.providers : {}), ...settings.providers, }, }; for (const field of RETIRED_FIELDS) delete next[field]; // The settings file now stores a plaintext management password; keep it locked down even if // it pre-existed with looser permissions. await mkdir(dirname(settingsPath), { recursive: true }); const temporaryPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; try { await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600, }); await rename(temporaryPath, settingsPath); } finally { await rm(temporaryPath, { force: true }); } return next; }