mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(config-backup): redact webhook and intents, bounded folder probe, per-instance origin, reload intents on restore
Review round on #661: webhook.json and intents.json are redacted in compare and URL values lose userinfo/query; a custom folder is probed (bounded, pastCap) before each pass and in each route and only created when its parent exists; manifests record an origin and change check, prune, list, restore and diff are scoped to it; a restore reloads IntentStore, lists untouched files and treats settings.json as restart-required; remote-cases.json is tracked; stale .tmp-cfg-* folders are swept and a failed restore unlinks its temp; the group is hidden from non-admins; an empty days field saves 30.
This commit is contained in:
1 parent
a2e49eb26d
commit
247740fc77
8 files changed
+240
-37
No files matched your search
+76
-16
@@ -25,6 +25,7 @@ import { createHash } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,7 @@ export const CONFIG_BACKUP_FILES = [
|
||||
'intents.json',
|
||||
'linked-cases.json',
|
||||
'remote-hosts.json',
|
||||
'remote-cases.json',
|
||||
'docker-hosts.json',
|
||||
'docker-cases.json',
|
||||
'webviews.json',
|
||||
@@ -94,6 +96,8 @@ export interface ConfigBackupManifest {
|
||||
appVersion: string;
|
||||
/** Digest of the set of files, so an unchanged state is not snapshotted twice. */
|
||||
hash: string;
|
||||
/** Host and data dir that made it, so instances sharing one folder keep to their own snapshots. */
|
||||
origin?: string;
|
||||
files: ConfigBackupFile[];
|
||||
}
|
||||
|
||||
@@ -152,6 +156,9 @@ export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir:
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Who owns a snapshot: this machine's name plus the data dir (prod and a beta differ by the latter). */
|
||||
export const configBackupOrigin = (dataDir: string): string => `${hostname()}:${resolve(dataDir)}`;
|
||||
|
||||
const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
/**
|
||||
@@ -196,21 +203,25 @@ function stamp(now: number): string {
|
||||
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 {
|
||||
function readManifest(dir: string, id: string, origin?: 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;
|
||||
}
|
||||
if (origin !== undefined && m.origin !== origin) 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[] {
|
||||
/**
|
||||
* Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored, and so
|
||||
* are other instances' snapshots when `origin` is given (see {@link configBackupOrigin}).
|
||||
*/
|
||||
export function listConfigBackups(dir: string, origin?: string): ConfigBackupSummary[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
@@ -219,7 +230,7 @@ export function listConfigBackups(dir: string): ConfigBackupSummary[] {
|
||||
}
|
||||
const out: ConfigBackupSummary[] = [];
|
||||
for (const id of names) {
|
||||
const m = readManifest(dir, id);
|
||||
const m = readManifest(dir, id, origin);
|
||||
if (!m) continue;
|
||||
const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes }));
|
||||
out.push({
|
||||
@@ -267,9 +278,10 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku
|
||||
if (present.length === 0) return { status: 'empty' };
|
||||
|
||||
const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n'));
|
||||
const existing = listConfigBackups(opts.backupDir);
|
||||
const origin = configBackupOrigin(opts.dataDir);
|
||||
const existing = listConfigBackups(opts.backupDir, origin);
|
||||
if (!opts.force && existing[0]) {
|
||||
const latest = readManifest(opts.backupDir, existing[0].id);
|
||||
const latest = readManifest(opts.backupDir, existing[0].id, origin);
|
||||
if (latest && latest.hash === hash) return { status: 'unchanged', id: latest.id };
|
||||
}
|
||||
|
||||
@@ -288,6 +300,7 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku
|
||||
reason: opts.reason,
|
||||
appVersion: opts.appVersion ?? '',
|
||||
hash,
|
||||
origin,
|
||||
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 });
|
||||
@@ -305,9 +318,26 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku
|
||||
return { status: 'created', id, files: present.length, bytes: present.reduce((n, f) => n + f.data.length, 0) };
|
||||
}
|
||||
|
||||
/** Remove `.tmp-cfg-*` build folders a crashed snapshot left behind (older than an hour, so a live one on a shared folder is safe). */
|
||||
export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): void {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const n of names) {
|
||||
if (!/^\.tmp-cfg-\d{8}-\d{6}(?:-\d{1,3})?-\d+$/.test(n)) continue;
|
||||
const st = lstatSync(join(dir, n), { throwIfNoEntry: false });
|
||||
if (st && now - st.mtimeMs > 3_600_000) rmSync(join(dir, n), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export interface PruneOptions {
|
||||
keepCount: number;
|
||||
keepDays: number;
|
||||
/** Only this instance's snapshots are pruned; omitted = every snapshot in the folder. */
|
||||
origin?: string;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
@@ -317,7 +347,7 @@ export interface PruneOptions {
|
||||
*/
|
||||
export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] {
|
||||
const now = opts.now ?? Date.now();
|
||||
const all = listConfigBackups(dir);
|
||||
const all = listConfigBackups(dir, opts.origin);
|
||||
const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null;
|
||||
const removed: string[] = [];
|
||||
all.forEach((b, index) => {
|
||||
@@ -343,6 +373,8 @@ export class ConfigBackupCorruptError extends Error {}
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: string[];
|
||||
/** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */
|
||||
untouched: string[];
|
||||
/** The snapshot taken of the live files before they were overwritten. */
|
||||
safetySnapshot: string | null;
|
||||
}
|
||||
@@ -359,7 +391,7 @@ export function restoreConfigBackup(opts: {
|
||||
appVersion?: string;
|
||||
now?: number;
|
||||
}): RestoreResult {
|
||||
const manifest = readManifest(opts.backupDir, opts.id);
|
||||
const manifest = readManifest(opts.backupDir, opts.id, configBackupOrigin(opts.dataDir));
|
||||
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`);
|
||||
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
@@ -392,11 +424,20 @@ export function restoreConfigBackup(opts: {
|
||||
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);
|
||||
try {
|
||||
writeFileSync(tmp, f.data, { mode: 0o600 });
|
||||
renameSync(tmp, target);
|
||||
} catch (err) {
|
||||
rmSync(tmp, { force: true });
|
||||
throw err;
|
||||
}
|
||||
restored.push(f.name);
|
||||
}
|
||||
return { restored, safetySnapshot: safety.status === 'created' ? safety.id : null };
|
||||
// A restore never deletes: tracked files the snapshot did not hold stay as they are.
|
||||
const untouched = CONFIG_BACKUP_FILES.filter(
|
||||
(n) => !restored.includes(n) && lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile()
|
||||
);
|
||||
return { restored, untouched, safetySnapshot: safety.status === 'created' ? safety.id : null };
|
||||
}
|
||||
|
||||
/** The live files in the data dir, as a side of a diff. */
|
||||
@@ -436,7 +477,13 @@ const MAX_DIFF_CHANGES = 200;
|
||||
const MAX_DIFF_VALUE_CHARS = 200;
|
||||
|
||||
/** Files whose every value is a credential or key: only names are ever shown. */
|
||||
const REDACT_ALL_FILES: ReadonlySet<string> = new Set(['.env', 'users.json', 'push-keys.json']);
|
||||
const REDACT_ALL_FILES: ReadonlySet<string> = new Set([
|
||||
'.env',
|
||||
'users.json',
|
||||
'push-keys.json',
|
||||
'webhook.json',
|
||||
'intents.json',
|
||||
]);
|
||||
/** Elsewhere a value is hidden when its own key looks like a credential. */
|
||||
const SECRET_KEY_RE = /(token|secret|passw|credential|auth|key$)/i;
|
||||
|
||||
@@ -478,6 +525,19 @@ function leavesOf(name: string, data: Buffer): Leaves | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** A URL value loses its userinfo, query and fragment: they carry passwords and tokens. */
|
||||
function scrubUrl(jsonText: string): string {
|
||||
try {
|
||||
const v = JSON.parse(jsonText) as unknown;
|
||||
if (typeof v !== 'string' || !/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return jsonText;
|
||||
const u = new URL(v);
|
||||
if (!u.username && !u.password && !u.search && !u.hash) return jsonText;
|
||||
return JSON.stringify(`${u.protocol}//${u.host}${u.pathname}`);
|
||||
} catch {
|
||||
return jsonText;
|
||||
}
|
||||
}
|
||||
|
||||
const clip = (s: string): string => (s.length > MAX_DIFF_VALUE_CHARS ? `${s.slice(0, MAX_DIFF_VALUE_CHARS)}…` : s);
|
||||
|
||||
function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffChange[]; truncated: boolean } {
|
||||
@@ -494,8 +554,8 @@ function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffCh
|
||||
changes.push({
|
||||
path,
|
||||
kind,
|
||||
...(before !== undefined && { before: clip(before) }),
|
||||
...(after !== undefined && { after: clip(after) }),
|
||||
...(before !== undefined && { before: clip(scrubUrl(before)) }),
|
||||
...(after !== undefined && { after: clip(scrubUrl(after)) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -516,7 +576,7 @@ function loadDiffSide(side: string, dataDir: string, backupDir: string): Map<str
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const manifest = readManifest(backupDir, side);
|
||||
const manifest = readManifest(backupDir, side, configBackupOrigin(dataDir));
|
||||
if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${side}`);
|
||||
const allowed = new Set<string>(CONFIG_BACKUP_FILES);
|
||||
for (const f of manifest.files) {
|
||||
|
||||
@@ -108,6 +108,11 @@ export class IntentStore {
|
||||
|
||||
// ----- Public API -----
|
||||
|
||||
/** Drop the in-memory copy so the next call re-reads intents.json (after a config restore replaced it). */
|
||||
reload(): void {
|
||||
this.profiles = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile for a session's case. Never persists on read: an absent
|
||||
* profile returns an empty transient one (`updatedAt: 0`).
|
||||
|
||||
@@ -1364,13 +1364,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
* wired to `codeman:me` for the same late-resolving role as the groups above.
|
||||
*/
|
||||
_applyDoctorAdminGate() {
|
||||
const group = document.getElementById('doctorGroup');
|
||||
if (!group) return;
|
||||
const me = window.__codemanUser || {};
|
||||
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
for (const id of ['doctorGroup', 'configBackupGroup']) {
|
||||
const group = document.getElementById(id);
|
||||
if (group) group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
|
||||
}
|
||||
},
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -1496,6 +1496,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.loadConfigBackups();
|
||||
},
|
||||
|
||||
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
|
||||
async mcpSync(apply) {
|
||||
const out = this.$('mcpSyncResult');
|
||||
const show = (html, hint = '') => {
|
||||
@@ -2871,7 +2872,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
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)),
|
||||
configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))),
|
||||
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
|
||||
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
|
||||
@@ -20,6 +20,9 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse }
|
||||
import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import { intentStore } from '../../intent-store.js';
|
||||
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
|
||||
import { dirname } from 'node:path';
|
||||
import {
|
||||
CONFIG_BACKUP_FILES,
|
||||
CONFIG_DIFF_CURRENT,
|
||||
@@ -33,6 +36,8 @@ import {
|
||||
readConfigBackupSettings,
|
||||
resolveConfigBackupDir,
|
||||
restoreConfigBackup,
|
||||
configBackupOrigin,
|
||||
sweepConfigBackupTemps,
|
||||
type ConfigBackupResult,
|
||||
type ConfigBackupSettings,
|
||||
type ConfigBackupSummary,
|
||||
@@ -49,7 +54,7 @@ const APP_VERSION: string = (() => {
|
||||
})();
|
||||
|
||||
/** 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']);
|
||||
const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json', 'settings.json']);
|
||||
|
||||
export interface ConfigBackupStatus {
|
||||
settings: ConfigBackupSettings;
|
||||
@@ -65,6 +70,29 @@ export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigB
|
||||
return { settings, dir: resolveConfigBackupDir(settings, getDataDir()) };
|
||||
}
|
||||
|
||||
/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */
|
||||
export class BackupDirUnavailableError extends Error {}
|
||||
|
||||
/**
|
||||
* Probe a custom backup folder without blocking the event loop on a dead mount. The default folder
|
||||
* needs no probe. Returns whether the folder exists; throws when it did not answer, or is not a
|
||||
* folder. A missing custom folder is only creatable when its parent is present.
|
||||
*/
|
||||
export async function probeBackupDir(settings: ConfigBackupSettings, dir: string): Promise<boolean> {
|
||||
if (!settings.dir || dir === defaultConfigBackupDir(getDataDir())) return true;
|
||||
const kind = await probePathKind(dir, { pastCap: true });
|
||||
if (kind === 'unknown')
|
||||
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dir, { pastCap: true }));
|
||||
if (kind === 'file') throw new BackupDirUnavailableError(`Backup folder is a file, not a folder: ${dir}`);
|
||||
if (kind === 'directory') return true;
|
||||
const parent = await probePathKind(dirname(dir), { pastCap: true });
|
||||
if (parent === 'unknown') {
|
||||
throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dirname(dir), { pastCap: true }));
|
||||
}
|
||||
if (parent !== 'directory') throw new BackupDirUnavailableError(`Backup folder's parent is missing: ${dirname(dir)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */
|
||||
export async function runConfigBackup(
|
||||
reason: string,
|
||||
@@ -72,6 +100,8 @@ export async function runConfigBackup(
|
||||
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
|
||||
const { settings, dir } = await readEffectiveBackupSettings();
|
||||
if (!settings.enabled && !opts.force) return null;
|
||||
await probeBackupDir(settings, dir);
|
||||
sweepConfigBackupTemps(dir);
|
||||
const result = createConfigBackup({
|
||||
dataDir: getDataDir(),
|
||||
backupDir: dir,
|
||||
@@ -79,7 +109,11 @@ export async function runConfigBackup(
|
||||
appVersion: APP_VERSION,
|
||||
force: opts.force,
|
||||
});
|
||||
const removed = pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays });
|
||||
const removed = pruneConfigBackups(dir, {
|
||||
keepCount: settings.keepCount,
|
||||
keepDays: settings.keepDays,
|
||||
origin: configBackupOrigin(getDataDir()),
|
||||
});
|
||||
return { result, removed };
|
||||
}
|
||||
|
||||
@@ -96,6 +130,13 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
const denied = deny(req, reply);
|
||||
if (denied) return denied;
|
||||
const { settings, dir } = await readEffectiveBackupSettings();
|
||||
let present: boolean;
|
||||
try {
|
||||
present = await probeBackupDir(settings, dir);
|
||||
} catch (err) {
|
||||
reply.code(503);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -103,7 +144,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
dir,
|
||||
defaultDir: defaultConfigBackupDir(getDataDir()),
|
||||
tracked: CONFIG_BACKUP_FILES,
|
||||
backups: listConfigBackups(dir),
|
||||
backups: present ? listConfigBackups(dir, configBackupOrigin(getDataDir())) : [],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -115,7 +156,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
const out = await runConfigBackup('manual', { force: true });
|
||||
return { success: true, data: { ...out!.result, removed: out!.removed } };
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
@@ -129,7 +170,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
|
||||
}
|
||||
try {
|
||||
const { dir } = await readEffectiveBackupSettings();
|
||||
const { settings, dir } = await readEffectiveBackupSettings();
|
||||
await probeBackupDir(settings, dir);
|
||||
const data = diffConfigBackups({
|
||||
dataDir: getDataDir(),
|
||||
backupDir: dir,
|
||||
@@ -146,7 +188,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
@@ -156,12 +198,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
if (denied) return denied;
|
||||
const { id } = req.params as { id: string };
|
||||
try {
|
||||
const { dir } = await readEffectiveBackupSettings();
|
||||
const { settings, dir } = await readEffectiveBackupSettings();
|
||||
await probeBackupDir(settings, dir);
|
||||
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
|
||||
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
|
||||
if (out.restored.includes('intents.json')) intentStore.reload();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
restored: out.restored,
|
||||
untouched: out.untouched,
|
||||
safetySnapshot: out.safetySnapshot,
|
||||
restartRequired: out.restored.filter((n) => RESTART_REQUIRED.has(n)),
|
||||
},
|
||||
@@ -175,7 +221,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
|
||||
}
|
||||
reply.code(500);
|
||||
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user