const path = require("path"); const fs = require("fs"); const { syncPluginRegistry, setPluginEnabled } = require("./plugins"); const { createSnapshot, finalizeSnapshot, discardSnapshot, restoreSnapshot, applyCoreFilesFromDirectory, applyPluginFiles } = require("./update-manager"); const { getUpdateStatus } = require("./update-index"); const { ensureManagedRepo, writeUpdateState } = require("./update-repository"); const { compareSemver, parseSemver } = require("./versioning"); const { createRecoveryMarker, updateRecoveryMarker, markRecoveryMarkerFailed, markRecoveryMarkerComplete } = require("./recovery-mode"); const repoRoot = path.join(__dirname, "..", ".."); const activeOperations = new Set(); function withOperation(key, fn) { if (activeOperations.has(key)) { throw new Error("An update is already running for this target."); } activeOperations.add(key); return Promise.resolve() .then(fn) .finally(() => activeOperations.delete(key)); } function emitProgress(publish, event, payload) { if (typeof publish === "function") { publish(event, payload, { role: "admin" }); } } function verifyCoreFiles(rootPath = repoRoot, expectedVersion = null) { for (const file of ["package.json", "src/main.js", "src/web/server.js"]) { if (!fs.existsSync(path.join(rootPath, file))) { throw new Error(`Core verification failed: ${file} is missing.`); } } const actualVersion = JSON.parse(fs.readFileSync(path.join(rootPath, "package.json"), "utf8")).version; if (expectedVersion && actualVersion !== expectedVersion) { throw new Error(`Core verification failed: requested ${expectedVersion}, but repository ref contains ${actualVersion || "no version"}.`); } } function verifyPluginFiles(pluginId, rootPath = repoRoot, expectedVersion = null) { const manifest = path.join(rootPath, "plugins", pluginId, "plugin.json"); if (!fs.existsSync(manifest)) { throw new Error(`Plugin verification failed: ${pluginId}/plugin.json is missing.`); } const metadata = JSON.parse(fs.readFileSync(manifest, "utf8")); if (expectedVersion && metadata.version !== expectedVersion) { throw new Error(`Plugin verification failed: requested ${expectedVersion}, but repository ref contains ${metadata.version || "no version"}.`); } return metadata; } function targetForRequestedVersion(baseTarget, requestedVersion, label) { if (!requestedVersion) return baseTarget; const version = parseSemver(requestedVersion)?.raw; if (!version) throw new Error("Choose a valid version in x.y.z format."); const selected = (baseTarget.available_versions || []).find((item) => item.version === version); if (!selected) throw new Error(`${label} ${version} is not listed as an installable repository release.`); const comparison = compareSemver(version, baseTarget.current_version); const direction = comparison < 0 ? "downgrade" : comparison === 0 ? "repair reinstall" : "update"; return { ...baseTarget, blocked: false, blocked_reason: null, update_available: true, safe_target_version: version, source_branch: selected.ref, raw_target: selected, rollback_safe: selected.rollback_safe !== false, migration_notes: selected.migration_notes || baseTarget.migration_notes || "", major_crossing: parseSemver(version)?.major !== parseSemver(baseTarget.current_version)?.major, requires_manual_confirmation: true, requested_version: version, version_description: `${baseTarget.current_version} -> ${version} (${direction})`, warnings: [ ...(baseTarget.warnings || []), comparison < 0 ? "You selected an older code version. Lumi data is preserved, but an older release may not understand newer database changes." : comparison === 0 ? "This reinstalls the selected release to repair its managed files." : `You selected ${version} instead of the recommended target.` ] }; } async function applyCoreUpdate({ source = "stable", remote = null, version = null, publish } = {}) { return withOperation("core", async () => { const status = getUpdateStatus({ source, remote }); const target = targetForRequestedVersion(status.core, version, "Core version"); if (target.blocked) throw new Error(target.blocked_reason || "Core update is blocked."); if (!target.update_available) throw new Error("No safe core update target is available."); const marker = createRecoveryMarker({ target_kind: "core", target_id: "core", from_version: target.current_version, to_version: target.safe_target_version, source_branch: target.source_branch, update_method: "git", rollback_safe: target.rollback_safe, major_crossing: target.major_crossing }); let snapshot = null; let snapshotRecord = null; try { emitProgress(publish, "update:queued", { target: "core" }); emitProgress(publish, "update:checking", { target: "core" }); emitProgress(publish, "update:metadata", target); updateRecoveryMarker({ status: "applying" }); snapshot = await createSnapshot({ type: "bot", metadata: { target_kind: "core", target_id: "core", from_version: target.current_version, to_version: target.safe_target_version, source_branch: target.source_branch, update_method: "git", rollback_safe: target.rollback_safe, recovery_marker_id: marker.id, major_crossing: target.major_crossing, migration_notes: target.migration_notes, danger_notes: target.dangers } }); snapshotRecord = finalizeSnapshot(snapshot); emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshotRecord.id }); emitProgress(publish, "update:recovery_marker", { target: "core", marker_id: marker.id }); emitProgress(publish, "update:download", { target: "core", branch: target.source_branch }); const managed = ensureManagedRepo(status.remote, target.source_branch); verifyCoreFiles(managed.path, target.safe_target_version); emitProgress(publish, "update:apply", { target: "core" }); applyCoreFilesFromDirectory(managed.path); updateRecoveryMarker({ status: "verifying" }); emitProgress(publish, "update:verify", { target: "core" }); verifyCoreFiles(repoRoot, target.safe_target_version); const record = snapshotRecord; markRecoveryMarkerComplete({ snapshot_id: record.id }); writeUpdateState({ remote: managed.repository, branch: managed.branch, last_update_at: new Date().toISOString(), last_update_status: "complete", last_update_stage: "complete", last_error: null, last_snapshot_id: record.id, last_target_kind: "core", last_target_version: target.safe_target_version }); emitProgress(publish, "update:restart_required", { target: "core" }); emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id }); return { status: "complete", restart_required: true, snapshot: record, target }; } catch (error) { if (snapshotRecord) { try { restoreSnapshot(snapshotRecord.id, { expectedType: "bot", currentVersion: target.safe_target_version, allowUnsafeMajorRollback: true }); } catch (restoreError) { markRecoveryMarkerFailed(restoreError); } } else if (snapshot) { try { discardSnapshot(snapshot); } catch { // Ignore cleanup failures. } } markRecoveryMarkerFailed(error); writeUpdateState({ last_update_at: new Date().toISOString(), last_update_status: "failed", last_target_kind: "core", last_error: error.message }); emitProgress(publish, "update:failed", { target: "core", error: error.message }); throw error; } }); } async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote = null, version = null, publish } = {}) { return withOperation(`plugin:${pluginId}`, async () => { let status = null; let target = null; let marker = null; let managed = null; let snapshot = null; let snapshotRecord = null; let stage = "checking repository metadata"; try { status = getUpdateStatus({ source, remote }); const statusTarget = status.plugins.find((plugin) => plugin.id === pluginId); if (!statusTarget) throw new Error("Plugin was not found in the local or repository catalog."); target = targetForRequestedVersion(statusTarget, version, "Plugin version"); if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked."); if (!target.update_available) throw new Error("No plugin update target is available."); emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId }); emitProgress(publish, "update:metadata", target); stage = "preparing the selected repository version"; managed = ensureManagedRepo(status.remote, target.source_branch); verifyPluginFiles(pluginId, managed.path, target.safe_target_version); stage = "preparing recovery"; marker = createRecoveryMarker({ target_kind: "plugin", target_id: pluginId, from_version: target.current_version, to_version: target.safe_target_version, source_branch: target.source_branch, update_method: target.installed === false ? "git_install" : "git", rollback_safe: target.rollback_safe, major_crossing: target.major_crossing }); updateRecoveryMarker({ status: "applying" }); stage = "creating the rollback snapshot"; snapshot = await createSnapshot({ type: "plugin", pluginId, metadata: { target_kind: "plugin", target_id: pluginId, from_version: target.current_version, to_version: target.safe_target_version, source_branch: target.source_branch, update_method: target.installed === false ? "git_install" : "git", rollback_safe: target.rollback_safe, recovery_marker_id: marker.id, major_crossing: target.major_crossing, migration_notes: target.migration_notes, danger_notes: target.dangers } }); snapshotRecord = finalizeSnapshot(snapshot); emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotRecord.id }); emitProgress(publish, "update:download", { target: "plugin", plugin_id: pluginId, branch: target.source_branch }); emitProgress(publish, "update:apply", { target: "plugin", plugin_id: pluginId }); stage = "replacing plugin files"; applyPluginFiles(path.join(managed.path, "plugins", pluginId), pluginId, { preserveData: true }); updateRecoveryMarker({ status: "verifying" }); emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId }); stage = "verifying the installed plugin"; verifyPluginFiles(pluginId, repoRoot, target.safe_target_version); syncPluginRegistry(); const record = snapshotRecord; markRecoveryMarkerComplete({ snapshot_id: record.id }); writeUpdateState({ remote: managed.repository, branch: managed.branch, last_update_at: new Date().toISOString(), last_update_status: "complete", last_update_stage: "complete", last_error: null, last_snapshot_id: record.id, last_target_kind: "plugin", last_target_id: pluginId, last_target_version: target.safe_target_version }); emitProgress(publish, "update:restart_required", { target: "plugin", plugin_id: pluginId }); emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id }); return { status: "complete", restart_required: true, snapshot: record, target }; } catch (error) { const originalMessage = error?.message || String(error || "Unknown update error."); error.message = `Plugin update failed while ${stage}: ${originalMessage}`; const recoveryDiagnostics = []; if (snapshotRecord) { try { restoreSnapshot(snapshotRecord.id, { expectedType: "plugin", expectedPluginId: pluginId, currentVersion: target.safe_target_version, allowUnsafeMajorRollback: true }); } catch (restoreError) { recoveryDiagnostics.push(`automatic restore failed: ${restoreError.message}`); } } else if (snapshot) { try { discardSnapshot(snapshot); } catch (discardError) { recoveryDiagnostics.push(`incomplete snapshot cleanup failed: ${discardError.message}`); } } if (marker) { try { markRecoveryMarkerFailed(error); } catch (markerError) { recoveryDiagnostics.push(`recovery marker update failed: ${markerError.message}`); } } if (recoveryDiagnostics.length) error.message += ` Recovery diagnostics: ${recoveryDiagnostics.join("; ")}`; try { writeUpdateState({ last_update_at: new Date().toISOString(), last_update_status: "failed", last_target_kind: "plugin", last_target_id: pluginId, last_target_version: target?.safe_target_version || null, last_update_stage: stage, last_error: error.message }); } catch (stateError) { error.message += ` Update-state diagnostics could not be saved: ${stateError.message}`; } emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message }); throw error; } }); } async function revertCoreSnapshot(snapshotId, { currentVersion, publish } = {}) { return withOperation("core:revert", async () => { emitProgress(publish, "update:revert", { target: "core", snapshot_id: snapshotId }); const entry = restoreSnapshot(snapshotId, { expectedType: "bot", currentVersion }); return { status: "reverted", restart_required: true, snapshot: entry }; }); } async function revertPluginSnapshot(pluginId, snapshotId, { currentVersion, publish } = {}) { return withOperation(`plugin:${pluginId}:revert`, async () => { emitProgress(publish, "update:revert", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotId }); const entry = restoreSnapshot(snapshotId, { expectedType: "plugin", expectedPluginId: pluginId, currentVersion }); syncPluginRegistry(); return { status: "reverted", restart_required: true, snapshot: entry }; }); } function disablePluginForRecovery(pluginId, publish) { setPluginEnabled(pluginId, false); emitProgress(publish, "recovery:plugin_disabled", { plugin_id: pluginId }); return { status: "disabled", plugin_id: pluginId }; } module.exports = { applyCoreUpdate, applyPluginUpdateFromRepo, targetForRequestedVersion, revertCoreSnapshot, revertPluginSnapshot, disablePluginForRecovery };