Lumi/src/services/repo-update.js
2026-06-17 12:08:35 +02:00

296 lines
11 KiB
JavaScript

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
};