/* What the hub keeps per app under `${HUB_DATA}/apps//`: baseline.json the desired config, as TEXT (some configs are not JSON) overrides.json the fields that stay local to each server, and their values history/ the config a server had before the hub changed it (50 files kept) Every write is a temp file and a rename, mode 0600: these files hold configs. */ import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' import path from 'node:path' export const KEEP_HISTORY = 50 /* A folder name for an app: letters, digits and . _ - only, with a short hash when anything had to change. */ export function safeName(name: string): string { const cleaned = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^\.+/, '_').slice(0, 80) if (cleaned === name && cleaned !== '') return cleaned return `${cleaned === '' ? '_' : cleaned}-${createHash('sha256').update(name).digest('hex').slice(0, 6)}` } export const appDir = (dataDir: string, app: string): string => path.join(dataDir, 'apps', safeName(app)) export function writeAtomic(file: string, text: string): void { mkdirSync(path.dirname(file), { recursive: true }) const temp = `${file}.${process.pid}.tmp` try { writeFileSync(temp, text, { mode: 0o600 }) renameSync(temp, file) } catch (e) { rmSync(temp, { force: true }) throw e } } export interface Baseline { app: string text: string importedFrom?: string updatedAt: string updatedBy: string } export interface Overrides { /** Dot paths (`a.b.0.c`) of the fields that are not synchronised. */ paths: string[] /** server -> path -> value */ values: Record> } export const EMPTY_OVERRIDES: Overrides = { paths: [], values: {} } function readJson(file: string): unknown { if (!existsSync(file)) return null try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null } } export function readBaseline(dataDir: string, app: string): Baseline | null { const data = readJson(path.join(appDir(dataDir, app), 'baseline.json')) as Partial | null if (data === null || typeof data.text !== 'string') return null return { app, text: data.text, ...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}), updatedAt: String(data.updatedAt ?? ''), updatedBy: String(data.updatedBy ?? ''), } } export function writeBaseline(dataDir: string, baseline: Baseline): void { writeAtomic(path.join(appDir(dataDir, baseline.app), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n') } export function readOverrides(dataDir: string, app: string): Overrides { const data = readJson(path.join(appDir(dataDir, app), 'overrides.json')) as Partial | null if (data === null || !Array.isArray(data.paths)) return { paths: [], values: {} } const values = data.values !== null && typeof data.values === 'object' ? data.values : {} return { paths: data.paths.filter((p): p is string => typeof p === 'string'), values } } export function writeOverrides(dataDir: string, app: string, overrides: Overrides): void { writeAtomic(path.join(appDir(dataDir, app), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n') } export type HistoryKind = 'before-uninstall' | 'before-apply' | 'before-set' | 'before-save' export interface HistoryEntry { /** The file name; the only handle `readHistory` accepts. */ id: string at: string /** A server id, or `baseline` for an earlier text of the baseline. */ server: string kind: string size: number hash: string } const HISTORY_FILE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}\.\d{3}Z)__(.+)__(before-[a-z-]+)\.txt$/ const historyDir = (dataDir: string, app: string): string => path.join(appDir(dataDir, app), 'history') /* Newest first. Never the text. */ export function listHistory(dataDir: string, app: string): HistoryEntry[] { const dir = historyDir(dataDir, app) if (!existsSync(dir)) return [] const entries: HistoryEntry[] = [] for (const name of readdirSync(dir)) { const m = HISTORY_FILE.exec(name) if (m === null) continue try { const text = readFileSync(path.join(dir, name), 'utf8') entries.push({ id: name, at: `${m[1]}T${m[2]}:${m[3]}:${m[4]}`, server: m[5], kind: m[6], size: Buffer.byteLength(text), hash: createHash('sha256').update(text).digest('hex').slice(0, 16), }) } catch { // A file that vanished while listing is skipped. } } return entries.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)) } /* The text of one entry. `id` has to be a name the listing returns, so nothing outside the folder can be read. */ export function readHistory(dataDir: string, app: string, id: string): string | null { const known = listHistory(dataDir, app).find((e) => e.id === id) return known === undefined ? null : readFileSync(path.join(historyDir(dataDir, app), known.id), 'utf8') } /* The apps that have a baseline, by the name stored in the file. */ export function listBaselineApps(dataDir: string): string[] { const root = path.join(dataDir, 'apps') if (!existsSync(root)) return [] const apps: string[] = [] for (const dir of readdirSync(root)) { try { if (!statSync(path.join(root, dir)).isDirectory()) continue const data = readJson(path.join(root, dir, 'baseline.json')) as Partial | null if (data !== null && typeof data.app === 'string' && typeof data.text === 'string') apps.push(data.app) } catch { // Not an app folder. } } return apps.sort((a, b) => a.localeCompare(b)) } /* Saves a server's config as it was before the hub changed it. The name starts with the time, so sorting the names sorts the files by age; the oldest beyond KEEP_HISTORY are deleted. */ export function writeHistory( dataDir: string, app: string, server: string, text: string, kind: HistoryKind, now: number, ): string { const dir = path.join(appDir(dataDir, app), 'history') const stamp = new Date(now).toISOString().replace(/:/g, '-') const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`) writeAtomic(file, text) const files = readdirSync(dir).filter((f) => f.endsWith('.txt')).sort() for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true }) return file }