296 lines
11 KiB
JavaScript
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
|
|
};
|