updates: add version-aware recovery flow
This commit is contained in:
parent
64da8ae103
commit
c611790370
13
README.md
13
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**:
|
||||
|
||||
@ -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
54
docs/recovery-mode.md
Normal 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
75
docs/updates.md
Normal 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.
|
||||
9
run.js
9
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() {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.LUMI_SAFE_MODE === "1" ||
|
||||
process.argv.includes("--safe-mode") ||
|
||||
require("fs").existsSync(safeModeFlag)
|
||||
) {
|
||||
startSafeMode();
|
||||
} else {
|
||||
startChild();
|
||||
}
|
||||
|
||||
77
safe-mode.js
77
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; }
|
||||
</style>
|
||||
@ -99,6 +109,47 @@ function buildSnapshotTable(snapshots) {
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
18
src/main.js
18
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 {
|
||||
|
||||
132
src/services/recovery-mode.js
Normal file
132
src/services/recovery-mode.js
Normal 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
223
src/services/repo-update.js
Normal 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
|
||||
};
|
||||
261
src/services/update-index.js
Normal file
261
src/services/update-index.js
Normal 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
|
||||
};
|
||||
@ -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
118
src/services/versioning.js
Normal 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
|
||||
};
|
||||
@ -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") || "";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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">
|
||||
<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 />
|
||||
<%- include("partials/state-button", {
|
||||
type: "submit",
|
||||
states: [
|
||||
{ id: "idle", text: "Upload bot update" },
|
||||
{ id: "loading", text: "Uploading", spinner: true },
|
||||
{ id: "success", text: "Uploaded" }
|
||||
]
|
||||
}) %>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
</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" }
|
||||
]
|
||||
}) %>
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@ -87,14 +233,20 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Snapshot</th>
|
||||
<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}` : 'Bot core' %></td>
|
||||
<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>
|
||||
<% }) %>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user