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