Lumi/src/services/repo-update.js
2026-07-18 16:39:02 +02:00

367 lines
15 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 { 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
};