The hub keeps a baseline per app in /data/apps/<app>/baseline.json. Local fields (overrides.json) are left out of the sync and out of the matrix comparison. Each server gets the baseline plus its own values. Applying goes target picker, preview with per-server diffs and warnings, then apply. Apply refuses a server whose config changed since the preview, saves the old config to history, then reads the new one back to check it. Large config bodies are read only for signed-in callers. The App config dialog has Compare, Baseline and Local fields tabs and an empty state that offers an import. Co-Authored-By: Claude Opus 5.5 <[email protected]>
110 lines
4.0 KiB
TypeScript
110 lines
4.0 KiB
TypeScript
/*
|
|
What the hub keeps per app under `${HUB_DATA}/apps/<app>/`:
|
|
|
|
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, 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<string, Record<string, unknown>>
|
|
}
|
|
|
|
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<Baseline> | 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<Overrides> | 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')
|
|
}
|
|
|
|
/*
|
|
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: 'before-uninstall' | 'before-apply',
|
|
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
|
|
}
|