- 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
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
/**
|
||
* 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<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();
|