mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(config): timestamped config backups with a configurable folder and retention
The files that hold a user's customization (settings.json, clis.json, custom-model-hosts.json, webhook.json, intents.json, linked-cases.json, push-keys.json, users.json, .env) are plain JSON in the data dir, rewritten by the server, the Settings UI and the CLI, with no copy to go back to. A snapshot is taken at startup and whenever the tracked files' content changes (checked every five minutes, and only written when a digest of the file set differs from the latest snapshot's), into `<data dir>/backups/config` by default or any absolute folder. Retention keeps the newest configBackupKeepCount (1-500) and, when configBackupKeepDays > 0, drops older ones; it only ever deletes folders it made (name pattern and a parseable manifest) and never the newest. GET/POST /api/config-backups list and back up now; POST .../:id/restore verifies every file against the manifest's SHA-256, takes a pre-restore snapshot, then replaces files atomically with mode 0600, writing only allowlisted names. Snapshots are 0700 folders of 0600 files and all routes are admin only in multi-user mode. Settings -> System gets the controls and a Restore list.
This commit is contained in:
1 parent
39077281d1
commit
f2230d5d3d
12 files changed
+1061
No files matched your search
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* @fileoverview Config backups: timestamped snapshots of the files that hold what a user has
|
||||
* customized, kept in a default-but-configurable folder with configurable retention.
|
||||
*
|
||||
* Why it exists: those files are plain JSON in the data dir, rewritten by the server, the Settings UI,
|
||||
* the CLI and (once, by a mis-configured test run) by things that should never touch them, and there
|
||||
* was no copy to go back to. A snapshot is taken at startup and whenever the tracked files' content
|
||||
* changes, so the history is one entry per actual change rather than one per timer tick.
|
||||
*
|
||||
* Safety rules the code enforces rather than documents:
|
||||
* - The tracked file list is a fixed allowlist, never "everything in the data dir".
|
||||
* - Retention only ever deletes folders this module made (name pattern AND a parseable manifest), so
|
||||
* pointing the backup folder at a directory that holds other things cannot delete them.
|
||||
* - The newest snapshot is never pruned, whatever the retention numbers say.
|
||||
* - A snapshot is built in a temp folder and renamed into place, so a crash leaves no half snapshot.
|
||||
* - Restore verifies each file against the manifest's SHA-256 and takes a `pre-restore` snapshot first.
|
||||
* - Snapshot folders are 0700 and their files 0600: some tracked files carry credentials.
|
||||
*
|
||||
* Pure of the server: every function takes the directories it works in.
|
||||
*
|
||||
* @module config-backup
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* The files (relative to the data dir) a snapshot holds: what a user configured, not what the server
|
||||
* regenerates (session state, logs, the hook secret, docker seeds, caches).
|
||||
*
|
||||
* ⚠️ `.env`, `users.json`, `custom-model-hosts.json` and `push-keys.json` carry credentials or keys.
|
||||
* They are already in the data dir with the same permissions; a snapshot folder is 0700 with 0600
|
||||
* files, so the default location widens nothing. A custom location is the user's own choice.
|
||||
*/
|
||||
export const CONFIG_BACKUP_FILES = [
|
||||
'settings.json',
|
||||
'clis.json',
|
||||
'custom-model-hosts.json',
|
||||
'webhook.json',
|
||||
'intents.json',
|
||||
'linked-cases.json',
|
||||
'push-keys.json',
|
||||
'users.json',
|
||||
'.env',
|
||||
] as const;
|
||||
|
||||
export const CONFIG_BACKUP_DEFAULTS = { enabled: true, keepCount: 20, keepDays: 30 } as const;
|
||||
export const CONFIG_BACKUP_MAX_KEEP_COUNT = 500;
|
||||
export const CONFIG_BACKUP_MAX_KEEP_DAYS = 3650;
|
||||
|
||||
/** `cfg-YYYYMMDD-HHMMSS`, plus `-2`, `-3`… if two land in one second. */
|
||||
const SNAPSHOT_ID_RE = /^cfg-\d{8}-\d{6}(?:-\d{1,3})?$/;
|
||||
const MANIFEST = 'manifest.json';
|
||||
|
||||
export interface ConfigBackupSettings {
|
||||
enabled: boolean;
|
||||
/** Absolute folder, or null for the default (`<data dir>/backups/config`). */
|
||||
dir: string | null;
|
||||
/** Keep at most this many snapshots (newest first). 1–500. */
|
||||
keepCount: number;
|
||||
/** Also drop snapshots older than this many days; 0 = no age limit. */
|
||||
keepDays: number;
|
||||
}
|
||||
|
||||
export interface ConfigBackupFile {
|
||||
name: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ConfigBackupManifest {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
/** Digest of the set of files, so an unchanged state is not snapshotted twice. */
|
||||
hash: string;
|
||||
files: ConfigBackupFile[];
|
||||
}
|
||||
|
||||
export interface ConfigBackupSummary {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: string;
|
||||
appVersion: string;
|
||||
files: Array<{ name: string; bytes: number }>;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
const intIn = (value: unknown, min: number, max: number, fallback: number): number => {
|
||||
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
};
|
||||
|
||||
/** `~`-expand and require an absolute path; anything else means "use the default". */
|
||||
export function normalizeBackupDir(raw: unknown, home: string = homedir()): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
let value = raw.trim();
|
||||
if (!value || value.includes('\0')) return null;
|
||||
if (value === '~') value = home;
|
||||
else if (value.startsWith('~/')) value = join(home, value.slice(2));
|
||||
if (!isAbsolute(value)) return null;
|
||||
return resolve(value);
|
||||
}
|
||||
|
||||
/** Read the four `configBackup*` settings out of a settings.json object, clamped and defaulted. */
|
||||
export function readConfigBackupSettings(raw: Record<string, unknown> | null | undefined): ConfigBackupSettings {
|
||||
const s = raw ?? {};
|
||||
return {
|
||||
// Absent means ON: the feature protects against an event nobody schedules.
|
||||
enabled: s.configBackupEnabled !== false,
|
||||
dir: normalizeBackupDir(s.configBackupDir),
|
||||
keepCount: intIn(s.configBackupKeepCount, 1, CONFIG_BACKUP_MAX_KEEP_COUNT, CONFIG_BACKUP_DEFAULTS.keepCount),
|
||||
keepDays: intIn(s.configBackupKeepDays, 0, CONFIG_BACKUP_MAX_KEEP_DAYS, CONFIG_BACKUP_DEFAULTS.keepDays),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConfigBackupDir(dataDir: string): string {
|
||||
return join(dataDir, 'backups', 'config');
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder snapshots go to: the configured one, or the default. A custom folder that IS the data dir
|
||||
* falls back to the default, because snapshot folders and the live files must not share a directory.
|
||||
*/
|
||||
export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: string): string {
|
||||
const fallback = defaultConfigBackupDir(dataDir);
|
||||
const dir = settings.dir;
|
||||
if (!dir) return fallback;
|
||||
const data = resolve(dataDir);
|
||||
if (dir === data) return fallback;
|
||||
return dir;
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
function stamp(now: number): string {
|
||||
const d = new Date(now);
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0');
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function readManifest(dir: string, id: string): ConfigBackupManifest | null {
|
||||
if (!SNAPSHOT_ID_RE.test(id)) return null;
|
||||
try {
|
||||
const m = JSON.parse(readFileSync(join(dir, id, MANIFEST), 'utf8')) as ConfigBackupManifest;
|
||||
if (!m || m.id !== id || !Array.isArray(m.files) || typeof m.createdAt !== 'number' || typeof m.hash !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return m;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored. */
|
||||
export function listConfigBackups(dir: string): ConfigBackupSummary[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: ConfigBackupSummary[] = [];
|
||||
for (const id of names) {
|
||||
const m = readManifest(dir, id);
|
||||
if (!m) continue;
|
||||
const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes }));
|
||||
out.push({
|
||||
id,
|
||||
createdAt: m.createdAt,
|
||||
reason: m.reason,
|
||||
appVersion: m.appVersion,
|
||||
files,
|
||||
bytes: files.reduce((n, f) => n + f.bytes, 0),
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1));
|
||||
}
|
||||
|
||||
export type ConfigBackupResult =
|
||||
| { status: 'created'; id: string; files: number; bytes: number }
|
||||
| { status: 'unchanged'; id: string }
|
||||
| { status: 'empty' };
|
||||
|
||||
export interface CreateConfigBackupOptions {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
reason: string;
|
||||
appVersion?: string;
|
||||
/** Snapshot even if nothing changed (a manual request, a pre-restore safety copy). */
|
||||
force?: boolean;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */
|
||||
export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBackupResult {
|
||||
const now = opts.now ?? Date.now();
|
||||
const present: Array<{ name: string; data: Buffer; sha: string }> = [];
|
||||
for (const name of CONFIG_BACKUP_FILES) {
|
||||
try {
|
||||
const path = join(opts.dataDir, name);
|
||||
if (!statSync(path).isFile()) continue;
|
||||
const data = readFileSync(path);
|
||||
present.push({ name, data, sha: sha256(data) });
|
||||
} catch {
|
||||
/* absent or unreadable: simply not part of this snapshot */
|
||||
}
|
||||
}
|
||||
if (present.length === 0) return { status: 'empty' };
|
||||
|
||||
const hash = sha256(present.map((f) => `${f.name}\0${f.sha}`).join('\n'));
|
||||
const existing = listConfigBackups(opts.backupDir);
|
||||
if (!opts.force && existing[0]) {
|
||||
const latest = readManifest(opts.backupDir, existing[0].id);
|
||||
if (latest && latest.hash === hash) return { status: 'unchanged', id: latest.id };
|
||||
}
|
||||
|
||||
mkdirSync(opts.backupDir, { recursive: true, mode: 0o700 });
|
||||
const base = `cfg-${stamp(now)}`;
|
||||
let id = base;
|
||||
for (let n = 2; existsSync(join(opts.backupDir, id)); n++) id = `${base}-${n}`;
|
||||
|
||||
const tmp = join(opts.backupDir, `.tmp-${id}-${process.pid}`);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
mkdirSync(tmp, { mode: 0o700 });
|
||||
try {
|
||||
const manifest: ConfigBackupManifest = {
|
||||
id,
|
||||
createdAt: now,
|
||||
reason: opts.reason,
|
||||
appVersion: opts.appVersion ?? '',
|
||||
hash,
|
||||
files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })),
|
||||
};
|
||||
for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 });
|
||||
writeFileSync(join(tmp, MANIFEST), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
|
||||
renameSync(tmp, join(opts.backupDir, id));
|
||||
try {
|
||||
chmodSync(join(opts.backupDir, id), 0o700);
|
||||
} catch {
|
||||
/* best effort on filesystems without modes */
|
||||
}
|
||||
} catch (err) {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
return { status: 'created', id, files: present.length, bytes: present.reduce((n, f) => n + f.data.length, 0) };
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
keepCount: number;
|
||||
keepDays: number;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply retention: keep the newest `keepCount`, and (when `keepDays` > 0) drop anything older than
|
||||
* that. The newest snapshot always survives. Returns the ids removed.
|
||||
*/
|
||||
export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] {
|
||||
const now = opts.now ?? Date.now();
|
||||
const all = listConfigBackups(dir);
|
||||
const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null;
|
||||
const removed: string[] = [];
|
||||
all.forEach((b, index) => {
|
||||
if (index === 0) return;
|
||||
const tooMany = index >= Math.max(1, opts.keepCount);
|
||||
const tooOld = cutoff !== null && b.createdAt < cutoff;
|
||||
if (!tooMany && !tooOld) return;
|
||||
// listConfigBackups already proved this is one of ours (name pattern + manifest).
|
||||
const target = join(dir, b.id);
|
||||
if (!target.startsWith(resolve(dir) + sep)) return;
|
||||
try {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
removed.push(b.id);
|
||||
} catch {
|
||||
/* leave it; the next pass retries */
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
export class ConfigBackupNotFoundError extends Error {}
|
||||
export class ConfigBackupCorruptError extends Error {}
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: string[];
|
||||
/** The snapshot taken of the live files before they were overwritten. */
|
||||
safetySnapshot: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest
|
||||
* BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first, and each
|
||||
* file is replaced atomically (temp + rename) with mode 0600.
|
||||
*/
|
||||
export function restoreConfigBackup(opts: {
|
||||
dataDir: string;
|
||||
backupDir: string;
|
||||
id: string;
|
||||
appVersion?: string;
|
||||
now?: number;
|
||||
}): RestoreResult {
|
||||
const manifest = readManifest(opts.backupDir, opts.id);
|
||||
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
|
||||
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
const loaded: Array<{ name: string; data: Buffer }> = [];
|
||||
for (const f of manifest.files) {
|
||||
if (!allowed.has(f.name)) continue;
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = readFileSync(join(opts.backupDir, opts.id, f.name));
|
||||
} catch {
|
||||
throw new ConfigBackupCorruptError(`${f.name} is missing from ${opts.id}`);
|
||||
}
|
||||
if (sha256(data) !== f.sha256)
|
||||
throw new ConfigBackupCorruptError(`${f.name} in ${opts.id} does not match its checksum`);
|
||||
loaded.push({ name: f.name, data });
|
||||
}
|
||||
if (loaded.length === 0) throw new ConfigBackupCorruptError(`${opts.id} holds no restorable files`);
|
||||
|
||||
const safety = createConfigBackup({
|
||||
dataDir: opts.dataDir,
|
||||
backupDir: opts.backupDir,
|
||||
reason: 'pre-restore',
|
||||
appVersion: opts.appVersion,
|
||||
force: true,
|
||||
now: opts.now,
|
||||
});
|
||||
|
||||
mkdirSync(opts.dataDir, { recursive: true });
|
||||
const restored: string[] = [];
|
||||
for (const f of loaded) {
|
||||
const target = join(opts.dataDir, f.name);
|
||||
const tmp = `${target}.restore-${process.pid}`;
|
||||
writeFileSync(tmp, f.data, { mode: 0o600 });
|
||||
renameSync(tmp, target);
|
||||
restored.push(f.name);
|
||||
}
|
||||
return { restored, safetySnapshot: safety.status === 'created' ? safety.id : null };
|
||||
}
|
||||
@@ -3242,6 +3242,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group" id="configBackupGroup">
|
||||
<div class="set-group-head"><h4>Config backups</h4><span class="set-scope">server</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row" data-search="config backup snapshot settings clis restore retention keep">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Back up my configuration</span>
|
||||
<span class="set-row-desc">Keeps timestamped copies of your settings, CLI overrides, custom endpoints, webhook, linked cases and (in multi-user mode) accounts. A copy is made at startup and whenever one of them changes. Some of these files hold credentials, so each copy is private to this user (mode 0700/0600).</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsConfigBackup"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup folder location path directory">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Backup folder</span>
|
||||
<span class="set-row-desc">An absolute path (a leading <code>~</code> is expanded). Leave empty for the default shown. Only folders Codeman created there are ever pruned.</span>
|
||||
</div>
|
||||
<input type="text" id="appSettingsConfigBackupDir" class="set-input" placeholder="default: ~/.codeman/backups/config" data-i18n-skip>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup retention keep count snapshots">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep this many backups</span>
|
||||
<span class="set-row-desc">1 to 500. The newest backup is always kept.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsConfigBackupKeepCount" class="set-input" min="1" max="500" step="1" placeholder="20">
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="config backup retention keep days age">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Delete backups older than (days)</span>
|
||||
<span class="set-row-desc">0 keeps backups until the count above pushes them out.</span>
|
||||
</div>
|
||||
<input type="number" id="appSettingsConfigBackupKeepDays" class="set-input" min="0" max="3650" step="1" placeholder="30">
|
||||
</div>
|
||||
<div class="set-row" data-search="config backup now restore list">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Backups</span>
|
||||
<span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first.</span>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button>
|
||||
</div>
|
||||
<div id="configBackupList" class="set-note" data-i18n-skip>Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Automation</h4><span class="set-scope">synced</span></div>
|
||||
<div class="set-group-body">
|
||||
|
||||
@@ -551,6 +551,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
|
||||
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
|
||||
this.applyMcpSyncVisibility();
|
||||
// Config backups: absent means ON, 20 copies, 30 days.
|
||||
document.getElementById('appSettingsConfigBackup').checked = settings.configBackupEnabled !== false;
|
||||
document.getElementById('appSettingsConfigBackupDir').value = settings.configBackupDir || '';
|
||||
document.getElementById('appSettingsConfigBackupKeepCount').value = settings.configBackupKeepCount ?? 20;
|
||||
document.getElementById('appSettingsConfigBackupKeepDays').value = settings.configBackupKeepDays ?? 30;
|
||||
this.loadConfigBackups();
|
||||
this._applyDoctorAdminGate();
|
||||
this.loadWebhook();
|
||||
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
|
||||
@@ -1365,6 +1371,73 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
// ── Config backups ──────────────────────────────────────────────────────────
|
||||
// Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save.
|
||||
|
||||
/** Fill the backup list, and show the default folder as the input's placeholder. */
|
||||
async loadConfigBackups() {
|
||||
const out = this.$('configBackupList');
|
||||
if (!out) return;
|
||||
const res = await this._api('/api/config-backups');
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
out.textContent = body?.error || 'Backups are not available here (admin only in multi-user mode).';
|
||||
return;
|
||||
}
|
||||
const data = body.data;
|
||||
const dirInput = this.$('appSettingsConfigBackupDir') || document.getElementById('appSettingsConfigBackupDir');
|
||||
if (dirInput && data.defaultDir) dirInput.placeholder = `default: ${data.defaultDir}`;
|
||||
if (!data.backups.length) {
|
||||
out.innerHTML = `No backups yet in <code>${escapeHtml(data.dir)}</code>.`;
|
||||
return;
|
||||
}
|
||||
const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
|
||||
const rows = data.backups.map((b) => {
|
||||
const when = new Date(b.createdAt).toLocaleString();
|
||||
const names = b.files.map((f) => escapeHtml(f.name)).join(', ');
|
||||
return `<li><b>${escapeHtml(when)}</b> <span class="set-row-desc">(${escapeHtml(b.reason)}, ${kb(b.bytes)})</span>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${escapeHtml(b.id)}')">Restore</button>
|
||||
<div class="set-row-desc">${names}</div></li>`;
|
||||
});
|
||||
out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div><ul>${rows.join('')}</ul>`;
|
||||
},
|
||||
|
||||
async backupConfigNow() {
|
||||
const btn = this.$('configBackupNowBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const res = await this._apiPost('/api/config-backups', {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this.showToast(body?.error || 'Backup failed', 'error');
|
||||
} else if (body.data.status === 'empty') {
|
||||
this.showToast('Nothing to back up yet', 'warning');
|
||||
} else {
|
||||
this.showToast(`Backed up ${body.data.files} file${body.data.files === 1 ? '' : 's'}`, 'success');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
this.loadConfigBackups();
|
||||
}
|
||||
},
|
||||
|
||||
async restoreConfigBackup(id) {
|
||||
if (!confirm('Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.')) return;
|
||||
const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {});
|
||||
let body = null;
|
||||
try { body = res ? await res.json() : null; } catch { /* fall through */ }
|
||||
if (!res || !res.ok || !body || body.success === false) {
|
||||
this.showToast(body?.error || 'Restore failed', 'error');
|
||||
return;
|
||||
}
|
||||
const d = body.data;
|
||||
const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : '';
|
||||
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}. Reload this page to see settings.${restart}`, 'success');
|
||||
this.loadConfigBackups();
|
||||
},
|
||||
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
const show = (html, hint = '') => {
|
||||
@@ -2737,6 +2810,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
|
||||
cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
|
||||
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
|
||||
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
|
||||
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
|
||||
configBackupKeepCount: Math.min(500, Math.max(1, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)),
|
||||
configBackupKeepDays: Math.min(3650, Math.max(0, parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10) || 0)),
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @fileoverview Config backups (src/config-backup.ts).
|
||||
*
|
||||
* GET /api/config-backups — settings in effect, the folder, and the snapshots in it.
|
||||
* POST /api/config-backups — snapshot now (a manual request always makes one).
|
||||
* POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot).
|
||||
*
|
||||
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all three
|
||||
* are admin only. Responses carry file NAMES and sizes, never file content. Restore replaces files the
|
||||
* running server may have cached: `restartRequired` says which ones only apply after a restart.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
CONFIG_BACKUP_FILES,
|
||||
ConfigBackupCorruptError,
|
||||
ConfigBackupNotFoundError,
|
||||
createConfigBackup,
|
||||
defaultConfigBackupDir,
|
||||
listConfigBackups,
|
||||
pruneConfigBackups,
|
||||
readConfigBackupSettings,
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
type ConfigBackupResult,
|
||||
type ConfigBackupSettings,
|
||||
type ConfigBackupSummary,
|
||||
} from '../../config-backup.js';
|
||||
|
||||
// Only recorded in each manifest; a missing package.json just leaves it blank.
|
||||
const APP_VERSION: string = (() => {
|
||||
try {
|
||||
return (createRequire(import.meta.url)('../../../package.json') as { version?: string }).version ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
|
||||
/** Files whose running-process copy only reloads on restart (the rest are read per request). */
|
||||
const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json']);
|
||||
|
||||
export interface ConfigBackupStatus {
|
||||
settings: ConfigBackupSettings;
|
||||
dir: string;
|
||||
defaultDir: string;
|
||||
tracked: readonly string[];
|
||||
backups: ConfigBackupSummary[];
|
||||
}
|
||||
|
||||
export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigBackupSettings; dir: string }> {
|
||||
const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
const settings = readConfigBackupSettings(raw);
|
||||
return { settings, dir: resolveConfigBackupDir(settings, getDataDir()) };
|
||||
}
|
||||
|
||||
/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */
|
||||
export async function runConfigBackup(
|
||||
reason: string,
|
||||
opts: { force?: boolean } = {}
|
||||
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
|
||||
const { settings, dir } = await readEffectiveBackupSettings();
|
||||
if (!settings.enabled && !opts.force) return null;
|
||||
const result = createConfigBackup({
|
||||
dataDir: getDataDir(),
|
||||
backupDir: dir,
|
||||
reason,
|
||||
appVersion: APP_VERSION,
|
||||
force: opts.force,
|
||||
});
|
||||
const removed = pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays });
|
||||
return { result, removed };
|
||||
}
|
||||
|
||||
export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
const { settings, dir } = await readEffectiveBackupSettings();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
settings,
|
||||
dir,
|
||||
defaultDir: defaultConfigBackupDir(getDataDir()),
|
||||
tracked: CONFIG_BACKUP_FILES,
|
||||
backups: listConfigBackups(dir),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/config-backups', async (req, reply) => {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
try {
|
||||
const out = await runConfigBackup('manual', { force: true });
|
||||
return { success: true, data: { ...out!.result, removed: out!.removed } };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config-backups/:id/restore', async (req, reply) => {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
try {
|
||||
const { dir } = await readEffectiveBackupSettings();
|
||||
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
restored: out.restored,
|
||||
safetySnapshot: out.safetySnapshot,
|
||||
restartRequired: out.restored.filter((n) => RESTART_REQUIRED.has(n)),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigBackupNotFoundError) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
|
||||
}
|
||||
if (err instanceof ConfigBackupCorruptError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export { registerVoiceRoutes } from './voice-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
|
||||
export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export { registerDoctorRoutes } from './doctor-routes.js';
|
||||
export {
|
||||
|
||||
@@ -1521,6 +1521,18 @@ export const SettingsUpdateSchema = z
|
||||
* opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden.
|
||||
*/
|
||||
mcpSyncEnabled: z.boolean().optional(),
|
||||
/**
|
||||
* Config backups (src/config-backup.ts): timestamped snapshots of the customized config files,
|
||||
* taken at startup and whenever their content changes. SYNCED. Absent means ON (the feature
|
||||
* protects against an event nobody schedules); the folder defaults to `<data dir>/backups/config`.
|
||||
*/
|
||||
configBackupEnabled: z.boolean().optional(),
|
||||
/** Absolute folder (a leading `~` is expanded); empty or absent = the default folder. */
|
||||
configBackupDir: z.string().max(1024).optional(),
|
||||
/** Keep at most this many snapshots. */
|
||||
configBackupKeepCount: z.number().int().min(1).max(500).optional(),
|
||||
/** Also drop snapshots older than this many days; 0 = no age limit. */
|
||||
configBackupKeepDays: z.number().int().min(0).max(3650).optional(),
|
||||
/**
|
||||
* Read My Mind predictor model override. Empty/absent = the AI-checker
|
||||
* default (opus: prediction quality is the product and it runs only on an
|
||||
|
||||
@@ -208,6 +208,8 @@ import {
|
||||
registerWebviewRoutes,
|
||||
registerTabLayoutRoutes,
|
||||
registerMcpSyncRoutes,
|
||||
registerConfigBackupRoutes,
|
||||
runConfigBackup,
|
||||
registerWebhookRoutes,
|
||||
registerDoctorRoutes,
|
||||
registerCustomModelRoutes,
|
||||
@@ -229,6 +231,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
// while capping growth of `sseClientsById` and blocking pathological inputs.
|
||||
const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
||||
const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
|
||||
// Config backups: one cheap read-and-hash of a handful of small files; a snapshot is written only when
|
||||
// their content changed since the latest one, so this is one entry per real change, not per tick.
|
||||
const CONFIG_BACKUP_INTERVAL_MS = 5 * 60_000;
|
||||
const CUSTOM_MODEL_REDISCOVER_INTERVAL_MS = 5 * 60_000;
|
||||
// Much shorter than the model-LIST refresh above on purpose: this catches an actual
|
||||
// eviction (a session's model no longer loaded, silently swapped out by another
|
||||
@@ -1179,6 +1184,7 @@ export class WebServer extends EventEmitter {
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
registerMcpSyncRoutes(this.app);
|
||||
registerConfigBackupRoutes(this.app);
|
||||
registerWebhookRoutes(this.app, {
|
||||
notifier: this.webhookNotifier,
|
||||
configDir: getDataDir(),
|
||||
@@ -3067,6 +3073,18 @@ export class WebServer extends EventEmitter {
|
||||
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
|
||||
getHookSecret();
|
||||
|
||||
// Config backups: a snapshot at start (the state the process found) and then on change.
|
||||
if (!this.testMode) {
|
||||
const backup = (reason: string) =>
|
||||
void runConfigBackup(reason).catch((err) =>
|
||||
console.warn('[config-backup] snapshot failed:', err instanceof Error ? err.message : err)
|
||||
);
|
||||
backup('startup');
|
||||
this.cleanup.setInterval(() => backup('auto'), CONFIG_BACKUP_INTERVAL_MS, {
|
||||
description: 'Config backup',
|
||||
});
|
||||
}
|
||||
|
||||
// Main Codex subscription limits come from the signed-in local CLI. Keep
|
||||
// this read-only and host-scoped; multi-user SSE routing makes it admin-only.
|
||||
if (!this.testMode) {
|
||||
|
||||
Reference in new issue
Block a user