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
233 lines
7 KiB
JavaScript
233 lines
7 KiB
JavaScript
/**
|
|
* Backup library for config-audit.
|
|
* Creates timestamped backups of config files with checksums and manifests.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, existsSync, statSync, rmSync, readFile } from 'node:fs';
|
|
import { readFile as readFileAsync } from 'node:fs/promises';
|
|
import { join, basename } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { homedir } from 'node:os';
|
|
|
|
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 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');
|
|
}
|
|
|
|
/**
|
|
* Generate a timestamp-based backup ID.
|
|
* @returns {string} Format: YYYYMMDD_HHMMSS
|
|
*/
|
|
export function generateBackupId() {
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
const d = String(now.getDate()).padStart(2, '0');
|
|
const h = String(now.getHours()).padStart(2, '0');
|
|
const min = String(now.getMinutes()).padStart(2, '0');
|
|
const s = String(now.getSeconds()).padStart(2, '0');
|
|
return `${y}${m}${d}_${h}${min}${s}`;
|
|
}
|
|
|
|
/**
|
|
* Create a safe filename from a file path (replace path separators with _).
|
|
* @param {string} filePath
|
|
* @returns {string}
|
|
*/
|
|
export function safeFileName(filePath) {
|
|
return filePath.replace(/[\\\/]/g, '_');
|
|
}
|
|
|
|
/**
|
|
* Calculate SHA-256 checksum of a buffer or string.
|
|
* @param {Buffer|string} content
|
|
* @returns {string}
|
|
*/
|
|
export function checksum(content) {
|
|
return createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Create a backup of the specified files.
|
|
* @param {string[]} files - Array of absolute file paths to back up
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.backupId] - Override backup ID (for testing)
|
|
* @returns {{ backupId: string, backupPath: string, manifest: object }}
|
|
*/
|
|
export function createBackup(files, opts = {}) {
|
|
const backupId = opts.backupId || generateBackupId();
|
|
const backupPath = join(getBackupDir(), backupId);
|
|
const filesDir = join(backupPath, 'files');
|
|
|
|
mkdirSync(filesDir, { recursive: true });
|
|
|
|
const manifestFiles = [];
|
|
|
|
for (const file of files) {
|
|
if (!existsSync(file)) continue;
|
|
|
|
const safeName = safeFileName(file);
|
|
copyFileSync(file, join(filesDir, safeName));
|
|
|
|
const content = readFileSync(file);
|
|
const hash = checksum(content);
|
|
const sizeBytes = statSync(file).size;
|
|
|
|
manifestFiles.push({
|
|
originalPath: file,
|
|
backupPath: `./files/${safeName}`,
|
|
checksum: hash,
|
|
sizeBytes,
|
|
});
|
|
}
|
|
|
|
const manifest = {
|
|
created_at: new Date().toISOString(),
|
|
backup_id: backupId,
|
|
files: manifestFiles,
|
|
};
|
|
|
|
// Write manifest as YAML-like format
|
|
const manifestYaml = serializeManifest(manifest);
|
|
writeFileSync(join(backupPath, 'manifest.yaml'), manifestYaml);
|
|
|
|
// Cleanup old backups
|
|
cleanupOldBackups();
|
|
|
|
return { backupId, backupPath, manifest };
|
|
}
|
|
|
|
/**
|
|
* Serialize manifest to YAML-like format.
|
|
* @param {object} manifest
|
|
* @returns {string}
|
|
*/
|
|
function serializeManifest(manifest) {
|
|
let yaml = `created_at: "${manifest.created_at}"\n`;
|
|
yaml += `backup_id: "${manifest.backup_id}"\n`;
|
|
yaml += `files:\n`;
|
|
for (const f of manifest.files) {
|
|
yaml += ` - original_path: "${f.originalPath}"\n`;
|
|
yaml += ` backup_path: "${f.backupPath}"\n`;
|
|
yaml += ` checksum: "${f.checksum}"\n`;
|
|
yaml += ` size_bytes: ${f.sizeBytes}\n`;
|
|
}
|
|
return yaml;
|
|
}
|
|
|
|
/**
|
|
* Parse a manifest.yaml file content.
|
|
* @param {string} content
|
|
* @returns {object}
|
|
*/
|
|
export function parseManifest(content) {
|
|
const result = { created_at: '', backup_id: '', files: [], created: [] };
|
|
|
|
const createdMatch = content.match(/created_at:\s*"([^"]+)"/);
|
|
if (createdMatch) result.created_at = createdMatch[1];
|
|
|
|
const idMatch = content.match(/backup_id:\s*"([^"]+)"/);
|
|
if (idMatch) result.backup_id = idMatch[1];
|
|
|
|
// 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*"([^"]+)"/);
|
|
const bpMatch = block.match(/backup_path:\s*"([^"]+)"/);
|
|
const csMatch = block.match(/checksum:\s*"([^"]+)"/);
|
|
const szMatch = block.match(/size_bytes:\s*(\d+)/);
|
|
|
|
if (origMatch && bpMatch && csMatch) {
|
|
result.files.push({
|
|
originalPath: origMatch[1],
|
|
backupPath: bpMatch[1],
|
|
checksum: csMatch[1],
|
|
sizeBytes: szMatch ? parseInt(szMatch[1], 10) : 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Remove old backups beyond MAX_BACKUPS.
|
|
*/
|
|
function cleanupOldBackups() {
|
|
const backupRoot = getBackupDir();
|
|
if (!existsSync(backupRoot)) return;
|
|
|
|
const dirs = readdirSync(backupRoot, { withFileTypes: true })
|
|
.filter(d => d.isDirectory())
|
|
.map(d => d.name)
|
|
.sort();
|
|
|
|
if (dirs.length > MAX_BACKUPS) {
|
|
const toDelete = dirs.slice(0, dirs.length - MAX_BACKUPS);
|
|
for (const dir of toDelete) {
|
|
rmSync(join(backupRoot, dir), { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
export { MAX_BACKUPS };
|