diff --git a/README.md b/README.md index c3f3b98..d72e351 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,19 @@ You can set these in `.env` or change role IDs in **Admin → Settings**. Use **Admin → Plugins** to install, enable, update, or uninstall plugins. You can also create a local plugin from the WebUI. +## Updates and recovery + +Use **Admin → Updates** for version-aware core and plugin updates. Lumi reads +repo metadata from `main` by default, can explicitly target the newest +`experimental-*` branch, creates snapshots before updates, blocks unsafe major +jumps without compatibility bridge metadata, and keeps advanced ZIP updates +hidden behind manual reveal controls. + +Recovery mode can be started with `LUMI_SAFE_MODE=1 npm run run`, +`node run.js --safe-mode`, or `data/recovery/safe-mode.flag`. See +[`docs/updates.md`](docs/updates.md) and +[`docs/recovery-mode.md`](docs/recovery-mode.md). + ## Twitch bot Configure Twitch chat settings in **Admin → Settings**: diff --git a/docs/lumi-ui.md b/docs/lumi-ui.md index d6452ea..a2b0190 100644 --- a/docs/lumi-ui.md +++ b/docs/lumi-ui.md @@ -189,11 +189,21 @@ reloads the same `/admin/logs` route with query parameters. ## Updates And Local-Only Files -Admin update and ZIP upload controls use the same state-button and -input-action-row patterns as other Lumi actions. Git update actions have -contextual confirmation copy because they can restart the process. ZIP update -forms still submit to the existing `/admin/updates/bot` and -`/admin/updates/plugin` routes and keep the existing snapshot behavior. +Admin update controls use the same progressive action patterns as other Lumi +actions. `/admin/updates` is split into Core and Plugins expandable sections +with collapsed summaries, warning/danger badges, safe target versions, changelog +ranges, migration notes, recovery state, snapshot/revert availability, and live +progress from `GET /admin/updates/events`. + +Core update success shows a five-second in-page notice before refresh/restart. +Plugin update success logs progress for the affected plugin row without forcing +a whole-page refresh. ZIP update controls remain available but are hidden under +advanced/manual reveal sections because ZIPs may bypass repository metadata and +compatibility checks. + +See [Update system](updates.md) and [Recovery mode](recovery-mode.md) for the +semver policy, compatibility bridge behavior, snapshots, revert limits, recovery +markers, and manual safe-mode triggers. The repository ignores local-only coordination and credential artifacts such as `codex-guidelines`, `Twitch.png`, and `twitch-credentials-lumi.png`. Plugin diff --git a/docs/recovery-mode.md b/docs/recovery-mode.md new file mode 100644 index 0000000..5e8f0d3 --- /dev/null +++ b/docs/recovery-mode.md @@ -0,0 +1,54 @@ +# Lumi Recovery Mode + +Failed updates should leave an administrator with a recovery path. Lumi writes a +recovery marker before update files are applied and keeps snapshots available for +manual revert. + +## Recovery Marker + +The marker lives at `data/recovery/update-marker.json` and records: + +- target kind and id +- from/to versions +- source branch +- update method +- rollback safety +- snapshot id when available +- status and timestamps + +Statuses include `pending`, `applying`, `verifying`, `completed`, `failed`, and +`stale`. A completed marker is cleared after a successful normal startup. If +Lumi starts and finds an incomplete marker, it marks it stale so the admin UI and +safe-mode UI can show the last attempted update. + +## Manual Safe Mode + +Safe mode can be started with any of these triggers: + +```bash +LUMI_SAFE_MODE=1 npm run run +node run.js --safe-mode +``` + +Creating `data/recovery/safe-mode.flag` also makes the wrapper start +`safe-mode.js` instead of the full bot. Safe mode loads only the minimum services +needed for recovery: config/database, auth/session, static recovery UI, +snapshots/revert, plugin disable, and restart controls. Optional plugins, +platform clients, AI runtime, scheduled jobs, and non-essential integrations are +not loaded. + +## Admin Recovery UI + +The normal **Admin > Updates** page shows a recovery banner when a marker is +present. The standalone safe-mode page shows the last attempted target, versions, +method, source branch, snapshot id, timestamp, and error. + +Admins can: + +- revert a safe snapshot, +- disable a problematic plugin, +- clear a stale marker after verifying startup, +- retry normal startup. + +Rollback is never automatic. Major-version rollback remains blocked unless the +snapshot is explicitly marked rollback safe. diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..b57340c --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,75 @@ +# Lumi Updates + +Lumi updates are managed from **Admin > Updates**. The page separates Core and +Plugins into expandable sections and uses repository metadata to choose a safe +target before any files are changed. + +## Version Policy + +Core and plugin versions use `major.minor.patch`. + +- Patch: fixes only. +- Minor: non-breaking features or design changes. +- Major: potentially incompatible changes. + +The UI tracks three values separately: current version, latest available +version, and safe target version. Same-major updates can target the latest +version. Major crossings are blocked unless metadata exposes a compatibility +bridge, usually `X.0.0`, or another version marked with +`migration_kind: "compatibility_bridge"` and compatible with the current +install. + +If the latest version is unsafe, the UI shows the safe target as the actionable +version and lists why the absolute latest is not selected. + +## Metadata + +The updater looks for these files on the source branch: + +- `update-manifest.json` or `lumi.manifest.json` +- `CHANGELOG.md` or `changelog.json` +- `plugins//plugin.json` +- `plugins//CHANGELOG.md` or `changelog.json` + +Useful metadata fields include `version`, `channel`, `compatible_from`, +`migration_kind`, `migration_notes`, `rollback_safe`, `requirements`, `size`, +and changelog entries. Missing changelog data is shown as a warning. + +Stable checks read `main` by default. The newest `experimental-*` branch is used +only when explicitly selected on the updates page. + +## Snapshots And Revert + +Before a core, plugin, repo, or ZIP update, Lumi creates a snapshot under +`data/snapshots/` and records metadata in the snapshot index: + +- target kind/id +- from/to versions +- source branch +- update method +- rollback safety +- recovery marker id +- major-crossing flag + +Manual revert is available for core and individual plugins. Revert actions are +limited to the previous-version snapshot for that target. Major-version rollback +is blocked unless the snapshot or manifest explicitly marks rollback as safe. + +## ZIP Fallback + +Core and plugin ZIP updates remain available, but they are hidden under +**Show advanced ZIP update options**. ZIP updates create snapshots and recovery +markers. They may bypass repo metadata and compatibility checks unless the ZIP +contains valid manifest data, so use them as a manual fallback. + +## Progress Events + +Admin update actions publish Server-Sent Events through +`GET /admin/updates/events`. Event names include `update:checking`, +`update:metadata`, `update:snapshot`, `update:recovery_marker`, +`update:download`, `update:apply`, `update:verify`, `update:complete`, +`update:failed`, and `update:revert`. + +Core update success returns a five-second in-page notice before refresh/restart. +Plugin update success updates progress for the affected plugin action without a +whole-page refresh. diff --git a/run.js b/run.js index 010ad66..4964a52 100644 --- a/run.js +++ b/run.js @@ -6,6 +6,7 @@ const safeModeEntry = path.join(__dirname, "safe-mode.js"); const maxRestarts = Number(process.env.MAX_RESTARTS || 25); const restartDelayMs = Number(process.env.RESTART_DELAY_MS || 1500); const restartCodes = new Set([10, 100]); +const safeModeFlag = path.join(__dirname, "data", "recovery", "safe-mode.flag"); let restarts = 0; let safeModeStarted = false; @@ -62,4 +63,12 @@ function startChild() { }); } -startChild(); +if ( + process.env.LUMI_SAFE_MODE === "1" || + process.argv.includes("--safe-mode") || + require("fs").existsSync(safeModeFlag) +) { + startSafeMode(); +} else { + startChild(); +} diff --git a/safe-mode.js b/safe-mode.js index 8ee7e35..aa734e5 100644 --- a/safe-mode.js +++ b/safe-mode.js @@ -14,6 +14,11 @@ const { const { getRoleFlags, hasAccess } = require("./src/services/rbac"); const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager"); const { requestRestart } = require("./src/services/updater"); +const { + safeModeStatus, + clearRecoveryMarker +} = require("./src/services/recovery-mode"); +const { setPluginEnabled } = require("./src/services/plugins"); function ensureSessionSecret() { let secret = getSetting("session_secret"); @@ -45,8 +50,13 @@ function renderPage(title, content) { main { padding: 24px 28px; max-width: 900px; margin: 0 auto; } .card { background: #fff; border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; box-shadow: 0 10px 25px rgba(20, 24, 30, 0.08); } .button { background: #0f6a78; color: #fff; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; text-decoration: none; display: inline-block; } + .button.subtle { background: #eef5f6; color: #173238; } .button.danger { background: #c24b3b; } .muted { color: #5a616a; } + .warning { border-left: 4px solid #d28a24; } + .danger-card { border-left: 4px solid #c24b3b; } + form { display: inline-block; margin: 4px 6px 4px 0; } + code { background: #f1ede7; padding: 2px 5px; border-radius: 5px; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; } @@ -99,6 +109,47 @@ function buildSnapshotTable(snapshots) { `; } +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function buildRecoveryPanel() { + const status = safeModeStatus(); + const marker = status.marker; + if (!marker) { + return `

Recovery state

No recovery marker is present.

`; + } + const pluginActions = marker.target_kind === "plugin" && marker.target_id + ? `
` + : ""; + return ` +
+

Last update marker

+

Status: ${escapeHtml(marker.status)}

+ + + + + + + + + + +
Target${escapeHtml(marker.target_kind || "unknown")} ${escapeHtml(marker.target_id || "")}
Version${escapeHtml(marker.from_version || "?")} -> ${escapeHtml(marker.to_version || "?")}
Method${escapeHtml(marker.update_method || "?")}
Source branch${escapeHtml(marker.source_branch || "?")}
Snapshot${escapeHtml(marker.snapshot_id || marker.recovery_marker_id || "pending")}
Updated${escapeHtml(marker.updated_at || marker.created_at || "?")}
Error${escapeHtml(marker.error || "")}
+
+ ${pluginActions} +
+
+
+
+ `; +} + const app = express(); const sessionStore = new BetterSqlite3Store({ client: db }); app.use( @@ -141,7 +192,7 @@ app.get("/", (req, res) => { res.send( renderPage( "Safe Mode", - `

Rollback snapshots

Use these snapshots to roll back failed updates. The server will restart after rollback.

${table}
` + `${buildRecoveryPanel()}

Rollback snapshots

Use these snapshots to roll back failed updates. Major-version rollback is blocked unless the snapshot is marked rollback safe. The server will restart after rollback.

${table}
` ) ); }); @@ -210,6 +261,30 @@ app.post("/rollback/:id", (req, res) => { } }); +app.post("/plugins/:id/disable", (req, res) => { + if (!req.session.user || !hasAccess(req.session.user, "admin")) { + return res.status(403).send(renderPage("Safe Mode", "
Access denied.
")); + } + setPluginEnabled(req.params.id, false); + res.redirect("/"); +}); + +app.post("/recovery/clear-marker", (req, res) => { + if (!req.session.user || !hasAccess(req.session.user, "admin")) { + return res.status(403).send(renderPage("Safe Mode", "
Access denied.
")); + } + clearRecoveryMarker(); + res.redirect("/"); +}); + +app.post("/recovery/retry-normal-startup", (req, res) => { + if (!req.session.user || !hasAccess(req.session.user, "admin")) { + return res.status(403).send(renderPage("Safe Mode", "
Access denied.
")); + } + res.send(renderPage("Safe Mode", "

Retrying normal startup

Safe mode is exiting. The wrapper will restart Lumi normally.

")); + requestRestart(); +}); + const port = Number(process.env.SAFE_MODE_PORT || 3001); app.listen(port, () => { console.log(`Safe mode listening on http://localhost:${port}`); diff --git a/src/main.js b/src/main.js index 76afdc1..bb5a518 100644 --- a/src/main.js +++ b/src/main.js @@ -15,11 +15,20 @@ const { createCommandRouter } = require("./services/command-router"); const { registerTopCommand } = require("./services/top"); const logger = require("./services/logger"); const { isPlatformEnabled } = require("./services/platforms"); +const { + isSafeModeRequested, + markStartupVerification +} = require("./services/recovery-mode"); async function main() { migrate(); ensureDefaults(); logger.hookConsole(); + const safeModeRequested = isSafeModeRequested(); + const startupMarker = markStartupVerification(); + if (startupMarker?.status === "stale") { + console.warn("Recovery marker detected from incomplete update; start with LUMI_SAFE_MODE=1 for recovery tools."); + } const settingsApi = { getSetting, setSetting }; const commandRouter = createCommandRouter({ settings: settingsApi }); @@ -28,7 +37,7 @@ async function main() { let twitchClient = null; let youtubeClient = null; - if (isPlatformEnabled("discord")) { + if (!safeModeRequested && isPlatformEnabled("discord")) { try { discordClient = await startBot({ commandRouter }); } catch (error) { @@ -36,7 +45,7 @@ async function main() { } } - if (isPlatformEnabled("twitch")) { + if (!safeModeRequested && isPlatformEnabled("twitch")) { try { twitchClient = await startTwitchBot({ commandRouter }); } catch (error) { @@ -44,7 +53,7 @@ async function main() { } } - if (isPlatformEnabled("youtube")) { + if (!safeModeRequested && isPlatformEnabled("youtube")) { try { youtubeClient = await startYouTubeBot({ commandRouter }); } catch (error) { @@ -55,6 +64,7 @@ async function main() { const app = createWebServer({ discordClient, loadPlugins: (appInstance, web, webhooks) => { + if (safeModeRequested) return; loadEnabled({ app: appInstance, discordClient, @@ -75,7 +85,7 @@ async function main() { const autoUpdateEnabled = getSetting("auto_update_enabled", false); const intervalMinutes = getSetting("auto_update_interval_minutes", 60); - if (autoUpdateEnabled) { + if (!safeModeRequested && autoUpdateEnabled) { const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000; setInterval(() => { try { diff --git a/src/services/recovery-mode.js b/src/services/recovery-mode.js new file mode 100644 index 0000000..021ef07 --- /dev/null +++ b/src/services/recovery-mode.js @@ -0,0 +1,132 @@ +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const repoRoot = path.join(__dirname, "..", ".."); +const recoveryDir = path.join(repoRoot, "data", "recovery"); +const markerPath = path.join(recoveryDir, "update-marker.json"); +const safeModeFlagPath = path.join(recoveryDir, "safe-mode.flag"); + +function ensureRecoveryDir() { + fs.mkdirSync(recoveryDir, { recursive: true }); +} + +function readRecoveryMarker() { + try { + if (!fs.existsSync(markerPath)) return null; + const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")); + return marker && typeof marker === "object" ? marker : null; + } catch { + return { + id: "unreadable", + status: "failed", + error: "Recovery marker could not be parsed.", + marker_path: markerPath + }; + } +} + +function writeRecoveryMarker(marker) { + ensureRecoveryDir(); + const next = { + id: marker.id || crypto.randomUUID(), + status: marker.status || "pending", + updated_at: new Date().toISOString(), + ...marker + }; + fs.writeFileSync(markerPath, JSON.stringify(next, null, 2), "utf8"); + return next; +} + +function createRecoveryMarker(details) { + return writeRecoveryMarker({ + id: crypto.randomUUID(), + status: "pending", + created_at: new Date().toISOString(), + ...details + }); +} + +function updateRecoveryMarker(values) { + const current = readRecoveryMarker() || {}; + return writeRecoveryMarker({ ...current, ...values }); +} + +function markRecoveryMarkerFailed(error) { + return updateRecoveryMarker({ + status: "failed", + error: error?.message || String(error || "Update failed.") + }); +} + +function markRecoveryMarkerComplete(values = {}) { + return updateRecoveryMarker({ + ...values, + status: "completed", + completed_at: new Date().toISOString() + }); +} + +function clearRecoveryMarker() { + try { + fs.rmSync(markerPath, { force: true }); + } catch { + // ignore cleanup failures + } +} + +function markStartupVerification() { + const marker = readRecoveryMarker(); + if (!marker) return null; + if (marker.status === "completed") { + clearRecoveryMarker(); + return { ...marker, cleared: true }; + } + if (["pending", "applying", "verifying"].includes(marker.status)) { + return writeRecoveryMarker({ + ...marker, + status: "stale", + stale_at: new Date().toISOString(), + error: marker.error || "Previous update did not complete startup verification." + }); + } + return marker; +} + +function isSafeModeRequested(argv = process.argv, env = process.env) { + return env.LUMI_SAFE_MODE === "1" || + env.SAFE_MODE === "1" || + argv.includes("--safe-mode") || + fs.existsSync(safeModeFlagPath); +} + +function safeModeStatus() { + const marker = readRecoveryMarker(); + const requested = isSafeModeRequested(); + const incomplete = marker && ["pending", "applying", "verifying", "failed", "stale"].includes(marker.status); + return { + requested, + active: process.env.SAFE_MODE === "1" || process.env.LUMI_SAFE_MODE === "1", + marker, + has_incomplete_marker: Boolean(incomplete), + safe_mode_flag: fs.existsSync(safeModeFlagPath), + marker_path: markerPath, + flag_path: safeModeFlagPath + }; +} + +module.exports = { + recoveryDir, + markerPath, + safeModeFlagPath, + readRecoveryMarker, + writeRecoveryMarker, + createRecoveryMarker, + updateRecoveryMarker, + markRecoveryMarkerFailed, + markRecoveryMarkerComplete, + clearRecoveryMarker, + markStartupVerification, + isSafeModeRequested, + safeModeStatus +}; diff --git a/src/services/repo-update.js b/src/services/repo-update.js new file mode 100644 index 0000000..7a78c9e --- /dev/null +++ b/src/services/repo-update.js @@ -0,0 +1,223 @@ +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const { syncPluginRegistry, setPluginEnabled } = require("./plugins"); +const { + createSnapshot, + finalizeSnapshot, + discardSnapshot, + restoreSnapshot +} = require("./update-manager"); +const { getUpdateStatus } = require("./update-index"); +const { + createRecoveryMarker, + updateRecoveryMarker, + markRecoveryMarkerFailed, + markRecoveryMarkerComplete +} = require("./recovery-mode"); + +const repoRoot = path.join(__dirname, "..", ".."); +const activeOperations = new Set(); + +function runGit(args) { + const result = spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8" + }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Git command failed.").trim()); + } + return result.stdout.trim(); +} + +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")); +} + +async function applyCoreUpdate({ source = "stable", publish } = {}) { + return withOperation("core", async () => { + const status = getUpdateStatus({ source }); + 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; + 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 + } + }); + emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshot.id }); + emitProgress(publish, "update:recovery_marker", { target: "core", marker_id: marker.id }); + emitProgress(publish, "update:download", { target: "core", branch: target.source_branch }); + runGit(["fetch", status.remote, target.source_branch]); + emitProgress(publish, "update:apply", { target: "core" }); + runGit(["pull", "--ff-only", status.remote, target.source_branch]); + updateRecoveryMarker({ status: "verifying" }); + emitProgress(publish, "update:verify", { target: "core" }); + verifyCoreFiles(); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + 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 (snapshot) discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); + emitProgress(publish, "update:failed", { target: "core", error: error.message }); + throw error; + } + }); +} + +async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) { + return withOperation(`plugin:${pluginId}`, async () => { + const status = getUpdateStatus({ source }); + 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: "git", + rollback_safe: target.rollback_safe, + major_crossing: target.major_crossing + }); + let snapshot = 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: "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 + } + }); + emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshot.id }); + emitProgress(publish, "update:download", { target: "plugin", plugin_id: pluginId, branch: target.source_branch }); + runGit(["fetch", status.remote, target.source_branch]); + emitProgress(publish, "update:apply", { target: "plugin", plugin_id: pluginId }); + runGit(["checkout", `${status.remote}/${target.source_branch}`, "--", `plugins/${pluginId}`]); + updateRecoveryMarker({ status: "verifying" }); + emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId }); + verifyPluginFiles(pluginId); + syncPluginRegistry(); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + 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 (snapshot) discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); + 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 +}; diff --git a/src/services/update-index.js b/src/services/update-index.js new file mode 100644 index 0000000..48fb532 --- /dev/null +++ b/src/services/update-index.js @@ -0,0 +1,261 @@ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); +const { getSetting } = require("./settings"); +const { scanPluginDirectories, getPlugins } = require("./plugins"); +const { + compareSemver, + findSafeTarget, + collectChangelogRange, + normalizeVersions +} = require("./versioning"); +const { listSnapshots } = require("./update-manager"); +const { safeModeStatus } = require("./recovery-mode"); + +const repoRoot = path.join(__dirname, "..", ".."); + +function runGit(args, options = {}) { + const result = spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + timeout: options.timeout || 20000 + }); + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout || "Git command failed.").trim()); + } + return result.stdout.trim(); +} + +function tryGit(args, fallback = "") { + try { + return runGit(args); + } catch { + return fallback; + } +} + +function fetchRemote(remote) { + runGit(["fetch", "--prune", remote]); +} + +function remoteRef(remote, branch) { + return `${remote}/${branch}`; +} + +function readGitFile(ref, filePath) { + const output = tryGit(["show", `${ref}:${filePath}`], ""); + return output || null; +} + +function readJsonGitFile(ref, filePath) { + const raw = readGitFile(ref, filePath); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function readLocalJson(filePath) { + try { + return JSON.parse(fs.readFileSync(path.join(repoRoot, filePath), "utf8")); + } catch { + return null; + } +} + +function resolveSourceBranch(remote, requested = "stable") { + if (requested === "experimental") { + const refs = tryGit([ + "for-each-ref", + "--format=%(refname:short)|%(committerdate:iso8601)", + `refs/remotes/${remote}/experimental-*` + ], ""); + const branches = refs + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [ref, date] = line.split("|"); + return { branch: ref.replace(`${remote}/`, ""), date }; + }) + .sort((a, b) => String(b.date).localeCompare(String(a.date))); + if (branches[0]) return branches[0].branch; + } + if (requested && requested !== "stable" && requested !== "main") { + return String(requested).replace(/^origin\//, ""); + } + return "main"; +} + +function parseMarkdownChangelog(raw) { + if (!raw) return []; + const entries = []; + const lines = raw.split(/\r?\n/); + let current = null; + for (const line of lines) { + const heading = line.match(/^#{1,3}\s+\[?v?(\d+\.\d+\.\d+)\]?(.+)?$/i); + if (heading) { + if (current) entries.push(current); + current = { version: heading[1], title: line.replace(/^#+\s+/, ""), changes: [] }; + } else if (current && line.trim()) { + current.changes.push(line.trim().replace(/^[-*]\s*/, "")); + } + } + if (current) entries.push(current); + return entries; +} + +function changelogEntries(ref, basePath = "") { + const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json")); + if (Array.isArray(json)) return json; + if (Array.isArray(json?.versions)) return json.versions; + const md = readGitFile(ref, path.posix.join(basePath, "CHANGELOG.md")); + return parseMarkdownChangelog(md); +} + +function manifestVersions(manifest, fallbackVersion) { + const entries = []; + if (Array.isArray(manifest?.versions)) entries.push(...manifest.versions); + if (manifest?.version) entries.push({ ...manifest, version: manifest.version }); + if (fallbackVersion) entries.push({ version: fallbackVersion }); + return normalizeVersions(entries); +} + +function coreManifest(ref) { + return readJsonGitFile(ref, "update-manifest.json") || + readJsonGitFile(ref, "lumi.manifest.json") || + readJsonGitFile(ref, "package.json") || + {}; +} + +function localCoreVersion() { + return readLocalJson("package.json")?.version || "0.0.0"; +} + +function latestEntry(entries) { + return entries.length ? entries[entries.length - 1] : null; +} + +function snapshotAvailability(kind, id = null) { + const snapshots = listSnapshots() + .filter((snap) => snap.type === kind || (kind === "core" && snap.type === "bot")) + .filter((snap) => !id || snap.pluginId === id) + .sort((a, b) => b.createdAt - a.createdAt); + const latest = snapshots[0] || null; + return { + available: Boolean(latest), + latest_snapshot_id: latest?.id || null, + rollback_safe: latest?.rollback_safe !== false, + latest + }; +} + +function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) { + const versions = manifestVersions(manifest, manifest?.version); + const latest = latestEntry(versions); + const targetResult = findSafeTarget(currentVersion, versions); + const target = targetResult.target; + const range = target ? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions) : []; + const warnings = []; + const dangers = []; + if (!changelog.length) warnings.push("Changelog metadata is missing."); + if (targetResult.warning) warnings.push(targetResult.warning); + if (targetResult.blocked) dangers.push(targetResult.reason); + if (target?.rollback_safe === false) warnings.push("Target metadata marks rollback as unsafe after migration."); + return { + kind, + id: id || kind, + name: name || manifest?.name || id || "Lumi core", + current_version: currentVersion, + latest_available_version: latest?.version || currentVersion, + safe_target_version: target?.version || null, + update_available: Boolean(target && compareSemver(target.version, currentVersion) > 0), + blocked: Boolean(targetResult.blocked), + blocked_reason: targetResult.reason || null, + source_branch: sourceBranch, + channel: manifest?.channel || channel || "stable", + version_description: target + ? `${currentVersion} -> ${target.version}` + : targetResult.reason || "No safe update target available.", + changelog_range: range, + size_delta: target?.size || manifest?.size || null, + size_delta_label: target?.size || manifest?.size ? String(target.size || manifest.size) : "unknown", + warnings, + dangers, + requirements: target?.requirements || manifest?.requirements || [], + migration_notes: target?.migration_notes || manifest?.migration_notes || "", + rollback_safe: target?.rollback_safe !== false, + major_crossing: target ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false, + snapshot: snapshotAvailability(kind === "plugin" ? "plugin" : "bot", id), + raw_target: target || null + }; +} + +function remotePluginDirs(ref) { + const output = tryGit(["ls-tree", "-d", "--name-only", `${ref}:plugins`], ""); + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((item) => item.replace(/^plugins\//, "")); +} + +function getUpdateStatus(options = {}) { + const remote = options.remote || getSetting("git_remote", "origin"); + const requestedSource = options.source || "stable"; + fetchRemote(remote); + const sourceBranch = resolveSourceBranch(remote, requestedSource); + const ref = remoteRef(remote, sourceBranch); + const core = buildStatus({ + kind: "core", + currentVersion: localCoreVersion(), + manifest: coreManifest(ref), + changelog: changelogEntries(ref), + sourceBranch, + channel: requestedSource === "experimental" ? "experimental" : "stable" + }); + const installed = scanPluginDirectories(); + const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin])); + const remoteDirs = new Set(remotePluginDirs(ref)); + const plugins = installed.map((plugin) => { + const basePath = `plugins/${plugin.id}`; + const manifest = readJsonGitFile(ref, `${basePath}/plugin.json`) || {}; + const changelog = changelogEntries(ref, basePath); + const dbPlugin = registry.get(plugin.id); + return buildStatus({ + kind: "plugin", + id: plugin.id, + name: plugin.name, + currentVersion: dbPlugin?.version || plugin.version || "0.0.0", + manifest: remoteDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name }, + changelog, + sourceBranch, + channel: requestedSource === "experimental" ? "experimental" : "stable" + }); + }); + return { + generated_at: new Date().toISOString(), + source_branch: sourceBranch, + requested_source: requestedSource, + remote, + core, + plugins, + plugins_summary: { + installed_plugins: installed.length, + total_plugins: remoteDirs.size || installed.length, + updatable_plugins: plugins.filter((plugin) => plugin.update_available).length, + blocked_plugins: plugins.filter((plugin) => plugin.blocked).length + }, + recovery: safeModeStatus() + }; +} + +module.exports = { + getUpdateStatus, + resolveSourceBranch, + fetchRemote, + runGit, + readGitFile +}; diff --git a/src/services/update-manager.js b/src/services/update-manager.js index 1d03142..b224c1a 100644 --- a/src/services/update-manager.js +++ b/src/services/update-manager.js @@ -9,6 +9,12 @@ try { AdmZip = null; } const { db } = require("./db"); +const { + createRecoveryMarker, + updateRecoveryMarker, + markRecoveryMarkerFailed, + markRecoveryMarkerComplete +} = require("./recovery-mode"); const repoRoot = path.join(__dirname, "..", ".."); const dataDir = path.join(repoRoot, "data"); @@ -49,7 +55,7 @@ async function backupDatabase(targetPath) { } } -async function createSnapshot({ type, pluginId }) { +async function createSnapshot({ type, pluginId, metadata = {} }) { ensureSnapshotsDir(); const id = `${Date.now()}-${crypto.randomUUID()}`; const snapshotPath = path.join(snapshotsDir, id); @@ -77,7 +83,7 @@ async function createSnapshot({ type, pluginId }) { } } - return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath }; + return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath, metadata }; } function finalizeSnapshot(snapshot) { @@ -89,7 +95,18 @@ function finalizeSnapshot(snapshot) { pluginExisted: snapshot.pluginExisted || false, createdAt: Date.now(), status: "available", - path: snapshot.snapshotPath + path: snapshot.snapshotPath, + target_kind: snapshot.metadata?.target_kind || snapshot.type, + target_id: snapshot.metadata?.target_id || snapshot.pluginId || null, + from_version: snapshot.metadata?.from_version || null, + to_version: snapshot.metadata?.to_version || null, + source_branch: snapshot.metadata?.source_branch || null, + update_method: snapshot.metadata?.update_method || null, + rollback_safe: snapshot.metadata?.rollback_safe !== false, + recovery_marker_id: snapshot.metadata?.recovery_marker_id || null, + major_crossing: Boolean(snapshot.metadata?.major_crossing), + migration_notes: snapshot.metadata?.migration_notes || "", + danger_notes: snapshot.metadata?.danger_notes || [] }; entries.push(record); saveIndex(pruneEntries(entries)); @@ -384,16 +401,38 @@ async function applyBotUpdate(zipPath, options = {}) { verifyBotPackage(rootPath); } - const snapshot = await createSnapshot({ type: "bot" }); + const marker = createRecoveryMarker({ + target_kind: "core", + target_id: "core", + from_version: options.metadata?.from_version || null, + to_version: options.metadata?.to_version || null, + source_branch: options.metadata?.source_branch || "manual_zip", + update_method: options.mode === "patch" ? "zip_patch" : "zip", + rollback_safe: options.metadata?.rollback_safe !== false, + major_crossing: Boolean(options.metadata?.major_crossing) + }); + updateRecoveryMarker({ status: "applying" }); + const snapshot = await createSnapshot({ + type: "bot", + metadata: { + target_kind: "core", + update_method: options.mode === "patch" ? "zip_patch" : "zip", + ...options.metadata, + recovery_marker_id: marker.id + } + }); try { if (mode === "patch") { applyCorePatch(rootPath); } else { applyCoreUpdate(rootPath); } - return finalizeSnapshot(snapshot); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + return record; } catch (error) { discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); throw error; } } finally { @@ -401,21 +440,48 @@ async function applyBotUpdate(zipPath, options = {}) { } } -async function applyPluginUpdate(zipPath) { +async function applyPluginUpdate(zipPath, options = {}) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-")); try { extractZip(zipPath, tempDir); const rootPath = resolvePluginRoot(tempDir); const manifest = verifyPluginPackage(rootPath); + if (options.expectedPluginId && manifest.id !== options.expectedPluginId) { + throw new Error(`Plugin ZIP id ${manifest.id} does not match ${options.expectedPluginId}.`); + } - const snapshot = await createSnapshot({ type: "plugin", pluginId: manifest.id }); + const marker = createRecoveryMarker({ + target_kind: "plugin", + target_id: manifest.id, + from_version: options.metadata?.from_version || null, + to_version: manifest.version || options.metadata?.to_version || null, + source_branch: options.metadata?.source_branch || "manual_zip", + update_method: "zip", + rollback_safe: options.metadata?.rollback_safe !== false, + major_crossing: Boolean(options.metadata?.major_crossing) + }); + updateRecoveryMarker({ status: "applying" }); + const snapshot = await createSnapshot({ + type: "plugin", + pluginId: manifest.id, + metadata: { + target_kind: "plugin", + target_id: manifest.id, + update_method: "zip", + ...options.metadata, + recovery_marker_id: marker.id + } + }); try { applyPluginFiles(rootPath, manifest.id, { preserveData: snapshot.pluginExisted }); - return finalizeSnapshot(snapshot); + const record = finalizeSnapshot(snapshot); + markRecoveryMarkerComplete({ snapshot_id: record.id }); + return record; } catch (error) { discardSnapshot(snapshot); + markRecoveryMarkerFailed(error); throw error; } } finally { @@ -436,7 +502,7 @@ function restoreDatabase(snapshotPath) { fs.rmSync(shm, { force: true }); } -function restoreSnapshot(id) { +function restoreSnapshot(id, options = {}) { const entries = loadIndex(); const entry = entries.find((item) => item.id === id); if (!entry) { @@ -445,6 +511,18 @@ function restoreSnapshot(id) { if (entry.status !== "available") { throw new Error("Snapshot is no longer available."); } + if (options.expectedType && entry.type !== options.expectedType) { + throw new Error("Snapshot target type does not match this revert action."); + } + if (options.expectedPluginId && entry.pluginId !== options.expectedPluginId) { + throw new Error("Snapshot target plugin does not match this revert action."); + } + if (entry.major_crossing && entry.rollback_safe === false && !options.allowUnsafeMajorRollback) { + throw new Error("This snapshot crossed a major version and is not marked rollback safe."); + } + if (options.currentVersion && entry.to_version && entry.to_version !== options.currentVersion) { + throw new Error("Only the previous version snapshot can be reverted from this action."); + } if (entry.type === "bot") { const coreZip = path.join(entry.path, "core.zip"); @@ -484,6 +562,9 @@ function restoreSnapshot(id) { module.exports = { applyBotUpdate, applyPluginUpdate, + createSnapshot, + finalizeSnapshot, + discardSnapshot, applyPluginFiles, resetPluginCode, replacePluginDirectory, diff --git a/src/services/versioning.js b/src/services/versioning.js new file mode 100644 index 0000000..25358a4 --- /dev/null +++ b/src/services/versioning.js @@ -0,0 +1,118 @@ +function parseSemver(value) { + const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + raw: `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` + }; +} + +function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + if (!a && !b) return 0; + if (!a) return -1; + if (!b) return 1; + for (const key of ["major", "minor", "patch"]) { + if (a[key] > b[key]) return 1; + if (a[key] < b[key]) return -1; + } + return 0; +} + +function isMajorJump(current, target) { + const from = parseSemver(current); + const to = parseSemver(target); + return Boolean(from && to && from.major !== to.major); +} + +function versionInRange(version, from, to) { + return compareSemver(version, from) > 0 && compareSemver(version, to) <= 0; +} + +function normalizeVersionEntry(entry, fallback = {}) { + if (typeof entry === "string") return { version: entry, ...fallback }; + if (!entry || typeof entry !== "object") return null; + const version = entry.version || entry.name || entry.tag; + if (!parseSemver(version)) return null; + return { ...fallback, ...entry, version: parseSemver(version).raw }; +} + +function normalizeVersions(entries, fallback = {}) { + return (Array.isArray(entries) ? entries : []) + .map((entry) => normalizeVersionEntry(entry, fallback)) + .filter(Boolean) + .sort((a, b) => compareSemver(a.version, b.version)); +} + +function compatibleFromAllows(entry, current) { + if (!entry?.compatible_from) return false; + return compareSemver(current, entry.compatible_from) >= 0; +} + +function isBridgeTarget(entry, current) { + const from = parseSemver(current); + const to = parseSemver(entry?.version); + if (!from || !to || from.major === to.major) return false; + if (entry.compatibility_bridge === true || entry.migration_kind === "compatibility_bridge") { + return compatibleFromAllows(entry, current) || !entry.compatible_from; + } + if (to.major === from.major + 1 && to.minor === 0 && to.patch === 0) { + return compatibleFromAllows(entry, current) || entry.compatible_from === undefined; + } + return false; +} + +function findSafeTarget(currentVersion, versionEntries) { + const current = parseSemver(currentVersion); + const versions = normalizeVersions(versionEntries); + if (!current) { + return { + target: null, + blocked: true, + reason: "Current version is not valid semver." + }; + } + const newer = versions.filter((entry) => compareSemver(entry.version, current.raw) > 0); + if (!newer.length) { + return { target: null, blocked: false, reason: "Already up to date." }; + } + const latest = newer[newer.length - 1]; + const sameMajor = newer.filter((entry) => parseSemver(entry.version)?.major === current.major); + if (sameMajor.length) { + return { target: sameMajor[sameMajor.length - 1], latest, blocked: false }; + } + const bridge = newer.find((entry) => isBridgeTarget(entry, current.raw)); + if (bridge) { + return { + target: bridge, + latest, + blocked: false, + warning: `Latest ${latest.version} crosses a major boundary; targeting compatibility bridge ${bridge.version}.` + }; + } + return { + target: null, + latest, + blocked: true, + reason: `Latest ${latest.version} crosses a major boundary and no compatible bridge target was found.` + }; +} + +function collectChangelogRange(currentVersion, targetVersion, changelogEntries) { + if (!targetVersion) return []; + return normalizeVersions(changelogEntries) + .filter((entry) => versionInRange(entry.version, currentVersion, targetVersion)) + .sort((a, b) => compareSemver(b.version, a.version)); +} + +module.exports = { + parseSemver, + compareSemver, + isMajorJump, + normalizeVersions, + findSafeTarget, + collectChangelogRange +}; diff --git a/src/web/public/app.js b/src/web/public/app.js index afc5342..c9d4802 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -562,6 +562,7 @@ const isDestructiveForm = (form, submitter = null) => { if (!form || form.dataset.noDestructiveConfirm !== undefined) return false; + if (form.dataset.updateAction !== undefined) return false; return String(form.method || "get").toLowerCase() === "post" && destructivePattern.test(destructiveAction(form, submitter)); }; @@ -772,6 +773,97 @@ } }); + const updateLog = document.querySelector("[data-update-progress-log]"); + if (updateLog) { + const appendUpdateLog = (message, level = "info") => { + const row = document.createElement("div"); + row.className = `update-progress-entry ${level}`; + row.textContent = message; + updateLog.prepend(row); + }; + try { + const source = new EventSource("/admin/updates/events"); + [ + "update:queued", + "update:checking", + "update:metadata", + "update:snapshot", + "update:recovery_marker", + "update:download", + "update:apply", + "update:verify", + "update:restart_required", + "update:complete", + "update:failed", + "update:revert", + "recovery:plugin_disabled", + "recovery:retry_startup" + ].forEach((eventName) => { + source.addEventListener(eventName, (event) => { + const payload = JSON.parse(event.data || "{}"); + const target = payload.plugin_id ? `plugin ${payload.plugin_id}` : payload.target || "recovery"; + appendUpdateLog(`${eventName.replace("update:", "").replace("recovery:", "recovery ")}: ${target}`, eventName.includes("failed") ? "danger" : "info"); + }); + }); + } catch { + appendUpdateLog("Live update stream is unavailable.", "danger"); + } + + document.querySelectorAll("form[data-update-action]").forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const submitter = event.submitter || form.querySelector("button[type='submit']"); + const confirmed = form.dataset.confirmMode === "modal" + ? await window.LumiConfirm?.destructive?.({ + title: form.dataset.confirmTitle || "Confirm update action", + text: form.dataset.confirmText || "This update action will change local files.", + label: form.dataset.confirmLabel || submitter?.textContent || "Confirm" + }) + : true; + if (!confirmed) { + window.LumiStateButton?.reset?.(submitter); + return; + } + const originalText = submitter?.textContent; + const isStateButton = submitter?.matches?.("[data-lumi-state-button]"); + if (isStateButton) { + window.LumiStateButton?.setState?.(submitter, "loading", { busy: true }); + } else if (submitter) { + submitter.disabled = true; + submitter.textContent = "Working..."; + } + appendUpdateLog(`Started ${submitter?.textContent?.trim() || "update action"}.`); + try { + const response = await fetch(form.action, { + method: form.method || "POST", + body: new FormData(form), + headers: { Accept: "application/json" } + }); + const result = await response.json(); + if (!response.ok || result.ok === false) throw new Error(result.error || "Update action failed."); + if (isStateButton) window.LumiStateButton?.success?.(submitter); + else if (submitter) submitter.textContent = "Done"; + appendUpdateLog(result.message || "Update action completed.", "success"); + if (result.refresh_after_ms) { + appendUpdateLog(`Lumi will refresh in ${Math.round(result.refresh_after_ms / 1000)} seconds.`, "success"); + window.setTimeout(() => window.location.reload(), Number(result.refresh_after_ms)); + } + } catch (error) { + if (isStateButton) window.LumiStateButton?.error?.(submitter); + else if (submitter) submitter.textContent = "Failed"; + appendUpdateLog(error.message, "danger"); + } finally { + if (!isStateButton && submitter) { + window.setTimeout(() => { + submitter.disabled = false; + submitter.textContent = originalText; + }, 2500); + } + } + }); + }); + } + document.querySelectorAll("[data-copy]").forEach((button) => { button.addEventListener("click", async () => { const text = button.getAttribute("data-copy") || ""; diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 6e0c3e2..6c94230 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -469,6 +469,27 @@ input[type="color"] { color: var(--lumi-success); } +.badge.success { + color: var(--lumi-success); + background: color-mix(in srgb, var(--lumi-success) 12%, var(--lumi-surface)); + border: 1px solid color-mix(in srgb, var(--lumi-success) 35%, var(--lumi-border)); + padding: 0 var(--lumi-space-2); +} + +.badge.warning { + color: var(--lumi-warning); + background: color-mix(in srgb, var(--lumi-warning) 12%, var(--lumi-surface)); + border: 1px solid color-mix(in srgb, var(--lumi-warning) 35%, var(--lumi-border)); + padding: 0 var(--lumi-space-2); +} + +.badge.danger { + color: var(--lumi-danger); + background: color-mix(in srgb, var(--lumi-danger) 12%, var(--lumi-surface)); + border: 1px solid color-mix(in srgb, var(--lumi-danger) 35%, var(--lumi-border)); + padding: 0 var(--lumi-space-2); +} + .status-warning { color: var(--lumi-warning); } @@ -711,6 +732,80 @@ input[type="color"] { color: var(--lumi-text-muted); } +.update-recovery-banner { + border-left: 4px solid var(--lumi-danger); +} + +.update-detail-grid { + display: grid; + gap: var(--lumi-space-4); +} + +.update-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: var(--lumi-space-3); +} + +.update-meta-grid > div { + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface-2); +} + +.update-meta-grid span { + display: block; + margin-bottom: var(--lumi-space-1); + color: var(--lumi-text-muted); + font-size: 0.82rem; +} + +.plugin-update-list, +.plugin-update-row { + display: grid; + gap: var(--lumi-space-3); +} + +.update-changelog { + display: grid; + gap: var(--lumi-space-2); + padding-left: 0; + list-style: none; +} + +.update-changelog li { + display: grid; + gap: var(--lumi-space-1); + padding: var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-radius: var(--lumi-radius-md); + background: var(--lumi-surface); +} + +.update-progress-log { + display: grid; + gap: var(--lumi-space-2); + max-height: 18rem; + overflow: auto; +} + +.update-progress-entry { + padding: var(--lumi-space-2) var(--lumi-space-3); + border: 1px solid var(--lumi-border); + border-left: 3px solid var(--lumi-accent); + border-radius: var(--lumi-radius-sm); + background: var(--lumi-surface-2); +} + +.update-progress-entry.success { + border-left-color: var(--lumi-success); +} + +.update-progress-entry.danger { + border-left-color: var(--lumi-danger); +} + .dashboard-metric-grid, .dashboard-chart-grid { display: grid; diff --git a/src/web/server.js b/src/web/server.js index 689c1c1..cad8c8d 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -83,6 +83,19 @@ const { applyPluginUpdate, listSnapshots } = require("../services/update-manager"); +const { getUpdateStatus } = require("../services/update-index"); +const { + applyCoreUpdate, + applyPluginUpdateFromRepo, + revertCoreSnapshot, + revertPluginSnapshot, + disablePluginForRecovery +} = require("../services/repo-update"); +const { + safeModeStatus, + clearRecoveryMarker, + updateRecoveryMarker +} = require("../services/recovery-mode"); const { generateCommandPreview, previewParts @@ -4873,13 +4886,134 @@ function createWebServer({ loadPlugins, discordClient }) { } }); + function wantsJson(req) { + return req.xhr || + req.get("accept")?.includes("application/json") || + req.get("content-type")?.includes("application/json"); + } + + function updateSourceFrom(req) { + const value = req.body?.source || req.query?.source || "stable"; + return value === "experimental" ? "experimental" : String(value || "stable"); + } + + function sendUpdateResult(req, res, result, redirectPath = "/admin/updates") { + if (wantsJson(req)) { + return res.json({ ok: true, ...result }); + } + setFlash(req, "success", result.message || "Update action completed."); + return res.redirect(redirectPath); + } + + function sendUpdateError(req, res, error, redirectPath = "/admin/updates") { + if (wantsJson(req)) { + return res.status(400).json({ ok: false, error: error.message }); + } + setFlash(req, "error", error.message); + return res.redirect(redirectPath); + } + + function scheduleRestartAfterNotice() { + setTimeout(() => requestRestart(), 5000); + } + + app.get("/admin/updates/events", requireRole("admin"), subscribeWebEvents); + + app.get("/admin/updates/status", requireRole("admin"), (req, res) => { + try { + res.json({ ok: true, status: getUpdateStatus({ source: updateSourceFrom(req) }) }); + } catch (error) { + res.status(500).json({ ok: false, error: error.message }); + } + }); + + app.get("/admin/updates/recovery", requireRole("admin"), (req, res) => { + res.json({ ok: true, recovery: safeModeStatus(), snapshots: listSnapshots() }); + }); + + app.post("/admin/updates/recovery/clear-marker", requireRole("admin"), (req, res) => { + clearRecoveryMarker(); + publishWebEvent("recovery:marker_detected", { status: "cleared" }, { role: "admin" }); + sendUpdateResult(req, res, { message: "Recovery marker cleared." }); + }); + + app.post("/admin/updates/recovery/retry-normal-startup", requireRole("admin"), (req, res) => { + updateRecoveryMarker({ status: "retry_startup", retry_at: new Date().toISOString() }); + publishWebEvent("recovery:retry_startup", {}, { role: "admin" }); + sendUpdateResult(req, res, { message: "Retrying normal startup." }); + scheduleRestartAfterNotice(); + }); + app.get("/admin/updates", requireRole("admin"), (req, res) => { + let updateStatus = null; + let updateStatusError = null; + try { + updateStatus = getUpdateStatus({ source: updateSourceFrom(req) }); + } catch (error) { + updateStatusError = error.message; + } res.render("admin-updates", { title: "Updates", - snapshots: listSnapshots() + snapshots: listSnapshots(), + updateStatus, + updateStatusError, + recoveryStatus: safeModeStatus() }); }); + app.post("/admin/updates/core/check", requireRole("admin"), (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + sendUpdateResult(req, res, { + status, + message: status.core.update_available + ? `Safe core target ${status.core.safe_target_version} is available.` + : status.core.blocked + ? status.core.blocked_reason + : "No core updates found." + }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/core/apply", requireRole("admin"), async (req, res) => { + try { + const result = await applyCoreUpdate({ + source: updateSourceFrom(req), + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + refresh_after_ms: 5000, + message: "Core update applied. Lumi will restart after the confirmation notice." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/core/revert", requireRole("admin"), async (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + const snapshotId = req.body.snapshot_id || status.core.snapshot.latest_snapshot_id; + if (!snapshotId) throw new Error("No core snapshot is available to revert."); + const result = await revertCoreSnapshot(snapshotId, { + currentVersion: status.core.current_version, + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + refresh_after_ms: 5000, + message: "Core snapshot reverted. Lumi will restart after the confirmation notice." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + app.post( "/admin/updates/bot", requireRole("admin"), @@ -4918,6 +5052,38 @@ function createWebServer({ loadPlugins, discordClient }) { } ); + app.post( + "/admin/updates/core/zip", + requireRole("admin"), + uploadSingle("update_zip"), + async (req, res) => { + if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError)); + if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive.")); + try { + const patchMode = req.body.patch_mode === "1"; + const snapshot = await applyBotUpdate(req.file.path, { + mode: patchMode ? "patch" : "full", + metadata: { + update_method: patchMode ? "zip_patch" : "zip", + source_branch: "manual_zip", + rollback_safe: req.body.rollback_safe === "1" + } + }); + sendUpdateResult(req, res, { + snapshot, + restart_required: true, + refresh_after_ms: 5000, + message: patchMode ? "Core patch ZIP applied. Restarting..." : "Core ZIP update applied. Restarting..." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } finally { + try { fs.rmSync(req.file?.path, { force: true }); } catch {} + } + } + ); + app.post( "/admin/updates/plugin", requireRole("admin"), @@ -4949,6 +5115,97 @@ function createWebServer({ loadPlugins, discordClient }) { } ); + app.post("/admin/updates/plugins/:id/check", requireRole("admin"), (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + const plugin = status.plugins.find((item) => item.id === req.params.id); + if (!plugin) throw new Error("Plugin not found."); + sendUpdateResult(req, res, { + plugin, + message: plugin.update_available + ? `Safe plugin target ${plugin.safe_target_version} is available.` + : plugin.blocked + ? plugin.blocked_reason + : "No plugin updates found." + }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/plugins/:id/apply", requireRole("admin"), async (req, res) => { + try { + const result = await applyPluginUpdateFromRepo(req.params.id, { + source: updateSourceFrom(req), + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + message: "Plugin update applied." + }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/plugins/:id/revert", requireRole("admin"), async (req, res) => { + try { + const status = getUpdateStatus({ source: updateSourceFrom(req) }); + const plugin = status.plugins.find((item) => item.id === req.params.id); + if (!plugin) throw new Error("Plugin not found."); + const snapshotId = req.body.snapshot_id || plugin.snapshot.latest_snapshot_id; + if (!snapshotId) throw new Error("No plugin snapshot is available to revert."); + const result = await revertPluginSnapshot(req.params.id, snapshotId, { + currentVersion: plugin.current_version, + publish: publishWebEvent + }); + sendUpdateResult(req, res, { + ...result, + message: "Plugin snapshot reverted. Restarting..." + }); + scheduleRestartAfterNotice(); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post("/admin/updates/plugins/:id/disable", requireRole("admin"), (req, res) => { + try { + const result = disablePluginForRecovery(req.params.id, publishWebEvent); + sendUpdateResult(req, res, { ...result, message: "Plugin disabled for recovery." }); + } catch (error) { + sendUpdateError(req, res, error); + } + }); + + app.post( + "/admin/updates/plugins/:id/zip", + requireRole("admin"), + uploadSingle("plugin_zip"), + async (req, res) => { + if (req.uploadError) return sendUpdateError(req, res, new Error(req.uploadError)); + if (!req.file) return sendUpdateError(req, res, new Error("Upload a ZIP archive.")); + try { + const snapshot = await applyPluginUpdate(req.file.path, { + expectedPluginId: req.params.id, + metadata: { + target_id: req.params.id, + source_branch: "manual_zip", + rollback_safe: req.body.rollback_safe === "1" + } + }); + sendUpdateResult(req, res, { + snapshot, + message: "Plugin ZIP update applied." + }); + } catch (error) { + sendUpdateError(req, res, error); + } finally { + try { fs.rmSync(req.file?.path, { force: true }); } catch {} + } + } + ); + app.post("/admin/update", requireRole("admin"), (req, res) => { try { const remote = getSetting("git_remote", "origin"); diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index 8d0ce8e..bff7277 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -1,106 +1,258 @@ <%- include("partials/layout-top", { title }) %> +<% + const status = updateStatus || {}; + const core = status.core || null; + const plugins = status.plugins || []; + const summary = status.plugins_summary || { installed_plugins: 0, total_plugins: 0, updatable_plugins: 0, blocked_plugins: 0 }; + const recovery = recoveryStatus || status.recovery || {}; + const marker = recovery.marker || null; + const selectedSource = status.requested_source || "stable"; + const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success"; + const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current"; + const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : []; +%> +
<%- include("partials/page-header", { eyebrow: "Maintenance", pageTitle: "Updates", - description: "Apply git or ZIP updates with automatic pre-update snapshots." + description: "Version-aware core and plugin updates with snapshots, safe targets, recovery markers, revert, and advanced ZIP fallback." }) %> -

Rollback is handled from Safe Mode if something breaks.

-
- -
-

Git updates

-

Check or pull updates from the remote and branch configured in Settings.

-
-
- <%- include("partials/state-button", { - type: "submit", - classes: "subtle", - states: [ - { id: "idle", text: "Check for updates" }, - { id: "loading", text: "Checking", spinner: true }, - { id: "success", text: "Checked" } - ] - }) %> -
-
- <%- include("partials/state-button", { - type: "submit", - states: [ - { id: "idle", text: "Update from git" }, - { id: "loading", text: "Updating", spinner: true }, - { id: "success", text: "Updated" } - ] - }) %> -
-
-
- -
-

Upload bot update

-
-
- - <%- include("partials/state-button", { - type: "submit", - states: [ - { id: "idle", text: "Upload bot update" }, - { id: "loading", text: "Uploading", spinner: true }, - { id: "success", text: "Uploaded" } - ] - }) %> -
-
- - -
+ <% if (updateStatusError) { %> +
Update metadata could not be loaded: <%= updateStatusError %>
+ <% } %> + + +
+

Stable checks read repo metadata from main. Experimental branches are considered only when selected here.

- -
-

Upload plugin update

-
-
- - <%- include("partials/state-button", { - type: "submit", - states: [ - { id: "idle", text: "Upload plugin update" }, - { id: "loading", text: "Uploading", spinner: true }, - { id: "success", text: "Uploaded" } - ] - }) %> + +<% if (recovery.has_incomplete_marker || marker) { %> +
+

Safe Mode / Recovery

+

+ Recovery marker status: + <%= marker?.status || "none" %> + <% if (marker?.target_kind) { %> + for <%= marker.target_kind %><%= marker.target_id ? `:${marker.target_id}` : "" %> + <% } %> +

+ <% if (marker) { %> +
+
From<%= marker.from_version || "unknown" %>
+
To<%= marker.to_version || "unknown" %>
+
Method<%= marker.update_method || "unknown" %>
+
Source<%= marker.source_branch || "unknown" %>
+
Snapshot<%= marker.snapshot_id || "pending" %>
+
Updated<%= marker.updated_at ? new Date(marker.updated_at).toLocaleString() : "unknown" %>
+
+ <% if (marker.error) { %>

<%= marker.error %>

<% } %> + <% } %> +
+ + + +
+ +
+
+
+<% } %> + +
+
> + + + Core + Current <%= core?.current_version || "unknown" %> · Target <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %> + + <%= badgeText(core) %> + +
+ <% if (core) { %> +
+
Current<%= core.current_version %>
+
Safe target<%= core.safe_target_version || "None" %>
+
Latest<%= core.latest_available_version %>
+
Source branch<%= core.source_branch %>
+
Size change<%= core.size_delta_label %>
+
Snapshot<%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %>
+
+

<%= core.version_description %>

+ <% if (core.warnings?.length) { %>
Warnings
    <% core.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.dangers?.length) { %>
Dangers
    <% core.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.requirements?.length) { %>
Requirements
    <% core.requirements.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (core.migration_notes) { %>
Migration notes

<%= core.migration_notes %>

<% } %> +
+

Changelog to target

+ <% if (!changelogItems(core).length) { %> +

No changelog entries were found for this range.

+ <% } else { %> +
    + <% changelogItems(core).forEach((entry) => { %> +
  • <%= entry.version %><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %>
  • + <% }) %> +
+ <% } %> +
+
+
+ + +
+
+ + +
+ <% if (core.snapshot.available) { %> +
+ + + +
+ <% } %> +
+
+ Show advanced ZIP update options +
ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.
+
+
+ + +
+ + +
+
+ <% } %>
- -
- -
+ +
+ +
+
> + + + Plugins + <%= summary.installed_plugins %> installed · <%= summary.total_plugins %> known · <%= summary.updatable_plugins %> updatable + + "><%= summary.blocked_plugins ? `${summary.blocked_plugins} blocked` : `${summary.updatable_plugins} updates` %> + +
+ <% if (!plugins.length) { %> +
No installed plugins found.
+ <% } %> + <% plugins.forEach((plugin) => { %> +
> + + + <%= plugin.name %> + <%= plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %> + + <%= badgeText(plugin) %> + +
+
+
Current<%= plugin.current_version %>
+
Safe target<%= plugin.safe_target_version || "None" %>
+
Latest<%= plugin.latest_available_version %>
+
Snapshot<%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %>
+
+

<%= plugin.version_description %>

+ <% if (plugin.warnings?.length) { %>
Warnings
    <% plugin.warnings.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (plugin.dangers?.length) { %>
Dangers
    <% plugin.dangers.forEach((item) => { %>
  • <%= item %>
  • <% }) %>
<% } %> + <% if (plugin.migration_notes) { %>
Migration notes

<%= plugin.migration_notes %>

<% } %> +

Changelog to target

+ <% if (!changelogItems(plugin).length) { %> +

No changelog entries were found for this range.

+ <% } else { %> +
    + <% changelogItems(plugin).forEach((entry) => { %> +
  • <%= entry.version %><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %>
  • + <% }) %> +
+ <% } %> +
+
+ + +
+
+ + +
+ <% if (plugin.snapshot.available) { %> +
+ + + +
+ <% } %> +
+ +
+
+
+ Show advanced plugin ZIP options +
Plugin ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.
+
+
+ + +
+ +
+
+
+
+ <% }) %> +
+
+
+ +
+

Live Progress

+
+
Update and recovery progress appears here while actions run.
+
+
+ +

Snapshots

<% if (!snapshots.length) { %>
No snapshots yet.
<% } else { %>
- - - - - - - - - <% snapshots.forEach((snap) => { %> - - - - - <% }) %> - -
SnapshotCreated
<%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %><%= new Date(snap.createdAt).toLocaleString() %>
+ + + + + + + + + + + + <% snapshots.forEach((snap) => { %> + + + + + + + + <% }) %> + +
TargetVersionMethodRollbackCreated
<%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %><%= snap.update_method || "snapshot" %><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Allowed previous-version only" %><%= new Date(snap.createdAt).toLocaleString() %>
<% } %>
-<%- include("partials/layout-bottom") %> +<%- include("partials/layout-bottom") %>