From 6b2883a4674f6bc5fdc654e0c9028cf69636a012 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 16:01:22 +0200 Subject: [PATCH] feat(backend): persistent settings store + settingsFile config Co-Authored-By: Claude Opus 4.8 --- backend/.gitignore | 1 + backend/src/config.ts | 2 ++ backend/src/settings/settings-store.ts | 29 ++++++++++++++++ backend/test/settings-store.test.ts | 47 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/src/settings/settings-store.ts create mode 100644 backend/test/settings-store.test.ts diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..dce83167 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +data/settings.json diff --git a/backend/src/config.ts b/backend/src/config.ts index a2be6c4e..a10bf53f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -3,6 +3,7 @@ export interface AppConfig { authMode: "none" | "mock" | "oidc"; defaults: { provider?: string; model?: string; thinking?: string }; maxPiProcesses: number; + settingsFile: string; } export function loadConfig(env: Record): AppConfig { return { @@ -13,5 +14,6 @@ export function loadConfig(env: Record): AppConfig { authMode: (env.AUTH_MODE as AppConfig["authMode"]) ?? "none", defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4), + settingsFile: env.SETTINGS_FILE ?? "data/settings.json", }; } diff --git a/backend/src/settings/settings-store.ts b/backend/src/settings/settings-store.ts new file mode 100644 index 00000000..7783a40f --- /dev/null +++ b/backend/src/settings/settings-store.ts @@ -0,0 +1,29 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { AppConfig } from "../config.js"; + +export interface Settings { + workspace?: string; + provider?: string; + model?: string; + thinking?: string; +} + +/** Read settings from cfg.settingsFile. Returns {} if missing or invalid. */ +export function loadSettings(cfg: AppConfig): Settings { + try { + const raw = readFileSync(cfg.settingsFile, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed as Settings; + return {}; + } catch { + return {}; + } +} + +/** Persist settings (pretty JSON). Creates the parent directory if needed. */ +export function saveSettings(cfg: AppConfig, s: Settings): Settings { + mkdirSync(dirname(cfg.settingsFile), { recursive: true }); + writeFileSync(cfg.settingsFile, JSON.stringify(s, null, 2) + "\n", "utf8"); + return s; +} diff --git a/backend/test/settings-store.test.ts b/backend/test/settings-store.test.ts new file mode 100644 index 00000000..cfdb090d --- /dev/null +++ b/backend/test/settings-store.test.ts @@ -0,0 +1,47 @@ +import { test, expect } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadSettings, saveSettings } from "../src/settings/settings-store.js"; +import { loadConfig } from "../src/config.js"; + +function cfgWith(file: string) { + return loadConfig({ THT_HARNESS_DIR: "../harness", SETTINGS_FILE: file }); +} + +test("loadSettings returns {} when the file does not exist", () => { + const dir = mkdtempSync(join(tmpdir(), "tht-set-")); + try { + expect(loadSettings(cfgWith(join(dir, "settings.json")))).toEqual({}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("saveSettings writes the file and loadSettings reads it back", () => { + const dir = mkdtempSync(join(tmpdir(), "tht-set-")); + try { + const cfg = cfgWith(join(dir, "nested", "settings.json")); + const saved = saveSettings(cfg, { workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }); + expect(saved.model).toBe("glm-5.2"); + expect(loadSettings(cfg)).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadSettings returns {} on corrupt JSON (no throw)", () => { + const dir = mkdtempSync(join(tmpdir(), "tht-set-")); + try { + const file = join(dir, "settings.json"); + writeFileSync(file, "{ not json"); + expect(loadSettings(cfgWith(file))).toEqual({}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadConfig sets settingsFile from SETTINGS_FILE, default data/settings.json", () => { + expect(loadConfig({}).settingsFile).toBe("data/settings.json"); + expect(loadConfig({ SETTINGS_FILE: "/x/y.json" }).settingsFile).toBe("/x/y.json"); +});