const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); let AdmZip = null; try { AdmZip = require("adm-zip"); } catch { AdmZip = null; } const { db } = require("./db"); const { createRecoveryMarker, updateRecoveryMarker, markRecoveryMarkerFailed, markRecoveryMarkerComplete } = require("./recovery-mode"); const repoRoot = path.join(__dirname, "..", ".."); const dataDir = path.join(repoRoot, "data"); const snapshotsDir = path.join(dataDir, "snapshots"); const indexPath = path.join(snapshotsDir, "index.json"); const maxSnapshots = 20; function ensureSnapshotsDir() { fs.mkdirSync(snapshotsDir, { recursive: true }); } function loadIndex() { if (!fs.existsSync(indexPath)) { return []; } try { const raw = fs.readFileSync(indexPath, "utf8"); const data = JSON.parse(raw); return Array.isArray(data) ? data : []; } catch { return []; } } function saveIndex(entries) { ensureSnapshotsDir(); fs.writeFileSync(indexPath, JSON.stringify(entries, null, 2), "utf8"); } async function backupDatabase(targetPath) { if (typeof db.backup === "function") { await db.backup(targetPath); return; } const source = path.join(dataDir, "app.db"); if (fs.existsSync(source)) { fs.copyFileSync(source, targetPath); } } async function createSnapshot({ type, pluginId, metadata = {} }) { ensureSnapshotsDir(); const id = `${Date.now()}-${crypto.randomUUID()}`; const snapshotPath = path.join(snapshotsDir, id); fs.mkdirSync(snapshotPath, { recursive: true }); const dbPath = path.join(snapshotPath, "app.db"); await backupDatabase(dbPath); let pluginExisted = false; let pluginZip = null; if (type === "bot") { const coreZip = path.join(snapshotPath, "core.zip"); zipCore(coreZip); } if (type === "plugin" && pluginId) { const pluginDir = path.join(repoRoot, "plugins", pluginId); pluginExisted = fs.existsSync(pluginDir); if (pluginExisted) { pluginZip = path.join(snapshotPath, "plugin.zip"); zipFolder(pluginDir, pluginZip, { base: pluginDir, ignore: new Set(["node_modules", "data"]) }); } } return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath, metadata }; } function finalizeSnapshot(snapshot) { const entries = loadIndex(); const record = { id: snapshot.id, type: snapshot.type, pluginId: snapshot.pluginId || null, pluginExisted: snapshot.pluginExisted || false, createdAt: Date.now(), status: "available", path: snapshot.snapshotPath, target_kind: snapshot.metadata?.target_kind || snapshot.type, target_id: snapshot.metadata?.target_id || snapshot.pluginId || null, from_version: snapshot.metadata?.from_version || null, to_version: snapshot.metadata?.to_version || null, source_branch: snapshot.metadata?.source_branch || null, update_method: snapshot.metadata?.update_method || null, rollback_safe: snapshot.metadata?.rollback_safe !== false, recovery_marker_id: snapshot.metadata?.recovery_marker_id || null, major_crossing: Boolean(snapshot.metadata?.major_crossing), migration_notes: snapshot.metadata?.migration_notes || "", danger_notes: snapshot.metadata?.danger_notes || [] }; entries.push(record); saveIndex(pruneEntries(entries)); return record; } function discardSnapshot(snapshot) { if (!snapshot?.snapshotPath) { return; } try { fs.rmSync(snapshot.snapshotPath, { recursive: true, force: true }); } catch { // Ignore cleanup failures. } } function pruneEntries(entries) { const available = entries .filter((entry) => entry.status === "available") .sort((a, b) => b.createdAt - a.createdAt); const keep = new Set(available.slice(0, maxSnapshots).map((entry) => entry.id)); const pruned = entries.filter((entry) => entry.status !== "available" || keep.has(entry.id)); for (const entry of entries) { if (entry.status === "available" && !keep.has(entry.id)) { try { fs.rmSync(entry.path, { recursive: true, force: true }); } catch { // Ignore cleanup failures. } } } return pruned; } function listSnapshots() { return loadIndex() .filter((entry) => entry.status === "available") .sort((a, b) => b.createdAt - a.createdAt); } function markSnapshotRolledBack(id) { const entries = loadIndex(); const entry = entries.find((item) => item.id === id); if (!entry) { return null; } entry.status = "rolled_back"; entry.rolledBackAt = Date.now(); saveIndex(entries); return entry; } function extractZip(zipPath, targetDir) { if (!AdmZip) { throw new Error("adm-zip is not installed. Run npm install."); } const zip = new AdmZip(zipPath); zip.extractAllTo(targetDir, true); } function resolveZipRoot(extractedDir) { const packagePath = path.join(extractedDir, "package.json"); if (fs.existsSync(packagePath)) { return extractedDir; } const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); const dirs = entries.filter((entry) => entry.isDirectory()); if (dirs.length === 1) { const candidate = path.join(extractedDir, dirs[0].name); if (fs.existsSync(path.join(candidate, "package.json"))) { return candidate; } } return extractedDir; } function resolvePatchRoot(extractedDir) { const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); const dirs = entries.filter((entry) => entry.isDirectory()); const files = entries.filter((entry) => entry.isFile()); if (files.length > 0) { return extractedDir; } if (dirs.length === 1) { return path.join(extractedDir, dirs[0].name); } return extractedDir; } function resolvePluginRoot(extractedDir) { const pluginPath = path.join(extractedDir, "plugin.json"); if (fs.existsSync(pluginPath)) { return extractedDir; } const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); const dirs = entries.filter((entry) => entry.isDirectory()); if (dirs.length === 1) { const candidate = path.join(extractedDir, dirs[0].name); if (fs.existsSync(path.join(candidate, "plugin.json"))) { return candidate; } } return extractedDir; } function verifyBotPackage(rootPath) { const required = [ path.join(rootPath, "package.json"), path.join(rootPath, "safe-mode.js"), path.join(rootPath, "src", "main.js"), path.join(rootPath, "src", "web", "server.js") ]; for (const filePath of required) { if (!fs.existsSync(filePath)) { throw new Error(`Missing required file: ${path.relative(rootPath, filePath)}`); } } JSON.parse(fs.readFileSync(required[0], "utf8")); } function verifyPatchPackage(rootPath) { if (!hasAnyFiles(rootPath)) { throw new Error("Patch archive is empty."); } } function verifyPluginPackage(rootPath) { const pluginPath = path.join(rootPath, "plugin.json"); if (!fs.existsSync(pluginPath)) { throw new Error("plugin.json not found in plugin package."); } const manifest = JSON.parse(fs.readFileSync(pluginPath, "utf8")); if (!manifest.id) { throw new Error("plugin.json must include an id."); } const mainFile = manifest.main || "index.js"; const mainPath = path.join(rootPath, mainFile); if (!fs.existsSync(mainPath)) { throw new Error(`Plugin entry ${mainFile} not found.`); } return manifest; } function zipCore(destination) { if (!AdmZip) { throw new Error("adm-zip is not installed. Run npm install."); } const zip = new AdmZip(); addFolder(zip, repoRoot, repoRoot, new Set([".git", "node_modules", "data", "plugins"])); zip.writeZip(destination); } function zipFolder(source, destination, options) { if (!AdmZip) { throw new Error("adm-zip is not installed. Run npm install."); } const zip = new AdmZip(); const base = options?.base || source; addFolder(zip, source, base, options?.ignore || new Set(["node_modules"])); zip.writeZip(destination); } function addFolder(zip, folderPath, basePath, ignore) { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(folderPath, entry.name); const relPath = path.relative(basePath, fullPath); const topLevel = relPath.split(path.sep)[0]; if (ignore.has(topLevel)) { continue; } if (entry.isDirectory()) { addFolder(zip, fullPath, basePath, ignore); } else if (entry.isFile()) { zip.addLocalFile(fullPath, path.dirname(relPath)); } } } function resetCoreFiles() { const ignore = new Set([".git", "node_modules", "data", "plugins"]); const entries = fs.readdirSync(repoRoot, { withFileTypes: true }); for (const entry of entries) { if (ignore.has(entry.name)) { continue; } const fullPath = path.join(repoRoot, entry.name); fs.rmSync(fullPath, { recursive: true, force: true }); } } function copyDirectory(source, target, ignore) { const entries = fs.readdirSync(source, { withFileTypes: true }); for (const entry of entries) { if (ignore.has(entry.name)) { continue; } const srcPath = path.join(source, entry.name); const destPath = path.join(target, entry.name); if (entry.isDirectory()) { fs.mkdirSync(destPath, { recursive: true }); copyDirectory(srcPath, destPath, ignore); } else if (entry.isFile()) { fs.copyFileSync(srcPath, destPath); } } } function applyCoreUpdate(rootPath) { resetCoreFiles(); copyDirectory( rootPath, repoRoot, new Set([".git", "node_modules", "data", "plugins"]) ); } function applyCorePatch(rootPath) { copyDirectory( rootPath, repoRoot, new Set([".git", "node_modules", "data", "plugins"]) ); } function hasAnyFiles(rootPath) { const entries = fs.readdirSync(rootPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile()) { return true; } if (entry.isDirectory()) { if (hasAnyFiles(path.join(rootPath, entry.name))) { return true; } } } return false; } function resetPluginCode(targetDir) { if (!fs.existsSync(targetDir)) { return; } for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) { if (entry.name === "data") { continue; } fs.rmSync(path.join(targetDir, entry.name), { recursive: true, force: true }); } } function applyPluginFiles(rootPath, pluginId, options = {}) { const pluginsDir = path.join(repoRoot, "plugins"); const targetDir = path.join(pluginsDir, pluginId); fs.mkdirSync(pluginsDir, { recursive: true }); replacePluginDirectory(rootPath, targetDir, options); } function replacePluginDirectory(rootPath, targetDir, options = {}) { if (options.preserveData) { resetPluginCode(targetDir); } else { fs.rmSync(targetDir, { recursive: true, force: true }); } fs.mkdirSync(targetDir, { recursive: true }); copyDirectory( rootPath, targetDir, options.preserveData ? new Set(["node_modules", "data"]) : new Set(["node_modules"]) ); } async function applyBotUpdate(zipPath, options = {}) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-update-")); try { extractZip(zipPath, tempDir); const mode = options.mode === "patch" ? "patch" : "full"; const rootPath = mode === "patch" ? resolvePatchRoot(tempDir) : resolveZipRoot(tempDir); if (mode === "patch") { verifyPatchPackage(rootPath); } else { verifyBotPackage(rootPath); } const marker = createRecoveryMarker({ target_kind: "core", target_id: "core", from_version: options.metadata?.from_version || null, to_version: options.metadata?.to_version || null, source_branch: options.metadata?.source_branch || "manual_zip", update_method: options.mode === "patch" ? "zip_patch" : "zip", rollback_safe: options.metadata?.rollback_safe !== false, major_crossing: Boolean(options.metadata?.major_crossing) }); updateRecoveryMarker({ status: "applying" }); const snapshot = await createSnapshot({ type: "bot", metadata: { target_kind: "core", update_method: options.mode === "patch" ? "zip_patch" : "zip", ...options.metadata, recovery_marker_id: marker.id } }); try { if (mode === "patch") { applyCorePatch(rootPath); } else { applyCoreUpdate(rootPath); } const record = finalizeSnapshot(snapshot); markRecoveryMarkerComplete({ snapshot_id: record.id }); return record; } catch (error) { discardSnapshot(snapshot); markRecoveryMarkerFailed(error); throw error; } } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } } async function applyPluginUpdate(zipPath, options = {}) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-")); try { extractZip(zipPath, tempDir); const rootPath = resolvePluginRoot(tempDir); const manifest = verifyPluginPackage(rootPath); if (options.expectedPluginId && manifest.id !== options.expectedPluginId) { throw new Error(`Plugin ZIP id ${manifest.id} does not match ${options.expectedPluginId}.`); } const marker = createRecoveryMarker({ target_kind: "plugin", target_id: manifest.id, from_version: options.metadata?.from_version || null, to_version: manifest.version || options.metadata?.to_version || null, source_branch: options.metadata?.source_branch || "manual_zip", update_method: "zip", rollback_safe: options.metadata?.rollback_safe !== false, major_crossing: Boolean(options.metadata?.major_crossing) }); updateRecoveryMarker({ status: "applying" }); const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id, metadata: { target_kind: "plugin", target_id: manifest.id, update_method: "zip", ...options.metadata, recovery_marker_id: marker.id } }); try { applyPluginFiles(rootPath, manifest.id, { preserveData: snapshot.pluginExisted }); const record = finalizeSnapshot(snapshot); markRecoveryMarkerComplete({ snapshot_id: record.id }); return record; } catch (error) { discardSnapshot(snapshot); markRecoveryMarkerFailed(error); throw error; } } finally { fs.rmSync(tempDir, { recursive: true, force: true }); } } function restoreDatabase(snapshotPath) { const source = path.join(snapshotPath, "app.db"); const target = path.join(dataDir, "app.db"); if (!fs.existsSync(source)) { throw new Error("Snapshot database not found."); } fs.copyFileSync(source, target); const wal = path.join(dataDir, "app.db-wal"); const shm = path.join(dataDir, "app.db-shm"); fs.rmSync(wal, { force: true }); fs.rmSync(shm, { force: true }); } function restoreSnapshot(id, options = {}) { const entries = loadIndex(); const entry = entries.find((item) => item.id === id); if (!entry) { throw new Error("Snapshot not found."); } if (entry.status !== "available") { throw new Error("Snapshot is no longer available."); } if (options.expectedType && entry.type !== options.expectedType) { throw new Error("Snapshot target type does not match this revert action."); } if (options.expectedPluginId && entry.pluginId !== options.expectedPluginId) { throw new Error("Snapshot target plugin does not match this revert action."); } if (entry.major_crossing && entry.rollback_safe === false && !options.allowUnsafeMajorRollback) { throw new Error("This snapshot crossed a major version and is not marked rollback safe."); } if (options.currentVersion && entry.to_version && entry.to_version !== options.currentVersion) { throw new Error("Only the previous version snapshot can be reverted from this action."); } if (entry.type === "bot") { const coreZip = path.join(entry.path, "core.zip"); if (!fs.existsSync(coreZip)) { throw new Error("Snapshot core archive missing."); } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); extractZip(coreZip, tempDir); const rootPath = resolveZipRoot(tempDir); applyCoreUpdate(rootPath); fs.rmSync(tempDir, { recursive: true, force: true }); } if (entry.type === "plugin") { const pluginsDir = path.join(repoRoot, "plugins"); const targetDir = path.join(pluginsDir, entry.pluginId); if (entry.pluginExisted) { const pluginZip = path.join(entry.path, "plugin.zip"); if (!fs.existsSync(pluginZip)) { throw new Error("Snapshot plugin archive missing."); } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); extractZip(pluginZip, tempDir); const rootPath = resolvePluginRoot(tempDir); applyPluginFiles(rootPath, entry.pluginId, { preserveData: true }); fs.rmSync(tempDir, { recursive: true, force: true }); } else { fs.rmSync(targetDir, { recursive: true, force: true }); } } restoreDatabase(entry.path); markSnapshotRolledBack(id); return entry; } module.exports = { applyBotUpdate, applyPluginUpdate, createSnapshot, finalizeSnapshot, discardSnapshot, applyPluginFiles, resetPluginCode, replacePluginDirectory, listSnapshots, restoreSnapshot };