/** * FORK: durable persistence for the chosen CDP target. * * `/browser-target` has to outlive the pi session that ran it, because the * browser daemon is machine-wide and shared by every session — so the choice * cannot live in pi's session entries. Same reasoning, and the same atomic * write, as the profile pin in src/profile/store.ts; a separate file so a * corrupt target cannot cost you your profile selection. * * The stored value is the raw string a user would have put in BU_CDP_HTTP * ("local", "10.88.0.25:9223", …). It is deliberately NOT pre-parsed: keeping * it in the same vocabulary as the environment variable means one resolution * path in src/cdp/endpoint.ts, and a stored value can never mean something the * variable could not. * * Every read failure degrades to "nothing stored" rather than throwing — a * corrupt file must never break browser control, it should just fall back to * the built-in default. */ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { dirname } from "node:path"; import { type Result, err, ok } from "../util/result"; import { isRecord } from "../util/guards"; import { targetFilePath } from "../profile/paths"; const CURRENT_VERSION = 1; type TargetFile = { readonly version: 1; /** Raw BU_CDP_HTTP-style value, or null when cleared. */ readonly target: string | null; readonly savedAt: string; }; /** * The persisted target, or null when nothing is stored, the file is missing, or * it cannot be parsed. A file written by a newer version is treated as "nothing * stored" rather than guessed at. */ export const readTarget = async (): Promise => { let raw: string; try { raw = await readFile(targetFilePath(), "utf8"); } catch { return null; } try { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed)) return null; const file = parsed; if (file["version"] !== CURRENT_VERSION) return null; const target = file["target"]; if (typeof target !== "string") return null; const trimmed = target.trim(); return trimmed.length > 0 ? trimmed : null; } catch { return null; } }; /** * Replace the target atomically: a sibling temp file plus rename, so a crash * mid-write cannot leave a half-written target behind — which would otherwise * silently point the agent at the wrong browser. */ const writeTargetFile = async (target: string | null): Promise> => { const path = targetFilePath(); const tmp = `${path}.${randomUUID()}.tmp`; const payload: TargetFile = { version: CURRENT_VERSION, target, savedAt: new Date().toISOString() }; try { await mkdir(dirname(path), { recursive: true }); await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); await rename(tmp, path); return ok(undefined); } catch (e) { await unlink(tmp).catch(() => {}); return err(`could not save browser target to ${path}: ${e instanceof Error ? e.message : String(e)}`); } }; /** Persist the chosen target. */ export const writeTarget = (target: string): Promise> => writeTargetFile(target); /** Clear the choice, restoring the built-in default. */ export const clearTarget = (): Promise> => writeTargetFile(null);