import { randomUUID } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { copyToClipboard, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { CURRENT_VERSION, type StatuslineSettings } from "./schema.ts"; import { parseStatuslineSettings } from "./validation.ts"; type TransferContext = Pick; /** Export the saved document, not unsaved edits, matching ce-workflow. */ export async function exportStatuslineSettings(ctx: TransferContext, path: string, copy = copyToClipboard): Promise { let status: string; try { await copy(readFileSync(path, "utf8")); status = "Settings JSON copied to clipboard (terminal support required)."; } catch (error) { status = (error as NodeJS.ErrnoException).code === "ENOENT" ? "No settings file exists yet; nothing copied." : "Could not read or copy settings; use the file path below."; } ctx.ui.notify(`${status}\n${path}`, "info"); await ctx.ui.select("Export statusline settings", [`Settings JSON file: ${path} (Enter to return)`]); } /** Reject malformed/unrelated documents rather than silently replacing settings with defaults. */ export function parseSettingsImport(content: string): StatuslineSettings { let value: Record; try { value = JSON.parse(content.replace(/^\uFEFF/, ""), (key, value) => { if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("Unsafe settings key"); return value; }); } catch { // JSON parser errors may contain pasted secrets. throw new Error("Settings must be valid JSON without prototype-related keys."); } if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Settings must be a JSON object."); if (value.version !== CURRENT_VERSION) throw new Error(`Expected statusline settings version ${CURRENT_VERSION}.`); const sections = ["providers", "layout", "separators", "segments", "bars", "thresholds", "timing", "icons", "preview", "extras"]; if (!["enabled", ...sections].some((key) => Object.hasOwn(value, key))) throw new Error("No statusline settings found."); if (Object.hasOwn(value, "enabled") && typeof value.enabled !== "boolean") throw new Error("Enabled must be a boolean."); for (const key of sections) { if (Object.hasOwn(value, key) && (!value[key] || typeof value[key] !== "object" || Array.isArray(value[key]))) { throw new Error(`Statusline ${key} must be a JSON object.`); } } return parseStatuslineSettings(value).settings; } /** Confirm and back up before the caller atomically saves and applies the imported settings. */ export async function importStatuslineSettings( ctx: TransferContext, path: string, apply: (settings: StatuslineSettings) => void, dirty = false, ): Promise { const input = await ctx.ui.editor("Import statusline settings: paste a file path or JSON", ""); if (!input?.trim()) return false; let backup: string | undefined; try { const text = input.trim().replace(/^\uFEFF/, ""); let content = text; if (!/^[{\[]/.test(text) && !/^(?:null|true|false|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(text)) { let source = text.replace(/^(["'])(.*)\1$/s, "$2"); if (/^~[/\\]/.test(source)) source = join(homedir(), source.slice(2)); content = readFileSync(resolve(ctx.cwd, source), "utf8"); } const imported = parseSettingsImport(content); if (!await ctx.ui.confirm("Replace statusline settings?", `Only import trusted settings. Back up and replace ${path}.${dirty ? " Unsaved edits will be discarded." : ""}`)) return false; if (existsSync(path)) { backup = `${path}.${new Date().toISOString().replace(/[:.]/g, "-")}.${randomUUID()}.bak`; writeFileSync(backup, readFileSync(path), { flag: "wx", mode: 0o600 }); } apply(imported); } catch (error) { const code = (error as NodeJS.ErrnoException).code; ctx.ui.notify(`Settings import failed; current settings were not replaced. ${code ?? (error as Error).message}${backup ? `\nBackup: ${backup}` : ""}`, "error"); return false; } ctx.ui.notify(`Imported statusline settings: ${path}\n${backup ? `Backup: ${backup}` : "No previous file; no backup needed."}\nStatusline settings applied immediately.`, "info"); return true; }