mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +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
@@ -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