/** * SQLite-backed settings store for the Bun process. * Uses bun:sqlite (built-in, synchronous, zero external deps). * DB path: ~/.config/agor/settings.db */ import { Database } from "bun:sqlite"; import { homedir } from "os"; import { join } from "path"; import { openDb } from "./db-utils.ts"; // ── DB path ────────────────────────────────────────────────────────────────── const CONFIG_DIR = join(homedir(), ".config", "agor"); const DB_PATH = join(CONFIG_DIR, "settings.db"); // ── Schema ─────────────────────────────────────────────────────────────────── const SCHEMA = ` PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 500; CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, config TEXT NOT NULL -- JSON blob ); CREATE TABLE IF NOT EXISTS custom_themes ( id TEXT PRIMARY KEY, name TEXT NOT NULL, palette TEXT NOT NULL -- JSON blob ); CREATE TABLE IF NOT EXISTS groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT NOT NULL, position INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS keybindings ( id TEXT PRIMARY KEY, chord TEXT NOT NULL ); `; // Seed default groups (idempotent via INSERT OR IGNORE) const SEED_GROUPS = ` INSERT OR IGNORE INTO groups VALUES ('dev', 'Development', '🔧', 0); INSERT OR IGNORE INTO groups VALUES ('test', 'Testing', '🧪', 1); INSERT OR IGNORE INTO groups VALUES ('ops', 'DevOps', '🚀', 2); INSERT OR IGNORE INTO groups VALUES ('research', 'Research', '🔬', 3); `; // ── SettingsDb class ───────────────────────────────────────────────────────── export interface ProjectConfig { id: string; name: string; cwd: string; accent?: string; provider?: string; profile?: string; model?: string; /** Group this project belongs to. Defaults to 'dev'. */ groupId?: string; /** For clones: path of the source repo (worktree parent). */ mainRepoPath?: string; /** For clones: ID of the original project this was cloned from. */ cloneOf?: string; /** For clones: absolute path to the git worktree. */ worktreePath?: string; /** For clones: branch name checked out in the worktree. */ worktreeBranch?: string; /** 1-indexed clone number within the parent (1–3). */ cloneIndex?: number; [key: string]: unknown; } export interface CustomTheme { id: string; name: string; palette: Record; } export interface Group { id: string; name: string; icon: string; position: number; } export class SettingsDb { private db: Database; constructor() { this.db = openDb(DB_PATH); this.db.exec(SCHEMA); this.db.exec(SEED_GROUPS); // Run version-tracked migrations this.runMigrations(); } /** Run version-tracked schema migrations. */ private runMigrations(): void { const CURRENT_VERSION = 1; const row = this.db .query<{ version: number }, []>("SELECT version FROM schema_version LIMIT 1") .get(); const currentVersion = row?.version ?? 0; if (currentVersion < 1) { // Version 1 is the initial schema — already created above via SCHEMA. // Future migrations go here as version checks: // if (currentVersion < 2) { this.db.exec("ALTER TABLE ..."); } // if (currentVersion < 3) { this.db.exec("ALTER TABLE ..."); } } if (!row) { this.db.exec(`INSERT INTO schema_version (version) VALUES (${CURRENT_VERSION})`); } else if (currentVersion < CURRENT_VERSION) { this.db.exec(`UPDATE schema_version SET version = ${CURRENT_VERSION}`); } } // ── Settings ────────────────────────────────────────────────────────────── getSetting(key: string): string | null { const row = this.db .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?") .get(key); return row?.value ?? null; } setSetting(key: string, value: string): void { this.db .query("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") .run(key, value); } getAll(): Record { const rows = this.db .query<{ key: string; value: string }, []>("SELECT key, value FROM settings") .all(); return Object.fromEntries(rows.map((r) => [r.key, r.value])); } // ── Projects ────────────────────────────────────────────────────────────── getProject(id: string): ProjectConfig | null { const row = this.db .query<{ config: string }, [string]>("SELECT config FROM projects WHERE id = ?") .get(id); if (!row) return null; try { return JSON.parse(row.config) as ProjectConfig; } catch { console.error(`[settings-db] Failed to parse project config for id=${id}`); return null; } } setProject(id: string, config: ProjectConfig): void { const json = JSON.stringify(config); this.db .query("INSERT INTO projects (id, config) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET config = excluded.config") .run(id, json); } deleteProject(id: string): void { this.db.query("DELETE FROM projects WHERE id = ?").run(id); } listProjects(): ProjectConfig[] { const rows = this.db .query<{ config: string }, []>("SELECT config FROM projects") .all(); return rows.flatMap((r) => { try { return [JSON.parse(r.config) as ProjectConfig]; } catch { return []; } }); } // ── Groups ───────────────────────────────────────────────────────────────── listGroups(): Group[] { return this.db .query("SELECT id, name, icon, position FROM groups ORDER BY position ASC") .all(); } createGroup(id: string, name: string, icon: string, position: number): void { this.db .query("INSERT INTO groups (id, name, icon, position) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, icon = excluded.icon, position = excluded.position") .run(id, name, icon, position); } deleteGroup(id: string): void { this.db.query("DELETE FROM groups WHERE id = ?").run(id); } // ── Custom Themes ───────────────────────────────────────────────────────── getCustomThemes(): CustomTheme[] { const rows = this.db .query<{ id: string; name: string; palette: string }, []>( "SELECT id, name, palette FROM custom_themes" ) .all(); return rows.flatMap((r) => { try { return [{ id: r.id, name: r.name, palette: JSON.parse(r.palette) }]; } catch { return []; } }); } saveCustomTheme(id: string, name: string, palette: Record): void { const json = JSON.stringify(palette); this.db .query( "INSERT INTO custom_themes (id, name, palette) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, palette = excluded.palette" ) .run(id, name, json); } deleteCustomTheme(id: string): void { this.db.query("DELETE FROM custom_themes WHERE id = ?").run(id); } // ── Keybindings ─────────────────────────────────────────────────────────── getKeybindings(): Record { const rows = this.db .query<{ id: string; chord: string }, []>("SELECT id, chord FROM keybindings") .all(); return Object.fromEntries(rows.map((r) => [r.id, r.chord])); } setKeybinding(id: string, chord: string): void { this.db .query("INSERT INTO keybindings (id, chord) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET chord = excluded.chord") .run(id, chord); } deleteKeybinding(id: string): void { this.db.query("DELETE FROM keybindings WHERE id = ?").run(id); } // ── Remote credential vault (Feature 3) ────────────────────────────────── private getMachineKey(): string { try { const h = require("os").hostname(); return h || "agor-default-key"; } catch { return "agor-default-key"; } } private xorObfuscate(text: string, key: string): string { const result: number[] = []; for (let i = 0; i < text.length; i++) { result.push(text.charCodeAt(i) ^ key.charCodeAt(i % key.length)); } return Buffer.from(result).toString("base64"); } private xorDeobfuscate(encoded: string, key: string): string { const buf = Buffer.from(encoded, "base64"); const result: string[] = []; for (let i = 0; i < buf.length; i++) { result.push(String.fromCharCode(buf[i] ^ key.charCodeAt(i % key.length))); } return result.join(""); } storeRelayCredential(url: string, token: string, label?: string): void { const key = this.getMachineKey(); const obfuscated = this.xorObfuscate(token, key); const data = JSON.stringify({ url, token: obfuscated, label: label ?? url }); this.setSetting(`relay_cred_${url}`, data); } getRelayCredential(url: string): { url: string; token: string; label: string } | null { const raw = this.getSetting(`relay_cred_${url}`); if (!raw) return null; try { const data = JSON.parse(raw) as { url: string; token: string; label: string }; const key = this.getMachineKey(); return { url: data.url, token: this.xorDeobfuscate(data.token, key), label: data.label }; } catch { return null; } } listRelayCredentials(): Array<{ url: string; label: string }> { const all = this.getAll(); const results: Array<{ url: string; label: string }> = []; for (const [k, v] of Object.entries(all)) { if (!k.startsWith("relay_cred_")) continue; try { const data = JSON.parse(v) as { url: string; label: string }; results.push({ url: data.url, label: data.label }); } catch { /* skip malformed */ } } return results; } deleteRelayCredential(url: string): void { this.db.query("DELETE FROM settings WHERE key = ?").run(`relay_cred_${url}`); } // ── Lifecycle ───────────────────────────────────────────────────────────── close(): void { this.db.close(); } } // Singleton — one DB handle for the process lifetime export const settingsDb = new SettingsDb();