import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types"; import { appDataDir } from "./paths"; const EMPTY: ConnectionsFile = { version: 2, stacks: [], connections: [] }; /** * Plain-JSON store for saved connections (passwords included — the user's * explicit v1 choice). Kept at 0600 and written atomically via tmp+rename. * Only the current version 2 format is read; anything else starts empty. */ export class ConnectionStore { private readonly file = path.join(appDataDir(), "connections.json"); private data: ConnectionsFile | null = null; async load(): Promise { if (!this.data) { try { const parsed = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile; this.data = parsed.version === 2 ? parsed : structuredClone(EMPTY); } catch { this.data = structuredClone(EMPTY); } } return this.data; } async upsertConnection(connection: SavedConnection): Promise { const data = await this.load(); const saved = { ...connection, id: connection.id || crypto.randomUUID() }; const index = data.connections.findIndex((c) => c.id === saved.id); if (index >= 0) data.connections[index] = saved; else data.connections.push(saved); await this.save(); return saved.id; } async deleteConnection(id: string): Promise { const data = await this.load(); data.connections = data.connections.filter((c) => c.id !== id); await this.save(); } async upsertStack(stack: Stack): Promise { const data = await this.load(); const saved = { ...stack, id: stack.id || crypto.randomUUID() }; const index = data.stacks.findIndex((s) => s.id === saved.id); if (index >= 0) data.stacks[index] = saved; else data.stacks.push(saved); await this.save(); return saved.id; } /** Deleting a stack moves its connections to the loose list. */ async deleteStack(id: string): Promise { const data = await this.load(); data.stacks = data.stacks.filter((s) => s.id !== id); for (const connection of data.connections) { if (connection.stackId === id) connection.stackId = null; } await this.save(); } private async save(): Promise { await fs.mkdir(path.dirname(this.file), { recursive: true }); const tmp = `${this.file}.tmp`; await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 }); await fs.rename(tmp, this.file); await fs.chmod(this.file, 0o600); this.restrictOnWindows(); } /** * PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the * plaintext passwords in this file would be world-readable there. Replace * inheritance with a current-user-only ACL, best effort. */ private restrictOnWindows(): void { if (process.platform !== "win32") return; try { Bun.spawn( ["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`], { stdout: "ignore", stderr: "ignore" }, ); } catch { // ACL tooling unavailable — file still works, just unprotected } } }