Lumi/src/services/update-manager.js
2026-07-18 17:17:58 +02:00

1047 lines
34 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const {
runFileOperationWithRetries,
writeJsonAtomicSync
} = require("./safe-files");
const { getSetting, setSetting } = require("./settings");
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 DEFAULT_SNAPSHOT_RETENTION_DAYS = 30;
const DEFAULT_SNAPSHOTS_PER_TARGET = 5;
const PRESERVE_RELATIVE_PATHS = new Set([
".git",
"node_modules",
"data",
"config",
"storage",
"uploads",
"logs",
"database",
"databases",
"plugins",
"updates",
".codex-local-backups",
"knowledge/community",
"knowledge/corrections",
"taskfile.txt",
"taskfile-export.json",
"security-audit-report.md",
"security-audit-findings.json",
".bot details.md",
"Discord profile banner.png",
"twitch-credentials-lumi.png",
".env",
".env.local",
".env.production",
".secrets",
"codex-guidelines"
]);
const GENERATED_RELATIVE_PATHS = new Set([
"dist",
"build",
"coverage",
".cache",
".parcel-cache",
".turbo",
"tmp",
"temp"
]);
const SNAPSHOT_EXCLUDE_RELATIVE_PATHS = new Set([
".git",
"node_modules",
"data",
"plugins",
"config",
"storage",
"uploads",
"logs",
"database",
"databases",
"updates",
".codex-local-backups",
"data/update-cache",
"data/snapshots"
]);
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();
writeJsonAtomicSync(indexPath, entries);
}
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();
cleanupSnapshots();
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);
const databaseZip = compressSnapshotFile(dbPath, path.join(snapshotPath, "database.zip"));
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, databaseZip, 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 || [],
compressed: true,
storage_bytes: directorySize(snapshot.snapshotPath)
};
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, options = {}) {
const now = Number(options.now) || Date.now();
const retention = options.retention || getSnapshotRetention();
const maximumAge = retention.max_age_days * 24 * 60 * 60 * 1000;
const available = entries
.filter((entry) => entry.status === "available")
.sort((a, b) => b.createdAt - a.createdAt);
const perTarget = new Map();
const keep = new Set();
for (const entry of available) {
const group = snapshotTargetKey(entry);
const count = perTarget.get(group) || 0;
const freshEnough = now - Number(entry.createdAt || 0) <= maximumAge;
if (freshEnough && count < retention.max_per_target && fs.existsSync(entry.path)) {
keep.add(entry.id);
perTarget.set(group, count + 1);
compactLegacySnapshot(entry);
}
}
const pruned = entries.filter((entry) => {
if (entry.status === "available") return keep.has(entry.id);
return now - Number(entry.createdAt || 0) <= maximumAge;
});
for (const entry of entries) {
if ((entry.status === "available" && !keep.has(entry.id)) ||
(entry.status !== "available" && !pruned.includes(entry))) {
try {
fs.rmSync(entry.path, { recursive: true, force: true });
} catch {
// Ignore cleanup failures.
}
}
}
return pruned;
}
function listSnapshots() {
return cleanupSnapshots()
.filter((entry) => entry.status === "available")
.sort((a, b) => b.createdAt - a.createdAt);
}
function cleanupSnapshots(options = {}) {
const entries = loadIndex();
const pruned = pruneEntries(entries, options);
saveIndex(pruned);
return pruned;
}
function getSnapshotRetention() {
return {
max_age_days: boundedInteger(
getSetting("update_snapshot_retention_days", DEFAULT_SNAPSHOT_RETENTION_DAYS),
1,
3650,
DEFAULT_SNAPSHOT_RETENTION_DAYS
),
max_per_target: boundedInteger(
getSetting("update_snapshot_retention_count", DEFAULT_SNAPSHOTS_PER_TARGET),
1,
50,
DEFAULT_SNAPSHOTS_PER_TARGET
)
};
}
function setSnapshotRetention(values = {}) {
const retention = {
max_age_days: boundedInteger(values.max_age_days, 1, 3650, DEFAULT_SNAPSHOT_RETENTION_DAYS),
max_per_target: boundedInteger(values.max_per_target, 1, 50, DEFAULT_SNAPSHOTS_PER_TARGET)
};
setSetting("update_snapshot_retention_days", retention.max_age_days);
setSetting("update_snapshot_retention_count", retention.max_per_target);
cleanupSnapshots({ retention, forceSave: true });
return retention;
}
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();
try {
fs.rmSync(entry.path, { recursive: true, force: true });
} catch {
// The restore already succeeded; stale payload cleanup can retry later.
}
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) {
// Patch archives are copied relative to the repository root. Do not infer a
// nested root from a single top-level directory such as src/, or patches will
// be applied to services/ and web/ instead of src/services/ and src/web/.
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.");
}
const manifestPath = path.join(rootPath, "patch-manifest.json");
if (!fs.existsSync(manifestPath)) return null;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.target !== "core" || manifest.data_policy !== "preserve") {
throw new Error("Patch manifest must target core and preserve local data.");
}
const currentVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version;
if (Array.isArray(manifest.from_versions) && !manifest.from_versions.includes(currentVersion)) {
throw new Error(`This patch supports core ${manifest.from_versions.join(", ")}, not ${currentVersion}.`);
}
const files = manifest.files && typeof manifest.files === "object" ? manifest.files : {};
if (!Object.keys(files).length) throw new Error("Patch manifest does not list any files.");
const preservePaths = buildCorePreservePaths(repoRoot);
for (const [relativePath, expectedHash] of Object.entries(files)) {
const normalized = normalizeRelative(relativePath);
if (!normalized || normalized.startsWith("/") || normalized.includes("../") || isRelativePathIgnored(normalized, preservePaths)) {
throw new Error(`Patch manifest contains an unsafe or preserved path: ${relativePath}`);
}
const filePath = path.resolve(rootPath, normalized);
if (!filePath.startsWith(path.resolve(rootPath) + path.sep) || !fs.statSync(filePath, { throwIfNoEntry: false })?.isFile()) {
throw new Error(`Patch file is missing: ${relativePath}`);
}
const actualHash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
if (actualHash !== expectedHash) throw new Error(`Patch file checksum failed: ${relativePath}`);
}
const packageFile = path.join(rootPath, "package.json");
if (manifest.to_version && fs.existsSync(packageFile)) {
const packageVersion = JSON.parse(fs.readFileSync(packageFile, "utf8")).version;
if (packageVersion !== manifest.to_version) {
throw new Error(`Patch targets ${manifest.to_version}, but package.json contains ${packageVersion}.`);
}
}
return manifest;
}
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([
...buildCorePreservePaths(repoRoot),
...SNAPSHOT_EXCLUDE_RELATIVE_PATHS
]));
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 compressSnapshotFile(source, destination) {
if (!fs.existsSync(source)) return null;
if (!AdmZip) throw new Error("adm-zip is not installed. Run npm install.");
const zip = new AdmZip();
zip.addLocalFile(source);
zip.writeZip(destination);
fs.rmSync(source, { force: true });
return 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 = normalizeRelative(path.relative(basePath, fullPath));
if (isRelativePathIgnored(relPath, ignore)) {
continue;
}
if (entry.isDirectory()) {
addFolder(zip, fullPath, basePath, ignore);
} else if (entry.isFile()) {
zip.addLocalFile(fullPath, path.dirname(relPath));
}
}
}
function compactLegacySnapshot(entry) {
if (!entry?.path || !fs.existsSync(entry.path)) return false;
let changed = false;
try {
const rawDatabase = path.join(entry.path, "app.db");
const databaseZip = path.join(entry.path, "database.zip");
if (fs.existsSync(rawDatabase) && !fs.existsSync(databaseZip)) {
compressSnapshotFile(rawDatabase, databaseZip);
changed = true;
}
if (entry.type === "bot") {
const fullPath = path.join(entry.path, "full");
const coreZip = path.join(entry.path, "core.zip");
if (fs.existsSync(fullPath)) {
if (!fs.existsSync(coreZip)) {
zipFolder(fullPath, coreZip, {
base: fullPath,
ignore: new Set([
...buildCorePreservePaths(fullPath),
...SNAPSHOT_EXCLUDE_RELATIVE_PATHS
])
});
}
fs.rmSync(fullPath, { recursive: true, force: true });
changed = true;
}
}
entry.compressed = true;
entry.storage_bytes = directorySize(entry.path);
} catch {
// Keep the original payload when migration/compaction is unavailable.
}
return changed;
}
function snapshotTargetKey(entry) {
const type = String(entry.target_kind || entry.type || "unknown");
const id = String(entry.target_id || entry.pluginId || "core");
return `${type}:${id}`;
}
function directorySize(directory) {
if (!directory || !fs.existsSync(directory)) return 0;
let total = 0;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) total += directorySize(target);
else if (entry.isFile()) total += fs.statSync(target).size;
}
return total;
}
function boundedInteger(value, minimum, maximum, fallback) {
const number = Number.parseInt(value, 10);
return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback;
}
function resetCoreFiles() {
resetDirectoryForFullUpdate(repoRoot, buildCorePreservePaths(repoRoot));
}
function resetDirectoryForFullUpdate(targetRoot, preservePaths = buildCorePreservePaths(targetRoot)) {
removeUnpreservedEntries(targetRoot, targetRoot, preservePaths);
}
function buildCorePreservePaths(targetRoot = repoRoot) {
const preserve = new Set(PRESERVE_RELATIVE_PATHS);
for (const scope of ["core", "plugins"]) {
const directory = path.join(targetRoot, "knowledge", scope);
collectLocalKnowledgePaths(directory, targetRoot, preserve);
}
return preserve;
}
function collectLocalKnowledgePaths(directory, base, preserve) {
if (!fs.existsSync(directory)) return;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectLocalKnowledgePaths(filePath, base, preserve);
} else if (!entry.isFile() || isLocallyOwnedKnowledgeFile(filePath)) {
preserve.add(normalizeRelative(path.relative(base, filePath)));
}
}
}
function isLocallyOwnedKnowledgeFile(filePath) {
if (path.extname(filePath).toLowerCase() !== ".md") return true;
let source = "";
try {
source = fs.readFileSync(filePath, "utf8");
} catch {
return true;
}
const frontmatter = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] || "";
const generated = /^generated:\s*true\s*$/im.test(frontmatter);
const readOnly = /^editable:\s*false\s*$/im.test(frontmatter);
return !(generated && readOnly);
}
function removeUnpreservedEntries(directory, base, preservePaths) {
const entries = fs.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const relativePath = normalizeRelative(path.relative(base, fullPath));
if (isRelativePathIgnored(relativePath, preservePaths)) continue;
if (entry.isDirectory() && hasPreservedDescendant(relativePath, preservePaths)) {
removeUnpreservedEntries(fullPath, base, preservePaths);
continue;
}
fs.rmSync(fullPath, { recursive: true, force: true });
}
}
function hasPreservedDescendant(relativePath, preservePaths) {
const prefix = `${normalizeRelative(relativePath)}/`;
return [...preservePaths].some((candidate) => normalizeRelative(candidate).startsWith(prefix));
}
function normalizeRelative(value) {
return String(value || "").split(path.sep).join("/");
}
function isRelativePathIgnored(relativePath, ignore) {
const rel = normalizeRelative(relativePath);
if (!rel) return false;
if (ignore.has(rel)) return true;
const parts = rel.split("/");
for (let index = 1; index <= parts.length; index += 1) {
if (ignore.has(parts.slice(0, index).join("/"))) {
return true;
}
}
return false;
}
function copyDirectory(source, target, ignore, options = {}) {
const base = options.base || source;
const entries = fs.readdirSync(source, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(source, entry.name);
const relPath = normalizeRelative(path.relative(base, srcPath));
if (isRelativePathIgnored(relPath, ignore)) {
continue;
}
const destPath = path.join(target, entry.name);
if (entry.isDirectory()) {
fs.mkdirSync(destPath, { recursive: true });
copyDirectory(srcPath, destPath, ignore, { base });
} else if (entry.isFile()) {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(srcPath, destPath);
}
}
}
function removeGeneratedPaths() {
for (const relativePath of GENERATED_RELATIVE_PATHS) {
const target = path.join(repoRoot, relativePath);
if (target.startsWith(path.join(repoRoot, "data", "snapshots"))) {
continue;
}
fs.rmSync(target, { recursive: true, force: true });
}
}
function applyCoreFilesFromDirectory(rootPath) {
removeGeneratedPaths();
const preservePaths = buildCorePreservePaths(repoRoot);
resetDirectoryForFullUpdate(repoRoot, preservePaths);
copyDirectory(
rootPath,
repoRoot,
preservePaths,
{ base: rootPath }
);
}
function applyCorePatch(rootPath) {
const preservePaths = buildCorePreservePaths(repoRoot);
preservePaths.add("patch-manifest.json");
copyDirectory(
rootPath,
repoRoot,
preservePaths,
{ base: rootPath }
);
}
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 = {}) {
const parent = path.dirname(targetDir);
const name = path.basename(targetDir);
const nonce = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
const staging = path.join(parent, `.${name}.update-${nonce}`);
const backup = path.join(parent, `.${name}.backup-${nonce}`);
const ignore = options.preserveData
? new Set(["node_modules", "data"])
: new Set(["node_modules"]);
let targetMoved = false;
let dataMoved = false;
let replacementInstalled = false;
let rollbackFailed = false;
fs.mkdirSync(parent, { recursive: true });
try {
fs.mkdirSync(staging, { recursive: true });
copyDirectory(rootPath, staging, ignore);
// A running plugin may legitimately keep files below data open. In
// particular, Lumi AI starts llama-server with data/runtime as its working
// directory. Windows and SMB then reject renaming the plugin root even
// though none of the code being updated is locked. Keep the data directory
// in place and transactionally replace only the plugin's code entries.
if (options.preserveData && fs.existsSync(targetDir)) {
replacePluginCodeInPlace(staging, targetDir, backup);
return;
}
if (fs.existsSync(targetDir)) {
moveDirectory(targetDir, backup, "backup_plugin");
targetMoved = true;
}
const backupData = path.join(backup, "data");
const stagedData = path.join(staging, "data");
if (options.preserveData && targetMoved && fs.existsSync(backupData)) {
moveDirectory(backupData, stagedData, "preserve_plugin_data");
dataMoved = true;
}
moveDirectory(staging, targetDir, "install_plugin");
replacementInstalled = true;
fs.rmSync(backup, { recursive: true, force: true });
} catch (error) {
try {
if (replacementInstalled && fs.existsSync(targetDir)) {
const installedData = path.join(targetDir, "data");
if (dataMoved && targetMoved && fs.existsSync(installedData) && fs.existsSync(backup)) {
moveDirectory(installedData, path.join(backup, "data"), "restore_plugin_data");
}
fs.rmSync(targetDir, { recursive: true, force: true });
} else if (dataMoved && targetMoved && fs.existsSync(path.join(staging, "data")) && fs.existsSync(backup)) {
moveDirectory(path.join(staging, "data"), path.join(backup, "data"), "restore_plugin_data");
}
if (targetMoved && fs.existsSync(backup) && !fs.existsSync(targetDir)) {
moveDirectory(backup, targetDir, "restore_plugin");
}
} catch (restoreError) {
rollbackFailed = true;
error.message = `${error.message} Plugin rollback also failed: ${restoreError.message}. Backup: ${backup}. Staging: ${staging}`;
}
throw error;
} finally {
if (!rollbackFailed) fs.rmSync(staging, { recursive: true, force: true });
}
}
function replacePluginCodeInPlace(staging, targetDir, backup) {
const movedExisting = [];
const installed = [];
let rollbackFailed = false;
fs.mkdirSync(backup, { recursive: true });
try {
for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) {
if (entry.name === "data") continue;
moveDirectory(
path.join(targetDir, entry.name),
path.join(backup, entry.name),
"backup_plugin_code"
);
movedExisting.push(entry.name);
}
for (const entry of fs.readdirSync(staging, { withFileTypes: true })) {
moveDirectory(
path.join(staging, entry.name),
path.join(targetDir, entry.name),
"install_plugin_code"
);
installed.push(entry.name);
}
fs.rmSync(backup, { recursive: true, force: true });
} catch (error) {
try {
for (const name of installed.reverse()) {
fs.rmSync(path.join(targetDir, name), { recursive: true, force: true });
}
for (const name of movedExisting) {
const saved = path.join(backup, name);
if (!fs.existsSync(saved)) continue;
const live = path.join(targetDir, name);
fs.rmSync(live, { recursive: true, force: true });
moveDirectory(saved, live, "restore_plugin_code");
}
} catch (restoreError) {
rollbackFailed = true;
error.message = `${error.message} Plugin code rollback also failed: ${restoreError.message}. Backup: ${backup}. Staging: ${staging}`;
}
throw error;
} finally {
if (!rollbackFailed) {
fs.rmSync(staging, { recursive: true, force: true });
fs.rmSync(backup, { recursive: true, force: true });
}
}
}
function moveDirectory(source, target, operation) {
runFileOperationWithRetries(() => fs.renameSync(source, target), {
target,
operation,
attempts: 8,
delayMs: 20
});
}
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);
let patchManifest = null;
if (mode === "patch") {
patchManifest = verifyPatchPackage(rootPath);
} else {
verifyBotPackage(rootPath);
}
const marker = createRecoveryMarker({
target_kind: "core",
target_id: "core",
from_version: options.metadata?.from_version || (patchManifest ? JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version : null),
to_version: options.metadata?.to_version || patchManifest?.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,
from_version: options.metadata?.from_version || (patchManifest ? JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version : null),
to_version: options.metadata?.to_version || patchManifest?.to_version || null,
recovery_marker_id: marker.id
}
});
let record = null;
try {
record = finalizeSnapshot(snapshot);
if (mode === "patch") {
applyCorePatch(rootPath);
} else {
applyCoreFilesFromDirectory(rootPath);
}
markRecoveryMarkerComplete({ snapshot_id: record.id });
return record;
} catch (error) {
if (record) {
try {
restoreSnapshot(record.id, {
expectedType: "bot",
allowUnsafeMajorRollback: true
});
} catch (restoreError) {
error.message = `${error.message} Automatic rollback also failed: ${restoreError.message}`;
}
} else {
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
}
});
let record = null;
try {
record = finalizeSnapshot(snapshot);
applyPluginFiles(rootPath, manifest.id, {
preserveData: snapshot.pluginExisted
});
markRecoveryMarkerComplete({ snapshot_id: record.id });
return record;
} catch (error) {
if (record) {
try {
restoreSnapshot(record.id, {
expectedType: "plugin",
expectedPluginId: manifest.id,
allowUnsafeMajorRollback: true
});
} catch (restoreError) {
error.message = `${error.message} Automatic rollback also failed: ${restoreError.message}`;
}
} else {
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");
const databaseZip = path.join(snapshotPath, "database.zip");
let restoreSource = source;
let temporary = null;
if (!fs.existsSync(restoreSource) && fs.existsSync(databaseZip)) {
temporary = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-database-restore-"));
extractZip(databaseZip, temporary);
restoreSource = path.join(temporary, "app.db");
}
if (!fs.existsSync(restoreSource)) {
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
throw new Error("Snapshot database archive is missing or invalid.");
}
try {
fs.copyFileSync(restoreSource, target);
} finally {
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
}
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 fullPath = path.join(entry.path, "full");
if (fs.existsSync(fullPath)) {
applyCoreFilesFromDirectory(fullPath);
restoreDatabase(entry.path);
markSnapshotRolledBack(id);
return entry;
}
const fullZip = path.join(entry.path, "full.zip");
const coreZip = path.join(entry.path, "core.zip");
const restoreZip = fs.existsSync(fullZip) ? fullZip : coreZip;
if (!fs.existsSync(restoreZip)) {
throw new Error("Snapshot core archive missing.");
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
try {
extractZip(restoreZip, tempDir);
const rootPath = resolveZipRoot(tempDir);
applyCoreFilesFromDirectory(rootPath);
} finally {
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-"));
try {
extractZip(pluginZip, tempDir);
const rootPath = resolvePluginRoot(tempDir);
applyPluginFiles(rootPath, entry.pluginId, { preserveData: true });
} finally {
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,
applyCoreFilesFromDirectory,
applyPluginFiles,
verifyPatchPackage,
resetPluginCode,
resetDirectoryForFullUpdate,
buildCorePreservePaths,
replacePluginDirectory,
cleanupSnapshots,
getSnapshotRetention,
setSnapshotRetention,
pruneEntries,
listSnapshots,
restoreSnapshot
};