/** * Config saving with atomic writes. * Writes config to temp file, then renames to target (atomic on POSIX). * * @module src/config/saver */ import { mkdir, rename, unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import { expandPath, getConfigPaths } from "./paths"; import { type Config, ConfigSchema } from "./types"; // ───────────────────────────────────────────────────────────────────────────── // Result Types // ───────────────────────────────────────────────────────────────────────────── export type SaveResult = | { ok: true; path: string } | { ok: false; error: SaveError }; export type SaveError = | { code: "VALIDATION_ERROR"; message: string } | { code: "IO_ERROR"; message: string; cause: Error }; // ───────────────────────────────────────────────────────────────────────────── // Saving Functions // ───────────────────────────────────────────────────────────────────────────── /** * Save config to default location or specified path. * Uses atomic write: temp file + rename. */ export function saveConfig( config: Config, configPath?: string ): Promise { const paths = getConfigPaths(); const targetPath = configPath ? expandPath(configPath) : paths.configFile; return saveConfigToPath(config, targetPath); } /** * Save config to a specific file path. * Creates parent directories if needed. * Uses atomic write pattern for safety. */ export async function saveConfigToPath( config: Config, filePath: string ): Promise { // Validate config before saving const validation = ConfigSchema.safeParse(config); if (!validation.success) { return { ok: false, error: { code: "VALIDATION_ERROR", message: `Invalid config: ${validation.error.issues[0]?.message ?? "unknown error"}`, }, }; } return saveTextToPath(Bun.YAML.stringify(config), filePath); } /** * Atomically save deterministic text outside the config schema. * Used for runtime receipts that need the same crash-safe write discipline as * the user config. */ export async function saveTextToPath( content: string, filePath: string ): Promise { // Ensure parent directory exists const dir = dirname(filePath); try { await mkdir(dir, { recursive: true }); } catch (cause) { return { ok: false, error: { code: "IO_ERROR", message: `Failed to create config directory: ${dir}`, cause: cause instanceof Error ? cause : new Error(String(cause)), }, }; } // Write to temp file first (atomic write pattern) // Use timestamp + random suffix to avoid collision const tempPath = join( dir, `.index.yml.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp` ); try { await Bun.write(tempPath, content); } catch (cause) { return { ok: false, error: { code: "IO_ERROR", message: `Failed to write temp config file: ${tempPath}`, cause: cause instanceof Error ? cause : new Error(String(cause)), }, }; } // Rename temp to target (atomic on POSIX, needs unlink on Windows) try { if (process.platform === "win32") { // Windows rename fails if dest exists, so unlink first (ignore ENOENT). await unlink(filePath).catch(() => undefined); } await rename(tempPath, filePath); } catch (cause) { // Clean up temp file on rename failure try { await unlink(tempPath); } catch { // Ignore cleanup errors } return { ok: false, error: { code: "IO_ERROR", message: `Failed to save config file: ${filePath}`, cause: cause instanceof Error ? cause : new Error(String(cause)), }, }; } return { ok: true, path: filePath }; } /** * Create directories for config, data, and cache. * Called during init to set up GNO storage locations. */ export async function ensureDirectories(): Promise { const paths = getConfigPaths(); try { await mkdir(paths.configDir, { recursive: true }); await mkdir(paths.dataDir, { recursive: true }); await mkdir(paths.cacheDir, { recursive: true }); return { ok: true, path: paths.configDir }; } catch (cause) { return { ok: false, error: { code: "IO_ERROR", message: "Failed to create GNO directories", cause: cause instanceof Error ? cause : new Error(String(cause)), }, }; } }