Files
openclaw-backups/skills/openclaw-backup-optimized/scripts/backup.js
2026-02-17 15:50:53 +00:00

339 lines
12 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const tar = require('tar');
const minimatch = require('minimatch');
const HOME = process.env.HOME || process.env.USERPROFILE || '.';
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(HOME, '.openclaw');
const OPENCLAW_BACKUP_DIR = process.env.OPENCLAW_BACKUP_DIR || path.join(HOME, '.openclaw-backup');
const BACKUP_REPO_URL = process.env.BACKUP_REPO_URL || '';
const BACKUP_CHANNEL_ID = process.env.BACKUP_CHANNEL_ID || '';
const BACKUP_TZ = process.env.BACKUP_TZ || 'America/Sao_Paulo';
const BACKUP_MAX_HISTORY = Number(process.env.BACKUP_MAX_HISTORY || 7);
const BACKUP_SPLIT_SIZE_MB = Number(process.env.BACKUP_SPLIT_SIZE_MB || 90);
const EXCLUDES = [
'.openclaw-backup/**',
'workspace/**',
'media/inbound/**',
'agents/main/sessions/*.jsonl.lock',
'agents/main/sessions/*.jsonl.deleted.*'
];
const formatDateTime = (tz) => {
const parts = new Intl.DateTimeFormat('sv-SE', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).formatToParts(new Date());
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
};
const formatShort = (tz) => {
const parts = new Intl.DateTimeFormat('sv-SE', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).formatToParts(new Date());
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
return `${map.year}${map.month}${map.day}-${map.hour}${map.minute}`;
};
const toPosix = (p) => p.split(path.sep).join('/');
const isExcluded = (rel) => {
const posix = toPosix(rel);
return EXCLUDES.some((pattern) => minimatch(posix, pattern, { dot: true }));
};
const notify = (title, body) => {
const payload = `**${title}**\n${body}`;
console.log(payload);
if (!BACKUP_CHANNEL_ID) return;
const res = spawnSync('openclaw', ['message', 'send', '--channel', 'discord', '--target', BACKUP_CHANNEL_ID, '--message', payload], {
stdio: 'ignore'
});
if (res.error) {
console.error('notify error:', res.error.message);
}
};
const onError = (nowTs) => {
notify('❌ Backup failed', `Time: ${nowTs} (${BACKUP_TZ})\nCheck server logs for details.`);
};
const ensureDir = async (dir) => {
await fsp.mkdir(dir, { recursive: true });
};
const moveHistoryOut = async (dest) => {
const history = path.join(dest, 'history');
try {
await fsp.access(history);
} catch {
return null;
}
const tmp = path.join(dest, `._history_${Date.now()}`);
await fsp.rename(history, tmp);
return tmp;
};
const restoreHistory = async (dest, tmp) => {
if (!tmp) return;
const history = path.join(dest, 'history');
await fsp.rename(tmp, history);
};
const syncDir = async (src, dest) => {
const historyTmp = await moveHistoryOut(dest).catch(() => null);
await fsp.rm(dest, { recursive: true, force: true });
await ensureDir(dest);
if (historyTmp) await restoreHistory(dest, historyTmp);
const walk = async (current) => {
const entries = await fsp.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
const rel = path.relative(src, full);
if (isExcluded(rel)) continue;
const target = path.join(dest, rel);
if (entry.isDirectory()) {
await ensureDir(target);
await walk(full);
} else if (entry.isFile()) {
await ensureDir(path.dirname(target));
await fsp.copyFile(full, target);
}
}
};
await walk(src);
};
const listWorkspaceEntries = async (dir) => {
const entries = [];
const walk = async (current) => {
const items = await fsp.readdir(current, { withFileTypes: true });
for (const item of items) {
const full = path.join(current, item.name);
const rel = path.relative(dir, full);
if (item.isDirectory()) {
await walk(full);
} else if (item.isFile()) {
const stat = await fsp.stat(full);
entries.push(`${toPosix(rel)}|${stat.size}|${stat.mtimeMs}`);
}
}
};
await walk(dir);
return entries;
};
const hashWorkspace = async (workspaceDir) => {
const entries = await listWorkspaceEntries(workspaceDir);
entries.sort();
const hash = crypto.createHash('sha256');
for (const line of entries) hash.update(line + '\n');
return hash.digest('hex');
};
const splitFile = async (filePath, outPrefix, splitSizeBytes) => {
const hash = crypto.createHash('sha256');
const read = fs.createReadStream(filePath);
let partIndex = 0;
let currentSize = 0;
let out = fs.createWriteStream(`${outPrefix}${String(partIndex).padStart(3, '0')}`);
await new Promise((resolve, reject) => {
read.on('data', (chunk) => {
hash.update(chunk);
let offset = 0;
while (offset < chunk.length) {
const remaining = splitSizeBytes - currentSize;
const slice = chunk.subarray(offset, offset + remaining);
out.write(slice);
currentSize += slice.length;
offset += slice.length;
if (currentSize >= splitSizeBytes) {
out.end();
partIndex += 1;
currentSize = 0;
out = fs.createWriteStream(`${outPrefix}${String(partIndex).padStart(3, '0')}`);
}
}
});
read.on('error', reject);
read.on('end', () => {
out.end();
resolve();
});
});
return hash.digest('hex');
};
const git = (args, cwd) => {
const res = spawnSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' });
if (res.error) throw res.error;
return res.stdout.trim();
};
const main = async () => {
const nowTs = formatDateTime(BACKUP_TZ);
const shortTs = formatShort(BACKUP_TZ);
const isoTs = new Date().toISOString();
try {
await ensureDir(OPENCLAW_BACKUP_DIR);
await syncDir(OPENCLAW_HOME, OPENCLAW_BACKUP_DIR);
const workspaceDir = path.join(OPENCLAW_HOME, 'workspace');
const workspaceHashFile = path.join(OPENCLAW_BACKUP_DIR, '.workspace.hash');
const workspaceShaFile = path.join(OPENCLAW_BACKUP_DIR, '.workspace.tar.sha256');
let prevHash = '';
try {
prevHash = (await fsp.readFile(workspaceHashFile, 'utf8')).trim();
} catch {}
let workspaceHash = '';
let workspaceChanged = 0;
let tarSha256 = '';
try {
workspaceHash = await hashWorkspace(workspaceDir);
} catch {}
if (workspaceHash && workspaceHash !== prevHash) {
workspaceChanged = 1;
}
if (workspaceChanged) {
const tarPath = path.join(OPENCLAW_BACKUP_DIR, 'workspace.tar.gz');
const partPrefix = path.join(OPENCLAW_BACKUP_DIR, 'workspace.tar.gz.part.');
const entries = await fsp.readdir(OPENCLAW_BACKUP_DIR);
for (const entry of entries) {
if (entry.startsWith('workspace.tar.gz.part.')) {
await fsp.rm(path.join(OPENCLAW_BACKUP_DIR, entry), { force: true });
}
}
await fsp.rm(tarPath, { force: true });
await tar.c({ gzip: true, cwd: OPENCLAW_HOME, file: tarPath }, ['workspace']);
const splitSize = BACKUP_SPLIT_SIZE_MB * 1024 * 1024;
tarSha256 = await splitFile(tarPath, partPrefix, splitSize);
await fsp.rm(tarPath, { force: true });
await fsp.writeFile(workspaceHashFile, workspaceHash);
await fsp.writeFile(workspaceShaFile, tarSha256);
}
git(['init', '-q'], OPENCLAW_BACKUP_DIR);
git(['branch', '-M', 'main'], OPENCLAW_BACKUP_DIR);
git(['add', '-A'], OPENCLAW_BACKUP_DIR);
const status = git(['status', '--porcelain'], OPENCLAW_BACKUP_DIR).split('\n').filter(Boolean);
const added = status.filter((line) => line.startsWith('A ')).map((line) => line.slice(3));
const modified = status.filter((line) => line.startsWith('M ')).map((line) => line.slice(3));
const deleted = status.filter((line) => line.startsWith('D ')).map((line) => line.slice(3));
const report = {
timestamp: isoTs,
timezone: BACKUP_TZ,
counts: {
added: added.length,
modified: modified.length,
deleted: deleted.length
},
workspace: {
changed: workspaceChanged,
hash: workspaceHash,
tarSha256
}
};
await fsp.writeFile(path.join(OPENCLAW_BACKUP_DIR, 'backup-report.json'), JSON.stringify(report, null, 2));
const comment = 'Backup OK. Full snapshot (config + memory + workspace) to minimize recovery time. Excluded .jsonl.lock and .jsonl.deleted.* session files to reduce noise.';
const phrase = 'You are building something big — and I am here to keep the line steady with you.';
git(['add', '-A'], OPENCLAW_BACKUP_DIR);
try {
git(['commit', '-m', `backup: ${nowTs}`], OPENCLAW_BACKUP_DIR);
} catch {}
const commitSha = git(['rev-parse', '--short', 'HEAD'], OPENCLAW_BACKUP_DIR) || 'unknown';
try {
git(['remote', 'remove', 'origin'], OPENCLAW_BACKUP_DIR);
} catch {}
if (BACKUP_REPO_URL) {
git(['remote', 'add', 'origin', BACKUP_REPO_URL], OPENCLAW_BACKUP_DIR);
git(['push', '-u', 'origin', 'main', '--force'], OPENCLAW_BACKUP_DIR);
}
const historyDir = path.join(OPENCLAW_BACKUP_DIR, 'history', shortTs);
await ensureDir(historyDir);
await fsp.copyFile(path.join(OPENCLAW_BACKUP_DIR, 'backup-report.json'), path.join(historyDir, 'backup-report.json'));
if (workspaceHash) await fsp.copyFile(path.join(OPENCLAW_BACKUP_DIR, '.workspace.hash'), path.join(historyDir, '.workspace.hash'));
if (tarSha256) await fsp.copyFile(path.join(OPENCLAW_BACKUP_DIR, '.workspace.tar.sha256'), path.join(historyDir, '.workspace.tar.sha256'));
const historyRoot = path.join(OPENCLAW_BACKUP_DIR, 'history');
let historyEntries = [];
try {
historyEntries = (await fsp.readdir(historyRoot)).map((name) => path.join(historyRoot, name));
} catch {}
const stats = await Promise.all(historyEntries.map(async (entry) => ({
entry,
stat: await fsp.stat(entry)
})));
stats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
const excess = stats.slice(BACKUP_MAX_HISTORY);
for (const item of excess) {
await fsp.rm(item.entry, { recursive: true, force: true });
}
let summary = `**Summary**\n• Time: ${nowTs} (${BACKUP_TZ})\n• Commit: ${commitSha}\n• Changes: +${added.length} ~${modified.length} -${deleted.length}\n• Workspace changed: ${workspaceChanged}`;
if (tarSha256) summary += `\n• Workspace tar SHA256: ${tarSha256}`;
const formatList = (label, items) => {
if (!items.length) return '';
const top = items.slice(0, 12);
return `\n\n**${label} (top 12)**\n${top.map((item) => `${item}`).join('\n')}`;
};
const changes = [
formatList('Added', added),
formatList('Modified', modified),
formatList('Deleted', deleted)
].join('');
const restoreNote = `\n\n**How to restore this backup**\n1) openclaw gateway stop\n2) Rename current OPENCLAW_HOME (e.g., mv ${OPENCLAW_HOME} ${OPENCLAW_HOME}-restore-${shortTs})\n3) git clone ${BACKUP_REPO_URL || '<repo>'} backup\n4) cd backup && git checkout ${commitSha}\n5) Create OPENCLAW_HOME and copy files back\n6) Merge workspace parts (cat workspace.tar.gz.part.* > workspace.tar.gz) and extract with tar\n7) openclaw gateway start\n\nNote: On Windows, use PowerShell (Move-Item/Copy-Item/tar.exe) for the steps above.`;
notify('✅ Backup completed', `${summary}${changes}\n\n**Comment**\n${comment}\n\n**Phrase**\n${phrase}${restoreNote}`);
} catch (err) {
onError(formatDateTime(BACKUP_TZ));
console.error(err);
process.exit(1);
}
};
main();