clawdie-ai/scripts/backup.ts
Mevy Assistant 7a0d3888d5 fix: update all stale PostgreSQL 17 references to 18
data17 path and postgresql17 package refs were never updated when PG was
upgraded to 18. Fixes setup scripts, skills, docs, tests, and archived
playbooks to match the running system (PG 18.3, /var/db/postgres/data).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 09:12:48 +00:00

380 lines
12 KiB
TypeScript

/**
* Clawdie backup — exports all critical state to a portable tarball.
*
* What's included:
* ops_db.sql — pg_dump: operational data (chats, messages, tasks, sessions)
* memory_db.sql — pg_dump: agent memory + embeddings
* skills_db.sql — pg_dump: built-in knowledge (skip with --skip-skills)
* env — .env config (API keys, db passwords, all settings)
* groups/ — registered group configurations
* mount-allowlist.json — mount security allowlist
* manifest.json — backup metadata
*
* ZFS snapshots: taken automatically via hostd before export (skip with --no-snapshot).
* Sanoid handles ongoing automated retention; this script does on-demand export.
*
* Usage:
* npx tsx scripts/backup.ts
* npx tsx scripts/backup.ts --skip-skills --output /mnt/backup
* npx tsx scripts/backup.ts --no-snapshot
*
* Output: ~/clawdie-backup-DD.mmm.YYYY-HHMM.tar.gz
*
* Requires: pg_dump (install postgresql18-client on host if missing)
* Restore: see docs/internal/sessions/2026-03-16-backup-restore.md
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
AGENT_HOME,
AGENT_NAME,
MEMORY_DB_NAME,
MEMORY_DB_URL,
MOUNT_ALLOWLIST_PATH,
OPS_DB_NAME,
OPS_DB_URL,
SKILLS_DB_NAME,
SKILLS_DB_URL,
STORE_DIR,
GROUPS_DIR,
ZFS_PREFIX,
} from '../src/config.js';
import { formatDisplayDate, formatSnapshotStamp } from '../src/display-date.js';
import { hostd } from '../src/hostd/client.js';
// ── CLI args ──────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const SKIP_SKILLS = args.includes('--skip-skills');
const NO_SNAPSHOT = args.includes('--no-snapshot');
const outputIdx = args.indexOf('--output');
const OUTPUT_DIR = outputIdx !== -1 ? args[outputIdx + 1] : os.homedir();
// ── Timestamp helpers ─────────────────────────────────────────────────────────
const now = new Date();
const STAMP = formatSnapshotStamp(now);
const BACKUP_NAME = `${AGENT_NAME}-backup-${STAMP}`;
// ── Helpers ───────────────────────────────────────────────────────────────────
function ok(msg: string): void {
console.log(`${msg}`);
}
function warn(msg: string): void {
console.log(`${msg}`);
}
function fail(msg: string): never {
console.error(`${msg}`);
process.exit(1);
}
function run(
cmd: string,
args: string[],
env?: NodeJS.ProcessEnv,
): { ok: boolean; output: string } {
const result = spawnSync(cmd, args, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
const output = [result.stdout, result.stderr]
.filter(Boolean)
.join('\n')
.trim();
return { ok: result.status === 0 && !result.error, output };
}
function commandExists(cmd: string): boolean {
return run('which', [cmd]).ok;
}
// ── ZFS snapshots (via hostd) ──────────────────────────────────────────────────
type ZfsDatasetInfo = { name: string; mountpoint: string };
function listZfsDatasets(): ZfsDatasetInfo[] {
if (!commandExists('zfs')) return [];
const result = run('zfs', [
'list',
'-H',
'-o',
'name,mountpoint',
'-t',
'filesystem',
]);
if (!result.ok) return [];
return result.output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, mountpoint] = line.split('\t');
return { name, mountpoint };
})
.filter((entry) =>
Boolean(
entry.name && entry.mountpoint && entry.mountpoint.startsWith('/'),
),
);
}
function detectHomeDataset(
datasets: ZfsDatasetInfo[],
homePath: string,
): string | null {
let best: ZfsDatasetInfo | null = null;
for (const entry of datasets) {
if (entry.mountpoint === homePath) return entry.name;
if (
!homePath.startsWith(
entry.mountpoint.endsWith('/')
? entry.mountpoint
: `${entry.mountpoint}/`,
)
) {
continue;
}
if (!best || entry.mountpoint.length > best.mountpoint.length) {
best = entry;
}
}
return best?.name ?? null;
}
function datasetExists(datasets: ZfsDatasetInfo[], name: string): boolean {
return datasets.some((entry) => entry.name === name);
}
function buildSnapshotTargets(): string[] {
const targets = [
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-db`,
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-git`,
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-cms`,
];
const datasets = listZfsDatasets();
const homeDataset = detectHomeDataset(datasets, AGENT_HOME);
if (homeDataset) targets.push(homeDataset);
const npmGlobalDataset = `zroot/${ZFS_PREFIX}/shared/npm-global`;
if (datasetExists(datasets, npmGlobalDataset)) {
targets.push(npmGlobalDataset);
}
return targets;
}
async function takeZfsSnapshots(snapshotTag: string): Promise<void> {
const datasets = buildSnapshotTargets();
for (const dataset of datasets) {
try {
const res = await hostd('zfs-snapshot', { dataset, name: snapshotTag });
if (res.ok) {
ok(`ZFS snapshot: ${dataset}@${snapshotTag}`);
} else {
warn(`ZFS snapshot failed for ${dataset}: ${res.error ?? res.output}`);
}
} catch {
warn(`hostd unreachable — ZFS snapshot skipped for ${dataset}`);
break;
}
}
}
// ── pg_dump ───────────────────────────────────────────────────────────────────
function pgDump(url: string, dbName: string, destFile: string): boolean {
if (!commandExists('pg_dump')) {
warn(
`pg_dump not found — skipping ${dbName} (install postgresql-client on host)`,
);
return false;
}
const result = run('pg_dump', [
'--dbname',
url,
'--no-password',
'--clean',
'--if-exists',
'-f',
destFile,
]);
if (result.ok) {
const sizeKb = Math.round(fs.statSync(destFile).size / 1024);
ok(`pg_dump ${dbName}${path.basename(destFile)} (${sizeKb} KB)`);
return true;
} else {
warn(`pg_dump ${dbName} failed: ${result.output.slice(0, 200)}`);
return false;
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log('');
console.log(`Clawdie backup — ${formatDisplayDate(now)}`);
console.log(`Output: ${OUTPUT_DIR}/${BACKUP_NAME}.tar.gz`);
console.log('');
// ── Staging dir ─────────────────────────────────────────────────────────────
const tmpBase = path.join(process.cwd(), 'tmp', 'backup');
fs.mkdirSync(tmpBase, { recursive: true });
const stagingBase = fs.mkdtempSync(path.join(tmpBase, `${AGENT_NAME}-backup-`));
const stagingDir = path.join(stagingBase, BACKUP_NAME);
fs.mkdirSync(stagingDir, { recursive: true });
const manifest: Record<string, unknown> = {
agent: AGENT_NAME,
created_at: now.toISOString(),
created_display: formatDisplayDate(now),
stamp: STAMP,
items: [] as string[],
};
const items = manifest.items as string[];
// ── ZFS snapshots ────────────────────────────────────────────────────────────
if (!NO_SNAPSHOT) {
console.log('Taking ZFS snapshots...');
// Use snapshot stamp format per AGENTS.md: DD.mmm.YYYY-HHMM
const snapshotTag = `pre-backup-${STAMP}`;
await takeZfsSnapshots(snapshotTag);
console.log('');
}
// ── PostgreSQL dumps ──────────────────────────────────────────────────────────
console.log('Exporting PostgreSQL...');
if (pgDump(OPS_DB_URL, OPS_DB_NAME, path.join(stagingDir, 'ops_db.sql'))) {
items.push('ops_db.sql');
}
if (
pgDump(
MEMORY_DB_URL,
MEMORY_DB_NAME,
path.join(stagingDir, 'memory_db.sql'),
)
) {
items.push('memory_db.sql');
}
console.log('');
// ── PostgreSQL dumps ──────────────────────────────────────────────────────────
console.log('Exporting PostgreSQL...');
if (
pgDump(
MEMORY_DB_URL,
MEMORY_DB_NAME,
path.join(stagingDir, 'memory_db.sql'),
)
) {
items.push('memory_db.sql');
}
if (!SKIP_SKILLS) {
if (
pgDump(
SKILLS_DB_URL,
SKILLS_DB_NAME,
path.join(stagingDir, 'skills_db.sql'),
)
) {
items.push('skills_db.sql');
}
} else {
ok('skills_db skipped (--skip-skills)');
}
console.log('');
// ── Config ────────────────────────────────────────────────────────────────────
console.log('Exporting config...');
const envSrc = path.join(process.cwd(), '.env');
if (fs.existsSync(envSrc)) {
fs.copyFileSync(envSrc, path.join(stagingDir, 'env'));
ok('.env');
items.push('env');
} else {
warn('.env not found');
}
const groupsSrc = GROUPS_DIR;
if (fs.existsSync(groupsSrc)) {
const dest = path.join(stagingDir, 'groups');
fs.mkdirSync(dest);
for (const f of fs.readdirSync(groupsSrc)) {
fs.copyFileSync(path.join(groupsSrc, f), path.join(dest, f));
}
ok('groups/');
items.push('groups/');
}
if (fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
fs.copyFileSync(
MOUNT_ALLOWLIST_PATH,
path.join(stagingDir, 'mount-allowlist.json'),
);
ok('mount-allowlist.json');
items.push('mount-allowlist.json');
} else {
ok('mount-allowlist.json not found — skipping');
}
console.log('');
// ── Manifest ──────────────────────────────────────────────────────────────────
fs.writeFileSync(
path.join(stagingDir, 'manifest.json'),
JSON.stringify(manifest, null, 2),
);
// ── Tarball ───────────────────────────────────────────────────────────────────
console.log('Creating archive...');
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const tarPath = path.join(OUTPUT_DIR, `${BACKUP_NAME}.tar.gz`);
const tarResult = run('tar', [
'-czf',
tarPath,
'-C',
stagingBase,
BACKUP_NAME,
]);
if (!tarResult.ok) fail(`tar failed: ${tarResult.output}`);
const sizeMb = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(2);
ok(`${BACKUP_NAME}.tar.gz (${sizeMb} MB)`);
// ── Cleanup ───────────────────────────────────────────────────────────────────
fs.rmSync(stagingBase, { recursive: true, force: true });
console.log('');
console.log('─'.repeat(60));
console.log(` Backup complete: ${tarPath}`);
console.log(` Items: ${items.join(', ')}`);
console.log(
` Restore: see docs/internal/sessions/2026-03-16-backup-restore.md`,
);
console.log('─'.repeat(60));
console.log('');
}
main().catch((err) => {
console.error(
'Backup failed:',
err instanceof Error ? err.message : String(err),
);
process.exit(1);
});