agent-orchestrator/ui-electrobun/src/bun/settings-db.ts
Hibryda f2e8b07d7f refactor(electrobun): simplify bun backend — extract db-utils, merge handlers
- db-utils.ts: shared openDb() (WAL, busy_timeout, foreign_keys, mkdirSync)
- 5 DB modules use openDb() instead of duplicated PRAGMA boilerplate
- bttask-db shares btmsg-db's Database handle (was duplicate connection)
- misc-handlers.ts: 14 inline handlers extracted from index.ts
- index.ts: 349→195 lines (only window controls remain inline)
- updater.ts: removed dead getLastKnownVersion()
- Net reduction: ~700 lines of duplicated boilerplate
2026-03-23 21:09:57 +01:00

337 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (13). */
cloneIndex?: number;
[key: string]: unknown;
}
export interface CustomTheme {
id: string;
name: string;
palette: Record<string, string>;
}
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<string, string> {
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<Group, []>("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<string, string>): 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<string, string> {
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();