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 { 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() { for (const file of ["package.json", "src/main.js", "src/web/server.js"]) { if (!fs.existsSync(path.join(repoRoot, file))) { throw new Error(`Core verification failed: ${file} is missing.`); } } } function verifyPluginFiles(pluginId) { const manifest = path.join(repoRoot, "plugins", pluginId, "plugin.json"); if (!fs.existsSync(manifest)) { throw new Error(`Plugin verification failed: ${pluginId}/plugin.json is missing.`); } JSON.parse(fs.readFileSync(manifest, "utf8")); } function applyPluginFromRepositorySnapshot(remote, branch, pluginId) { const managed = ensureManagedRepo(remote, branch); const pluginRoot = path.join(managed.path, "plugins", pluginId); if (!fs.existsSync(path.join(pluginRoot, "plugin.json"))) { throw new Error(`Plugin ${pluginId} was not found in ${branch}.`); } applyPluginFiles(pluginRoot, pluginId, { preserveData: true }); return managed; } async function applyCoreUpdate({ source = "stable", remote = null, publish } = {}) { return withOperation("core", async () => { const status = getUpdateStatus({ source, remote }); const target = status.core; 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); emitProgress(publish, "update:apply", { target: "core" }); applyCoreFilesFromDirectory(managed.path); updateRecoveryMarker({ status: "verifying" }); emitProgress(publish, "update:verify", { target: "core" }); verifyCoreFiles(); 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_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, publish } = {}) { return withOperation(`plugin:${pluginId}`, async () => { const status = getUpdateStatus({ source, remote }); const target = status.plugins.find((plugin) => plugin.id === pluginId); if (!target) throw new Error("Plugin is not installed."); if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked."); if (!target.update_available) throw new Error("No safe plugin update target is available."); const 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 }); let snapshot = null; let snapshotRecord = null; try { emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId }); emitProgress(publish, "update:metadata", target); updateRecoveryMarker({ status: "applying" }); 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 }); const managed = applyPluginFromRepositorySnapshot(status.remote, target.source_branch, pluginId); updateRecoveryMarker({ status: "verifying" }); emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId }); verifyPluginFiles(pluginId); 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_snapshot_id: record.id, last_target_kind: "plugin", last_target_id: pluginId, last_target_version: target.safe_target_version }); emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id }); return { status: "complete", restart_required: false, snapshot: record, target }; } catch (error) { if (snapshotRecord) { try { restoreSnapshot(snapshotRecord.id, { expectedType: "plugin", expectedPluginId: pluginId, 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: "plugin", last_target_id: pluginId, last_error: error.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, revertCoreSnapshot, revertPluginSnapshot, disablePluginForRecovery };