updates: add version-aware recovery flow

This commit is contained in:
Franz Rolfsvaag 2026-06-16 09:44:16 +02:00
parent 64da8ae103
commit c611790370
16 changed files with 1767 additions and 110 deletions

View File

@ -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**:

View File

@ -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

54
docs/recovery-mode.md Normal file
View File

@ -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.

75
docs/updates.md Normal file
View File

@ -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_id>/plugin.json`
- `plugins/<plugin_id>/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.

11
run.js
View File

@ -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();
}

View File

@ -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; }
</style>
@ -99,6 +109,47 @@ function buildSnapshotTable(snapshots) {
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildRecoveryPanel() {
const status = safeModeStatus();
const marker = status.marker;
if (!marker) {
return `<section class="card"><h2>Recovery state</h2><p class="muted">No recovery marker is present.</p></section>`;
}
const pluginActions = marker.target_kind === "plugin" && marker.target_id
? `<form method="post" action="/plugins/${encodeURIComponent(marker.target_id)}/disable"><button class="button danger" type="submit">Disable plugin</button></form>`
: "";
return `
<section class="card ${marker.status === "failed" || marker.status === "stale" ? "danger-card" : "warning"}">
<h2>Last update marker</h2>
<p class="muted">Status: <strong>${escapeHtml(marker.status)}</strong></p>
<table>
<tbody>
<tr><th>Target</th><td>${escapeHtml(marker.target_kind || "unknown")} ${escapeHtml(marker.target_id || "")}</td></tr>
<tr><th>Version</th><td>${escapeHtml(marker.from_version || "?")} -> ${escapeHtml(marker.to_version || "?")}</td></tr>
<tr><th>Method</th><td>${escapeHtml(marker.update_method || "?")}</td></tr>
<tr><th>Source branch</th><td>${escapeHtml(marker.source_branch || "?")}</td></tr>
<tr><th>Snapshot</th><td>${escapeHtml(marker.snapshot_id || marker.recovery_marker_id || "pending")}</td></tr>
<tr><th>Updated</th><td>${escapeHtml(marker.updated_at || marker.created_at || "?")}</td></tr>
<tr><th>Error</th><td>${escapeHtml(marker.error || "")}</td></tr>
</tbody>
</table>
<div>
${pluginActions}
<form method="post" action="/recovery/clear-marker"><button class="button danger" type="submit">Clear stale marker</button></form>
<form method="post" action="/recovery/retry-normal-startup"><button class="button subtle" type="submit">Retry normal startup</button></form>
</div>
</section>
`;
}
const app = express();
const sessionStore = new BetterSqlite3Store({ client: db });
app.use(
@ -141,7 +192,7 @@ app.get("/", (req, res) => {
res.send(
renderPage(
"Safe Mode",
`<section class="card"><h2>Rollback snapshots</h2><p class="muted">Use these snapshots to roll back failed updates. The server will restart after rollback.</p>${table}</section>`
`${buildRecoveryPanel()}<section class="card"><h2>Rollback snapshots</h2><p class="muted">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.</p>${table}</section>`
)
);
});
@ -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", "<section class=\"card\">Access denied.</section>"));
}
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", "<section class=\"card\">Access denied.</section>"));
}
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", "<section class=\"card\">Access denied.</section>"));
}
res.send(renderPage("Safe Mode", "<section class=\"card\"><h2>Retrying normal startup</h2><p class=\"muted\">Safe mode is exiting. The wrapper will restart Lumi normally.</p></section>"));
requestRestart();
});
const port = Number(process.env.SAFE_MODE_PORT || 3001);
app.listen(port, () => {
console.log(`Safe mode listening on http://localhost:${port}`);

View File

@ -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 {

View File

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

223
src/services/repo-update.js Normal file
View File

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

View File

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

View File

@ -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,

118
src/services/versioning.js Normal file
View File

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

View File

@ -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") || "";

View File

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

View File

@ -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");

View File

@ -1,81 +1,227 @@
<%- 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 : [];
%>
<section class="card">
<%- 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."
}) %>
<p class="hint">Rollback is handled from Safe Mode if something breaks.</p>
<% if (updateStatusError) { %>
<div class="callout danger">Update metadata could not be loaded: <%= updateStatusError %></div>
<% } %>
<form method="get" action="/admin/updates" class="inline-actions">
<label class="field compact">
<span>Update source</span>
<select name="source">
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option>
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option>
</select>
</label>
<button class="button subtle" type="submit">Reload metadata</button>
</form>
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
</section>
<section class="card">
<h2>Git updates</h2>
<p>Check or pull updates from the remote and branch configured in Settings.</p>
<% if (recovery.has_incomplete_marker || marker) { %>
<section class="card update-recovery-banner">
<h2>Safe Mode / Recovery</h2>
<p>
Recovery marker status:
<strong><%= marker?.status || "none" %></strong>
<% if (marker?.target_kind) { %>
for <strong><%= marker.target_kind %><%= marker.target_id ? `:${marker.target_id}` : "" %></strong>
<% } %>
</p>
<% if (marker) { %>
<div class="update-meta-grid">
<div><span>From</span><strong><%= marker.from_version || "unknown" %></strong></div>
<div><span>To</span><strong><%= marker.to_version || "unknown" %></strong></div>
<div><span>Method</span><strong><%= marker.update_method || "unknown" %></strong></div>
<div><span>Source</span><strong><%= marker.source_branch || "unknown" %></strong></div>
<div><span>Snapshot</span><strong><%= marker.snapshot_id || "pending" %></strong></div>
<div><span>Updated</span><strong><%= marker.updated_at ? new Date(marker.updated_at).toLocaleString() : "unknown" %></strong></div>
</div>
<% if (marker.error) { %><p class="hint status-danger"><%= marker.error %></p><% } %>
<% } %>
<div class="inline-actions">
<form method="post" action="/admin/check-update" class="inline-form">
<%- 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" }
]
}) %>
<form method="post" action="/admin/updates/recovery/clear-marker" data-confirm-mode="modal" data-confirm-title="Clear recovery marker" data-confirm-text="Clear the stale recovery marker? Do this only after you have verified Lumi starts normally." data-confirm-label="Clear marker">
<button class="button danger" type="submit">Clear stale marker</button>
</form>
<form method="post" action="/admin/update" class="inline-form" data-confirm-mode="modal" data-confirm-title="Update from git" data-confirm-text="Pull updates from the configured remote and branch, then restart Lumi if the update succeeds." data-confirm-label="Update from git">
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Update from git" },
{ id: "loading", text: "Updating", spinner: true },
{ id: "success", text: "Updated" }
]
}) %>
<form method="post" action="/admin/updates/recovery/retry-normal-startup">
<button class="button subtle" type="submit">Retry normal startup</button>
</form>
</div>
</section>
<% } %>
<section class="card">
<h2>Upload bot update</h2>
<form method="post" action="/admin/updates/bot" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required />
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Upload bot update" },
{ id: "loading", text: "Uploading", spinner: true },
{ id: "success", text: "Uploaded" }
]
}) %>
<section class="card" data-update-panel>
<details class="lumi-expandable-settings" <%= core?.blocked || core?.update_available || recovery.has_incomplete_marker ? "open" : "" %>>
<summary>
<span>
<strong>Core</strong>
<span class="hint">Current <%= core?.current_version || "unknown" %> · Target <%= core?.safe_target_version || "none" %> · <%= core?.source_branch || "main" %></span>
</span>
<span class="badge <%= badgeClass(core) %>"><%= badgeText(core) %></span>
</summary>
<div class="lumi-expandable-body update-detail-grid">
<% if (core) { %>
<div class="update-meta-grid">
<div><span>Current</span><strong><%= core.current_version %></strong></div>
<div><span>Safe target</span><strong><%= core.safe_target_version || "None" %></strong></div>
<div><span>Latest</span><strong><%= core.latest_available_version %></strong></div>
<div><span>Source branch</span><strong><%= core.source_branch %></strong></div>
<div><span>Size change</span><strong><%= core.size_delta_label %></strong></div>
<div><span>Snapshot</span><strong><%= core.snapshot.available ? core.snapshot.latest_snapshot_id : "None" %></strong></div>
</div>
<p><%= core.version_description %></p>
<% if (core.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% core.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% core.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.requirements?.length) { %><div class="callout"><strong>Requirements</strong><ul><% core.requirements.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (core.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= core.migration_notes %></p></div><% } %>
<div>
<h3>Changelog to target</h3>
<% if (!changelogItems(core).length) { %>
<p class="hint">No changelog entries were found for this range.</p>
<% } else { %>
<ul class="update-changelog">
<% changelogItems(core).forEach((entry) => { %>
<li><strong><%= entry.version %></strong><span><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %></span></li>
<% }) %>
</ul>
<% } %>
</div>
<div class="inline-actions">
<form method="post" action="/admin/updates/core/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check core</button>
</form>
<form method="post" action="/admin/updates/core/apply" data-update-action data-confirm-mode="modal" data-confirm-title="Apply core update" data-confirm-text="Create a snapshot, write a recovery marker, apply the safe target, verify, then restart Lumi after a 5 second notice." data-confirm-label="Apply update">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= core.blocked || !core.update_available ? "disabled" : "" %>>Apply safe target</button>
</form>
<% if (core.snapshot.available) { %>
<form method="post" action="/admin/updates/core/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert core snapshot" data-confirm-text="Revert only the previous core version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert core">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<input type="hidden" name="snapshot_id" value="<%= core.snapshot.latest_snapshot_id %>" />
<button class="button danger" type="submit">Revert previous</button>
</form>
<% } %>
</div>
<details class="inline-details">
<summary>Show advanced ZIP update options</summary>
<div class="callout">ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div>
<form method="post" action="/admin/updates/core/zip" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="update_zip" accept=".zip" required />
<button type="submit" class="button">Upload core ZIP</button>
</div>
<label class="switch"><input type="checkbox" class="switch-input" name="patch_mode" value="1" /><span class="switch-track"></span><span class="switch-text">Patch mode</span></label>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
</form>
</details>
<% } %>
</div>
<div class="field full">
<label>Patch mode (apply only files in ZIP, skip full package verification)</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="patch_mode" value="1" />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Patch mode</span>
</label>
</details>
</section>
<section class="card" data-update-panel>
<details class="lumi-expandable-settings" <%= summary.updatable_plugins || summary.blocked_plugins ? "open" : "" %>>
<summary>
<span>
<strong>Plugins</strong>
<span class="hint"><%= summary.installed_plugins %> installed · <%= summary.total_plugins %> known · <%= summary.updatable_plugins %> updatable</span>
</span>
<span class="badge <%= summary.blocked_plugins ? "danger" : summary.updatable_plugins ? "warning" : "success" %>"><%= summary.blocked_plugins ? `${summary.blocked_plugins} blocked` : `${summary.updatable_plugins} updates` %></span>
</summary>
<div class="lumi-expandable-body plugin-update-list">
<% if (!plugins.length) { %>
<div class="empty-state">No installed plugins found.</div>
<% } %>
<% plugins.forEach((plugin) => { %>
<details class="lumi-expandable-settings plugin-update-row" <%= plugin.blocked || plugin.update_available ? "open" : "" %>>
<summary>
<span>
<strong><%= plugin.name %></strong>
<span class="hint"><%= plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
</span>
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
</summary>
<div class="lumi-expandable-body update-detail-grid">
<div class="update-meta-grid">
<div><span>Current</span><strong><%= plugin.current_version %></strong></div>
<div><span>Safe target</span><strong><%= plugin.safe_target_version || "None" %></strong></div>
<div><span>Latest</span><strong><%= plugin.latest_available_version %></strong></div>
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
</div>
<p><%= plugin.version_description %></p>
<% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.migration_notes) { %><div class="callout"><strong>Migration notes</strong><p><%= plugin.migration_notes %></p></div><% } %>
<h3>Changelog to target</h3>
<% if (!changelogItems(plugin).length) { %>
<p class="hint">No changelog entries were found for this range.</p>
<% } else { %>
<ul class="update-changelog">
<% changelogItems(plugin).forEach((entry) => { %>
<li><strong><%= entry.version %></strong><span><%= Array.isArray(entry.changes) ? entry.changes.join(" ") : (entry.description || entry.title || "") %></span></li>
<% }) %>
</ul>
<% } %>
<div class="inline-actions">
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check plugin</button>
</form>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="Apply plugin update" data-confirm-text="Create a plugin snapshot, write a recovery marker, apply the safe target, and verify this plugin." data-confirm-label="Apply plugin">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>>Apply safe target</button>
</form>
<% if (plugin.snapshot.available) { %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/revert" data-update-action data-confirm-mode="modal" data-confirm-title="Revert plugin snapshot" data-confirm-text="Revert only the previous plugin version snapshot. Major rollback is blocked unless metadata marks it rollback safe." data-confirm-label="Revert plugin">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<input type="hidden" name="snapshot_id" value="<%= plugin.snapshot.latest_snapshot_id %>" />
<button class="button danger" type="submit">Revert previous</button>
</form>
<% } %>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/disable" data-confirm-mode="modal" data-confirm-title="Disable plugin" data-confirm-text="Disable this plugin for recovery? Lumi may need a restart for already-loaded plugin code to unload." data-confirm-label="Disable plugin">
<button class="button danger" type="submit">Disable for recovery</button>
</form>
</div>
<details class="inline-details">
<summary>Show advanced plugin ZIP options</summary>
<div class="callout">Plugin ZIP updates create snapshots and recovery markers, but may bypass repo metadata and compatibility checks unless the ZIP includes valid manifest data.</div>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/zip" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin ZIP</button>
</div>
<label class="switch"><input type="checkbox" class="switch-input" name="rollback_safe" value="1" /><span class="switch-track"></span><span class="switch-text">ZIP manifest/notes mark rollback safe</span></label>
</form>
</details>
</div>
</details>
<% }) %>
</div>
</form>
</details>
</section>
<section class="card">
<h2>Upload plugin update</h2>
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required />
<%- include("partials/state-button", {
type: "submit",
states: [
{ id: "idle", text: "Upload plugin update" },
{ id: "loading", text: "Uploading", spinner: true },
{ id: "success", text: "Uploaded" }
]
}) %>
</div>
</form>
<h2>Live Progress</h2>
<div class="update-progress-log" data-update-progress-log>
<div class="hint">Update and recovery progress appears here while actions run.</div>
</div>
</section>
<section class="card">
@ -84,22 +230,28 @@
<div class="empty-state">No snapshots yet.</div>
<% } else { %>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Snapshot</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<% snapshots.forEach((snap) => { %>
<tr>
<td><%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %></td>
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
<table class="table">
<thead>
<tr>
<th>Target</th>
<th>Version</th>
<th>Method</th>
<th>Rollback</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<% snapshots.forEach((snap) => { %>
<tr>
<td><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td>
<td><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></td>
<td><%= snap.update_method || "snapshot" %></td>
<td><%= snap.major_crossing && snap.rollback_safe === false ? "Blocked after major migration" : "Allowed previous-version only" %></td>
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</section>