summaryrefslogtreecommitdiff
path: root/src/engine-store.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/engine-store.ts')
-rw-r--r--src/engine-store.ts81
1 files changed, 81 insertions, 0 deletions
diff --git a/src/engine-store.ts b/src/engine-store.ts
new file mode 100644
index 0000000..d218d2b
--- /dev/null
+++ b/src/engine-store.ts
@@ -0,0 +1,81 @@
+/**
+ * Persistence for the `/search-engine` choice.
+ *
+ * Deliberately the same shape, and the same atomic write, as the harness's
+ * browser-target store: a sibling temp file plus rename, so a crash mid-write
+ * cannot leave a half-written engine name behind. Its own file rather than a
+ * shared settings blob, so a corrupt engine choice cannot cost anything else.
+ *
+ * Like the CDP target, the stored value is the raw string a user would have put
+ * in the environment variable ("ddg", "bing", …), not a pre-parsed id. One
+ * vocabulary, one resolution path in engine-target.ts, and a stored value can
+ * never mean something the variable could not.
+ *
+ * Every read failure degrades to "nothing stored" rather than throwing.
+ */
+
+import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
+import { randomUUID } from "node:crypto";
+import { dirname, join } from "node:path";
+
+import { agentDir } from "./paths.ts";
+
+const CURRENT_VERSION = 1;
+
+const engineFilePath = (): string => join(agentDir(), "castle-cdp-search-engine.json");
+
+type EngineFile = {
+ readonly version: 1;
+ /** Raw CCS_SEARCH_ENGINE-style value, or null when cleared. */
+ readonly engine: string | null;
+ readonly savedAt: string;
+};
+
+/**
+ * The persisted engine, 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 readStoredEngine = async (): Promise<string | null> => {
+ let raw: string;
+ try {
+ raw = await readFile(engineFilePath(), "utf8");
+ } catch {
+ return null;
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const file = parsed as Record<string, unknown>;
+ if (file["version"] !== CURRENT_VERSION) return null;
+ const engine = file["engine"];
+ if (typeof engine !== "string") return null;
+ const trimmed = engine.trim();
+ return trimmed.length > 0 ? trimmed : null;
+ } catch {
+ return null;
+ }
+};
+
+const write = async (engine: string | null): Promise<void> => {
+ const path = engineFilePath();
+ const tmp = `${path}.${randomUUID()}.tmp`;
+ const payload: EngineFile = { version: CURRENT_VERSION, engine, 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);
+ } catch (e) {
+ await unlink(tmp).catch(() => {});
+ throw new Error(`could not save search engine to ${path}: ${e instanceof Error ? e.message : String(e)}`);
+ }
+};
+
+/** Persist the chosen engine. */
+export const writeStoredEngine = (engine: string): Promise<void> => write(engine);
+
+/** Clear the choice, restoring the built-in default. */
+export const clearStoredEngine = (): Promise<void> => write(null);
+
+/** Exposed so the command can tell the user where the choice lives. */
+export const storedEngineLocation = (): string => engineFilePath();