2026-03-16 11:17:46 +00:00
|
|
|
/**
|
|
|
|
|
* Clawdie backup — exports all critical state to a portable tarball.
|
|
|
|
|
*
|
|
|
|
|
* What's included:
|
|
|
|
|
* messages.db — SQLite: all 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 postgresql17-client on host if missing)
|
|
|
|
|
* Restore: see docs/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_NAME,
|
|
|
|
|
MEMORY_DB_NAME,
|
|
|
|
|
MEMORY_DB_URL,
|
|
|
|
|
MOUNT_ALLOWLIST_PATH,
|
|
|
|
|
SKILLS_DB_NAME,
|
|
|
|
|
SKILLS_DB_URL,
|
|
|
|
|
STORE_DIR,
|
|
|
|
|
GROUPS_DIR,
|
|
|
|
|
ZFS_PREFIX,
|
|
|
|
|
} from '../src/config.js';
|
2026-04-03 09:37:42 +00:00
|
|
|
import { formatDisplayDate, formatSnapshotStamp } from '../src/display-date.js';
|
2026-03-16 11:17:46 +00:00
|
|
|
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();
|
2026-04-03 09:37:42 +00:00
|
|
|
const STAMP = formatSnapshotStamp(now);
|
2026-03-16 11:17:46 +00:00
|
|
|
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) ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function takeZfsSnapshots(snapshotTag: string): Promise<void> {
|
|
|
|
|
const datasets = [
|
2026-04-03 09:37:42 +00:00
|
|
|
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-db`,
|
|
|
|
|
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-git`,
|
|
|
|
|
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-cms`,
|
2026-03-16 11:17:46 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-03 09:37:42 +00:00
|
|
|
const tmpBase = path.join(process.cwd(), 'tmp', 'backup');
|
|
|
|
|
fs.mkdirSync(tmpBase, { recursive: true });
|
|
|
|
|
const stagingBase = fs.mkdtempSync(path.join(tmpBase, 'clawdie-backup-'));
|
2026-03-16 11:17:46 +00:00
|
|
|
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('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── SQLite ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
console.log('Exporting SQLite...');
|
|
|
|
|
const sqliteSrc = path.join(STORE_DIR, 'messages.db');
|
|
|
|
|
if (fs.existsSync(sqliteSrc)) {
|
|
|
|
|
fs.copyFileSync(sqliteSrc, path.join(stagingDir, 'messages.db'));
|
|
|
|
|
const sizeKb = Math.round(fs.statSync(sqliteSrc).size / 1024);
|
|
|
|
|
ok(`messages.db (${sizeKb} KB)`);
|
|
|
|
|
items.push('messages.db');
|
|
|
|
|
} else {
|
|
|
|
|
warn('messages.db not found — skipping');
|
|
|
|
|
}
|
|
|
|
|
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/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);
|
|
|
|
|
});
|