fix(rollback): restore the backup path contract the engine and the commands disagreed on
Pipeline step 4 dogfood. `/config-audit rollback` could not see a single one of
the four real backups on this machine, and reported "Backup not found" for one
that was sitting right there.
Four defects, one root: nothing agreed on where a backup lives or what its
manifest looks like.
- M-BUG-22 `lib/backup.mjs` resolved `~/.config-audit/backups` (pre-v2.2.0)
while every command, agent and doc uses `~/.claude/config-audit/backups`.
The auto-backup hook and fix-cli wrote to the first, implement to the second,
rollback read only the first. Canonical root now, with the legacy root kept
readable so older backups stay listable and restorable (`legacy: true`).
- M-BUG-25 `parseManifest` understood only the engine's quoted `original_path:`
spelling, but implement hand-builds its manifest with `- backup:`/`original:`/
`sha256:`. Every implement-made backup parsed to zero files and restoreBackup
returned `{restored: [], failed: []}` — a success-shaped no-op. Both formats
parse now, and a manifest with unparseable entries throws instead of
pretending to succeed.
- M-BUG-23 both session hooks watched `~/.config-audit/sessions`, which does not
exist; sessions live under `~/.claude/`. "Check for active sessions" had never
fired once. It fires now.
- M-BUG-24 the suite called createBackup() against the developer's real home —
it had left nine stray backups there, and cleanupOldBackups() deletes past ten.
Root is overridable via CONFIG_AUDIT_BACKUP_ROOT; both test files use it.
Rollback still cannot delete files implement CREATED — no backup can hold a file
that never existed. It no longer does so silently: manifests carry a `created:`
list, restoreBackup returns `createdNotRemoved`, and rollback.md requires the
report. Automatic deletion is a destructive action and needs its own design.
Verified against backup 20260717_032636 on a throwaway copy: all three files
restore byte-exact (sha256 match), zero writes outside the copy, backup dir
unmodified. Suite 1382 -> 1398/0; frozen v5.0.0 snapshots untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SejM9RQAa1Hfuq7Ek2WfFr
This commit is contained in:
parent
b8cbbc0f5b
commit
8f149891c9
9 changed files with 455 additions and 61 deletions
|
|
@ -10,15 +10,29 @@ import { join, basename } from 'node:path';
|
|||
import { createHash } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const BACKUP_ROOT = join(homedir(), '.config-audit', 'backups');
|
||||
const MAX_BACKUPS = 10;
|
||||
|
||||
/**
|
||||
* Get the backup root directory path.
|
||||
*
|
||||
* Canonical location is `~/.claude/config-audit/backups` — the path every
|
||||
* command, agent and doc uses. `CONFIG_AUDIT_BACKUP_ROOT` overrides it so tests
|
||||
* never write into the operator's real home.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBackupDir() {
|
||||
return BACKUP_ROOT;
|
||||
return process.env.CONFIG_AUDIT_BACKUP_ROOT
|
||||
|| join(homedir(), '.claude', 'config-audit', 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pre-v2.2.0 backup root. Read-only: nothing writes here any more, but
|
||||
* backups made before the move must stay listable and restorable.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getLegacyBackupDir() {
|
||||
return process.env.CONFIG_AUDIT_LEGACY_BACKUP_ROOT
|
||||
|| join(homedir(), '.config-audit', 'backups');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,7 +77,7 @@ export function checksum(content) {
|
|||
*/
|
||||
export function createBackup(files, opts = {}) {
|
||||
const backupId = opts.backupId || generateBackupId();
|
||||
const backupPath = join(BACKUP_ROOT, backupId);
|
||||
const backupPath = join(getBackupDir(), backupId);
|
||||
const filesDir = join(backupPath, 'files');
|
||||
|
||||
mkdirSync(filesDir, { recursive: true });
|
||||
|
|
@ -128,7 +142,7 @@ function serializeManifest(manifest) {
|
|||
* @returns {object}
|
||||
*/
|
||||
export function parseManifest(content) {
|
||||
const result = { created_at: '', backup_id: '', files: [] };
|
||||
const result = { created_at: '', backup_id: '', files: [], created: [] };
|
||||
|
||||
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
|
||||
if (createdMatch) result.created_at = createdMatch[1];
|
||||
|
|
@ -136,7 +150,7 @@ export function parseManifest(content) {
|
|||
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
|
||||
if (idMatch) result.backup_id = idMatch[1];
|
||||
|
||||
// Parse file entries
|
||||
// Parse file entries — engine format (quoted `original_path:` …).
|
||||
const fileBlocks = content.split(/\n\s+-\s+original_path:/).slice(1);
|
||||
for (const block of fileBlocks) {
|
||||
const origMatch = block.match(/^\s*"([^"]+)"/);
|
||||
|
|
@ -154,6 +168,45 @@ export function parseManifest(content) {
|
|||
}
|
||||
}
|
||||
|
||||
// Parse file entries — implement-flow format. `commands/implement.md` has the
|
||||
// agent hand-build the backup dir, so real manifests on disk use unquoted
|
||||
// `- backup:` / `original:` / `sha256:`. Reading only the engine format made
|
||||
// restoreBackup a success-shaped no-op on every backup implement produced.
|
||||
if (result.files.length === 0) {
|
||||
const implBlocks = content.split(/\n\s+-\s+backup:/).slice(1);
|
||||
for (const block of implBlocks) {
|
||||
const bpMatch = block.match(/^\s*(\S+)/);
|
||||
const origMatch = block.match(/original:\s*(\S+)/);
|
||||
const csMatch = block.match(/sha256:\s*(\S+)/);
|
||||
|
||||
if (origMatch && bpMatch && csMatch) {
|
||||
result.files.push({
|
||||
originalPath: origMatch[1],
|
||||
backupPath: bpMatch[1],
|
||||
checksum: csMatch[1],
|
||||
sizeBytes: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.backup_id) {
|
||||
const implId = content.match(/^created:\s*(\S+)\s*$/m);
|
||||
if (implId) result.backup_id = implId[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Files the implement step CREATED. A backup cannot hold a file that did not
|
||||
// exist, so rollback can never restore these — but it must be able to say so.
|
||||
const lines = content.split('\n');
|
||||
const createdAt = lines.findIndex(l => /^created:[ \t]*$/.test(l));
|
||||
if (createdAt !== -1) {
|
||||
for (const line of lines.slice(createdAt + 1)) {
|
||||
const item = line.match(/^[ \t]+-[ \t]+(\S+)[ \t]*$/);
|
||||
if (!item) break;
|
||||
result.created.push(item[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -161,9 +214,10 @@ export function parseManifest(content) {
|
|||
* Remove old backups beyond MAX_BACKUPS.
|
||||
*/
|
||||
function cleanupOldBackups() {
|
||||
if (!existsSync(BACKUP_ROOT)) return;
|
||||
const backupRoot = getBackupDir();
|
||||
if (!existsSync(backupRoot)) return;
|
||||
|
||||
const dirs = readdirSync(BACKUP_ROOT, { withFileTypes: true })
|
||||
const dirs = readdirSync(backupRoot, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
.sort();
|
||||
|
|
@ -171,7 +225,7 @@ function cleanupOldBackups() {
|
|||
if (dirs.length > MAX_BACKUPS) {
|
||||
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
|
||||
for (const dir of toDelete) {
|
||||
rmSync(join(BACKUP_ROOT, dir), { recursive: true, force: true });
|
||||
rmSync(join(backupRoot, dir), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,47 +6,72 @@
|
|||
|
||||
import { readFile, writeFile, readdir, stat, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getBackupDir, parseManifest, checksum } from './lib/backup.mjs';
|
||||
import { getBackupDir, getLegacyBackupDir, parseManifest, checksum } from './lib/backup.mjs';
|
||||
|
||||
/**
|
||||
* Resolve a backup id to its directory, canonical root first, then the
|
||||
* pre-v2.2.0 root. Returns null when the id exists in neither.
|
||||
* @param {string} backupId
|
||||
* @returns {Promise<{ path: string, legacy: boolean } | null>}
|
||||
*/
|
||||
async function resolveBackupPath(backupId) {
|
||||
for (const [root, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
|
||||
const candidate = join(root, backupId);
|
||||
try {
|
||||
await stat(join(candidate, 'manifest.yaml'));
|
||||
return { path: candidate, legacy };
|
||||
} catch {
|
||||
// try the next root
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available backups.
|
||||
* @returns {Promise<{ backups: object[] }>}
|
||||
*/
|
||||
export async function listBackups() {
|
||||
const backupRoot = getBackupDir();
|
||||
const backups = [];
|
||||
const seen = new Set();
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(backupRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return { backups: [] };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const backupPath = join(backupRoot, entry.name);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
|
||||
// Canonical root first; a legacy backup with the same id must not shadow it.
|
||||
for (const [backupRoot, legacy] of [[getBackupDir(), false], [getLegacyBackupDir(), true]]) {
|
||||
let entries;
|
||||
try {
|
||||
const manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
const manifest = parseManifest(manifestContent);
|
||||
|
||||
backups.push({
|
||||
id: entry.name,
|
||||
createdAt: manifest.created_at,
|
||||
files: manifest.files.map(f => ({
|
||||
originalPath: f.originalPath,
|
||||
backupPath: f.backupPath,
|
||||
checksum: f.checksum,
|
||||
sizeBytes: f.sizeBytes,
|
||||
})),
|
||||
});
|
||||
entries = await readdir(backupRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
// Skip backups without valid manifest
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || seen.has(entry.name)) continue;
|
||||
|
||||
const backupPath = join(backupRoot, entry.name);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
|
||||
try {
|
||||
const manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
const manifest = parseManifest(manifestContent);
|
||||
|
||||
seen.add(entry.name);
|
||||
backups.push({
|
||||
id: entry.name,
|
||||
createdAt: manifest.created_at,
|
||||
legacy,
|
||||
files: manifest.files.map(f => ({
|
||||
originalPath: f.originalPath,
|
||||
backupPath: f.backupPath,
|
||||
checksum: f.checksum,
|
||||
sizeBytes: f.sizeBytes,
|
||||
})),
|
||||
created: manifest.created,
|
||||
});
|
||||
} catch {
|
||||
// Skip backups without valid manifest
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
|
|
@ -65,22 +90,22 @@ export async function listBackups() {
|
|||
*/
|
||||
export async function restoreBackup(backupId, opts = {}) {
|
||||
const verify = opts.verify !== false;
|
||||
const backupRoot = getBackupDir();
|
||||
const backupPath = join(backupRoot, backupId);
|
||||
const manifestPath = join(backupPath, 'manifest.yaml');
|
||||
const resolved = await resolveBackupPath(backupId);
|
||||
if (!resolved) throw new Error(`Backup not found: ${backupId}`);
|
||||
|
||||
// Read manifest
|
||||
let manifestContent;
|
||||
try {
|
||||
manifestContent = await readFile(manifestPath, 'utf-8');
|
||||
} catch {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
const backupPath = resolved.path;
|
||||
const manifestContent = await readFile(join(backupPath, 'manifest.yaml'), 'utf-8');
|
||||
|
||||
const manifest = parseManifest(manifestContent);
|
||||
const restored = [];
|
||||
const failed = [];
|
||||
|
||||
// A manifest with entries that parsed to nothing would restore nothing while
|
||||
// reporting success. Fail loudly instead.
|
||||
if (manifest.files.length === 0 && /^\s+-\s/m.test(manifestContent)) {
|
||||
throw new Error(`Unreadable manifest for backup ${backupId}: entries present but none parsed`);
|
||||
}
|
||||
|
||||
for (const fileEntry of manifest.files) {
|
||||
const backupFilePath = join(backupPath, fileEntry.backupPath);
|
||||
|
||||
|
|
@ -139,7 +164,10 @@ export async function restoreBackup(backupId, opts = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
return { restored, failed };
|
||||
// Files implement CREATED are absent from the backup by definition, so they
|
||||
// survive the restore. Report them — a half-restored target is only dangerous
|
||||
// when it is also silent.
|
||||
return { restored, failed, createdNotRemoved: manifest.created, legacy: resolved.legacy };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -148,17 +176,11 @@ export async function restoreBackup(backupId, opts = {}) {
|
|||
* @returns {Promise<{ deleted: boolean, error?: string }>}
|
||||
*/
|
||||
export async function deleteBackup(backupId) {
|
||||
const backupRoot = getBackupDir();
|
||||
const backupPath = join(backupRoot, backupId);
|
||||
const resolved = await resolveBackupPath(backupId);
|
||||
if (!resolved) return { deleted: false, error: `Backup not found: ${backupId}` };
|
||||
|
||||
try {
|
||||
await stat(backupPath);
|
||||
} catch {
|
||||
return { deleted: false, error: `Backup not found: ${backupId}` };
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(backupPath, { recursive: true, force: true });
|
||||
await rm(resolved.path, { recursive: true, force: true });
|
||||
return { deleted: true };
|
||||
} catch (err) {
|
||||
return { deleted: false, error: err.message };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue