Hub stage 4: config history, rollback, drift check, baseline export and import
Every saved config, and every earlier baseline, is listed per app. You can view it, diff it against a server's current config, and roll it back through preview and apply. Earlier baselines can be restored. A drift check (HUB_DRIFT_MINUTES, default 15) compares each server with its effective config. Drift shows in the header and the matrix, with Accept as baseline and Revert to baseline. All baselines export to one file. Importing that file shows a dry run before it writes. Rolling back preselects, and diffs against, the server the version came from. The line-diff op "del" is renamed to "remove" for the language lint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e05039202
commit
57e492cceb
29 files changed
+2294
-125
No files matched your search
+67
-2
@@ -8,7 +8,7 @@ What the hub keeps per app under `${HUB_DATA}/apps/<app>/`:
|
||||
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 { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export const KEEP_HISTORY = 50
|
||||
@@ -87,6 +87,71 @@ export function writeOverrides(dataDir: string, app: string, overrides: Override
|
||||
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<Baseline> | 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.
|
||||
@@ -96,7 +161,7 @@ export function writeHistory(
|
||||
app: string,
|
||||
server: string,
|
||||
text: string,
|
||||
kind: 'before-uninstall' | 'before-apply',
|
||||
kind: HistoryKind,
|
||||
now: number,
|
||||
): string {
|
||||
const dir = path.join(appDir(dataDir, app), 'history')
|
||||
|
||||
Reference in new issue
Block a user