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.
|
Use **Admin → Plugins** to install, enable, update, or uninstall plugins.
|
||||||
You can also create a local plugin from the WebUI.
|
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
|
## Twitch bot
|
||||||
|
|
||||||
Configure Twitch chat settings in **Admin → Settings**:
|
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
|
## Updates And Local-Only Files
|
||||||
|
|
||||||
Admin update and ZIP upload controls use the same state-button and
|
Admin update controls use the same progressive action patterns as other Lumi
|
||||||
input-action-row patterns as other Lumi actions. Git update actions have
|
actions. `/admin/updates` is split into Core and Plugins expandable sections
|
||||||
contextual confirmation copy because they can restart the process. ZIP update
|
with collapsed summaries, warning/danger badges, safe target versions, changelog
|
||||||
forms still submit to the existing `/admin/updates/bot` and
|
ranges, migration notes, recovery state, snapshot/revert availability, and live
|
||||||
`/admin/updates/plugin` routes and keep the existing snapshot behavior.
|
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
|
The repository ignores local-only coordination and credential artifacts such as
|
||||||
`codex-guidelines`, `Twitch.png`, and `twitch-credentials-lumi.png`. Plugin
|
`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.
|
||||||
11
run.js
11
run.js
@ -6,6 +6,7 @@ const safeModeEntry = path.join(__dirname, "safe-mode.js");
|
|||||||
const maxRestarts = Number(process.env.MAX_RESTARTS || 25);
|
const maxRestarts = Number(process.env.MAX_RESTARTS || 25);
|
||||||
const restartDelayMs = Number(process.env.RESTART_DELAY_MS || 1500);
|
const restartDelayMs = Number(process.env.RESTART_DELAY_MS || 1500);
|
||||||
const restartCodes = new Set([10, 100]);
|
const restartCodes = new Set([10, 100]);
|
||||||
|
const safeModeFlag = path.join(__dirname, "data", "recovery", "safe-mode.flag");
|
||||||
|
|
||||||
let restarts = 0;
|
let restarts = 0;
|
||||||
let safeModeStarted = false;
|
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();
|
||||||
|
}
|
||||||
|
|||||||
77
safe-mode.js
77
safe-mode.js
@ -14,6 +14,11 @@ const {
|
|||||||
const { getRoleFlags, hasAccess } = require("./src/services/rbac");
|
const { getRoleFlags, hasAccess } = require("./src/services/rbac");
|
||||||
const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager");
|
const { listSnapshots, restoreSnapshot } = require("./src/services/update-manager");
|
||||||
const { requestRestart } = require("./src/services/updater");
|
const { requestRestart } = require("./src/services/updater");
|
||||||
|
const {
|
||||||
|
safeModeStatus,
|
||||||
|
clearRecoveryMarker
|
||||||
|
} = require("./src/services/recovery-mode");
|
||||||
|
const { setPluginEnabled } = require("./src/services/plugins");
|
||||||
|
|
||||||
function ensureSessionSecret() {
|
function ensureSessionSecret() {
|
||||||
let secret = getSetting("session_secret");
|
let secret = getSetting("session_secret");
|
||||||
@ -45,8 +50,13 @@ function renderPage(title, content) {
|
|||||||
main { padding: 24px 28px; max-width: 900px; margin: 0 auto; }
|
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); }
|
.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 { 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; }
|
.button.danger { background: #c24b3b; }
|
||||||
.muted { color: #5a616a; }
|
.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; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; }
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #e1ddd7; }
|
||||||
</style>
|
</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 app = express();
|
||||||
const sessionStore = new BetterSqlite3Store({ client: db });
|
const sessionStore = new BetterSqlite3Store({ client: db });
|
||||||
app.use(
|
app.use(
|
||||||
@ -141,7 +192,7 @@ app.get("/", (req, res) => {
|
|||||||
res.send(
|
res.send(
|
||||||
renderPage(
|
renderPage(
|
||||||
"Safe Mode",
|
"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);
|
const port = Number(process.env.SAFE_MODE_PORT || 3001);
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Safe mode listening on http://localhost:${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 { registerTopCommand } = require("./services/top");
|
||||||
const logger = require("./services/logger");
|
const logger = require("./services/logger");
|
||||||
const { isPlatformEnabled } = require("./services/platforms");
|
const { isPlatformEnabled } = require("./services/platforms");
|
||||||
|
const {
|
||||||
|
isSafeModeRequested,
|
||||||
|
markStartupVerification
|
||||||
|
} = require("./services/recovery-mode");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
migrate();
|
migrate();
|
||||||
ensureDefaults();
|
ensureDefaults();
|
||||||
logger.hookConsole();
|
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 settingsApi = { getSetting, setSetting };
|
||||||
const commandRouter = createCommandRouter({ settings: settingsApi });
|
const commandRouter = createCommandRouter({ settings: settingsApi });
|
||||||
@ -28,7 +37,7 @@ async function main() {
|
|||||||
let twitchClient = null;
|
let twitchClient = null;
|
||||||
let youtubeClient = null;
|
let youtubeClient = null;
|
||||||
|
|
||||||
if (isPlatformEnabled("discord")) {
|
if (!safeModeRequested && isPlatformEnabled("discord")) {
|
||||||
try {
|
try {
|
||||||
discordClient = await startBot({ commandRouter });
|
discordClient = await startBot({ commandRouter });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -36,7 +45,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlatformEnabled("twitch")) {
|
if (!safeModeRequested && isPlatformEnabled("twitch")) {
|
||||||
try {
|
try {
|
||||||
twitchClient = await startTwitchBot({ commandRouter });
|
twitchClient = await startTwitchBot({ commandRouter });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -44,7 +53,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlatformEnabled("youtube")) {
|
if (!safeModeRequested && isPlatformEnabled("youtube")) {
|
||||||
try {
|
try {
|
||||||
youtubeClient = await startYouTubeBot({ commandRouter });
|
youtubeClient = await startYouTubeBot({ commandRouter });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -55,6 +64,7 @@ async function main() {
|
|||||||
const app = createWebServer({
|
const app = createWebServer({
|
||||||
discordClient,
|
discordClient,
|
||||||
loadPlugins: (appInstance, web, webhooks) => {
|
loadPlugins: (appInstance, web, webhooks) => {
|
||||||
|
if (safeModeRequested) return;
|
||||||
loadEnabled({
|
loadEnabled({
|
||||||
app: appInstance,
|
app: appInstance,
|
||||||
discordClient,
|
discordClient,
|
||||||
@ -75,7 +85,7 @@ async function main() {
|
|||||||
|
|
||||||
const autoUpdateEnabled = getSetting("auto_update_enabled", false);
|
const autoUpdateEnabled = getSetting("auto_update_enabled", false);
|
||||||
const intervalMinutes = getSetting("auto_update_interval_minutes", 60);
|
const intervalMinutes = getSetting("auto_update_interval_minutes", 60);
|
||||||
if (autoUpdateEnabled) {
|
if (!safeModeRequested && autoUpdateEnabled) {
|
||||||
const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000;
|
const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
try {
|
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;
|
AdmZip = null;
|
||||||
}
|
}
|
||||||
const { db } = require("./db");
|
const { db } = require("./db");
|
||||||
|
const {
|
||||||
|
createRecoveryMarker,
|
||||||
|
updateRecoveryMarker,
|
||||||
|
markRecoveryMarkerFailed,
|
||||||
|
markRecoveryMarkerComplete
|
||||||
|
} = require("./recovery-mode");
|
||||||
|
|
||||||
const repoRoot = path.join(__dirname, "..", "..");
|
const repoRoot = path.join(__dirname, "..", "..");
|
||||||
const dataDir = path.join(repoRoot, "data");
|
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();
|
ensureSnapshotsDir();
|
||||||
const id = `${Date.now()}-${crypto.randomUUID()}`;
|
const id = `${Date.now()}-${crypto.randomUUID()}`;
|
||||||
const snapshotPath = path.join(snapshotsDir, id);
|
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) {
|
function finalizeSnapshot(snapshot) {
|
||||||
@ -89,7 +95,18 @@ function finalizeSnapshot(snapshot) {
|
|||||||
pluginExisted: snapshot.pluginExisted || false,
|
pluginExisted: snapshot.pluginExisted || false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
status: "available",
|
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);
|
entries.push(record);
|
||||||
saveIndex(pruneEntries(entries));
|
saveIndex(pruneEntries(entries));
|
||||||
@ -384,16 +401,38 @@ async function applyBotUpdate(zipPath, options = {}) {
|
|||||||
verifyBotPackage(rootPath);
|
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 {
|
try {
|
||||||
if (mode === "patch") {
|
if (mode === "patch") {
|
||||||
applyCorePatch(rootPath);
|
applyCorePatch(rootPath);
|
||||||
} else {
|
} else {
|
||||||
applyCoreUpdate(rootPath);
|
applyCoreUpdate(rootPath);
|
||||||
}
|
}
|
||||||
return finalizeSnapshot(snapshot);
|
const record = finalizeSnapshot(snapshot);
|
||||||
|
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||||
|
return record;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
discardSnapshot(snapshot);
|
discardSnapshot(snapshot);
|
||||||
|
markRecoveryMarkerFailed(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} finally {
|
} 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-"));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-plugin-"));
|
||||||
try {
|
try {
|
||||||
extractZip(zipPath, tempDir);
|
extractZip(zipPath, tempDir);
|
||||||
const rootPath = resolvePluginRoot(tempDir);
|
const rootPath = resolvePluginRoot(tempDir);
|
||||||
const manifest = verifyPluginPackage(rootPath);
|
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 {
|
try {
|
||||||
applyPluginFiles(rootPath, manifest.id, {
|
applyPluginFiles(rootPath, manifest.id, {
|
||||||
preserveData: snapshot.pluginExisted
|
preserveData: snapshot.pluginExisted
|
||||||
});
|
});
|
||||||
return finalizeSnapshot(snapshot);
|
const record = finalizeSnapshot(snapshot);
|
||||||
|
markRecoveryMarkerComplete({ snapshot_id: record.id });
|
||||||
|
return record;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
discardSnapshot(snapshot);
|
discardSnapshot(snapshot);
|
||||||
|
markRecoveryMarkerFailed(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@ -436,7 +502,7 @@ function restoreDatabase(snapshotPath) {
|
|||||||
fs.rmSync(shm, { force: true });
|
fs.rmSync(shm, { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreSnapshot(id) {
|
function restoreSnapshot(id, options = {}) {
|
||||||
const entries = loadIndex();
|
const entries = loadIndex();
|
||||||
const entry = entries.find((item) => item.id === id);
|
const entry = entries.find((item) => item.id === id);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@ -445,6 +511,18 @@ function restoreSnapshot(id) {
|
|||||||
if (entry.status !== "available") {
|
if (entry.status !== "available") {
|
||||||
throw new Error("Snapshot is no longer 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") {
|
if (entry.type === "bot") {
|
||||||
const coreZip = path.join(entry.path, "core.zip");
|
const coreZip = path.join(entry.path, "core.zip");
|
||||||
@ -484,6 +562,9 @@ function restoreSnapshot(id) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
applyBotUpdate,
|
applyBotUpdate,
|
||||||
applyPluginUpdate,
|
applyPluginUpdate,
|
||||||
|
createSnapshot,
|
||||||
|
finalizeSnapshot,
|
||||||
|
discardSnapshot,
|
||||||
applyPluginFiles,
|
applyPluginFiles,
|
||||||
resetPluginCode,
|
resetPluginCode,
|
||||||
replacePluginDirectory,
|
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) => {
|
const isDestructiveForm = (form, submitter = null) => {
|
||||||
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
|
if (!form || form.dataset.noDestructiveConfirm !== undefined) return false;
|
||||||
|
if (form.dataset.updateAction !== undefined) return false;
|
||||||
return String(form.method || "get").toLowerCase() === "post" &&
|
return String(form.method || "get").toLowerCase() === "post" &&
|
||||||
destructivePattern.test(destructiveAction(form, submitter));
|
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) => {
|
document.querySelectorAll("[data-copy]").forEach((button) => {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
const text = button.getAttribute("data-copy") || "";
|
const text = button.getAttribute("data-copy") || "";
|
||||||
|
|||||||
@ -469,6 +469,27 @@ input[type="color"] {
|
|||||||
color: var(--lumi-success);
|
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 {
|
.status-warning {
|
||||||
color: var(--lumi-warning);
|
color: var(--lumi-warning);
|
||||||
}
|
}
|
||||||
@ -711,6 +732,80 @@ input[type="color"] {
|
|||||||
color: var(--lumi-text-muted);
|
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-metric-grid,
|
||||||
.dashboard-chart-grid {
|
.dashboard-chart-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -83,6 +83,19 @@ const {
|
|||||||
applyPluginUpdate,
|
applyPluginUpdate,
|
||||||
listSnapshots
|
listSnapshots
|
||||||
} = require("../services/update-manager");
|
} = 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 {
|
const {
|
||||||
generateCommandPreview,
|
generateCommandPreview,
|
||||||
previewParts
|
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) => {
|
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", {
|
res.render("admin-updates", {
|
||||||
title: "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(
|
app.post(
|
||||||
"/admin/updates/bot",
|
"/admin/updates/bot",
|
||||||
requireRole("admin"),
|
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(
|
app.post(
|
||||||
"/admin/updates/plugin",
|
"/admin/updates/plugin",
|
||||||
requireRole("admin"),
|
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) => {
|
app.post("/admin/update", requireRole("admin"), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const remote = getSetting("git_remote", "origin");
|
const remote = getSetting("git_remote", "origin");
|
||||||
|
|||||||
@ -1,106 +1,258 @@
|
|||||||
<%- include("partials/layout-top", { title }) %>
|
<%- 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">
|
<section class="card">
|
||||||
<%- include("partials/page-header", {
|
<%- include("partials/page-header", {
|
||||||
eyebrow: "Maintenance",
|
eyebrow: "Maintenance",
|
||||||
pageTitle: "Updates",
|
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) { %>
|
||||||
</section>
|
<div class="callout danger">Update metadata could not be loaded: <%= updateStatusError %></div>
|
||||||
|
<% } %>
|
||||||
<section class="card">
|
<form method="get" action="/admin/updates" class="inline-actions">
|
||||||
<h2>Git updates</h2>
|
<label class="field compact">
|
||||||
<p>Check or pull updates from the remote and branch configured in Settings.</p>
|
<span>Update source</span>
|
||||||
<div class="inline-actions">
|
<select name="source">
|
||||||
<form method="post" action="/admin/check-update" class="inline-form">
|
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option>
|
||||||
<%- include("partials/state-button", {
|
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option>
|
||||||
type: "submit",
|
</select>
|
||||||
classes: "subtle",
|
</label>
|
||||||
states: [
|
<button class="button subtle" type="submit">Reload metadata</button>
|
||||||
{ id: "idle", text: "Check for updates" },
|
|
||||||
{ id: "loading", text: "Checking", spinner: true },
|
|
||||||
{ id: "success", text: "Checked" }
|
|
||||||
]
|
|
||||||
}) %>
|
|
||||||
</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>
|
|
||||||
</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" }
|
|
||||||
]
|
|
||||||
}) %>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<% if (recovery.has_incomplete_marker || marker) { %>
|
||||||
<h2>Upload plugin update</h2>
|
<section class="card update-recovery-banner">
|
||||||
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
|
<h2>Safe Mode / Recovery</h2>
|
||||||
<div class="field full input-action-row">
|
<p>
|
||||||
<input type="file" name="plugin_zip" accept=".zip" required />
|
Recovery marker status:
|
||||||
<%- include("partials/state-button", {
|
<strong><%= marker?.status || "none" %></strong>
|
||||||
type: "submit",
|
<% if (marker?.target_kind) { %>
|
||||||
states: [
|
for <strong><%= marker.target_kind %><%= marker.target_id ? `:${marker.target_id}` : "" %></strong>
|
||||||
{ id: "idle", text: "Upload plugin update" },
|
<% } %>
|
||||||
{ id: "loading", text: "Uploading", spinner: true },
|
</p>
|
||||||
{ id: "success", text: "Uploaded" }
|
<% 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/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/updates/recovery/retry-normal-startup">
|
||||||
|
<button class="button subtle" type="submit">Retry normal startup</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<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>
|
||||||
</form>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<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>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">
|
||||||
<h2>Snapshots</h2>
|
<h2>Snapshots</h2>
|
||||||
<% if (!snapshots.length) { %>
|
<% if (!snapshots.length) { %>
|
||||||
<div class="empty-state">No snapshots yet.</div>
|
<div class="empty-state">No snapshots yet.</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Snapshot</th>
|
<th>Target</th>
|
||||||
<th>Created</th>
|
<th>Version</th>
|
||||||
</tr>
|
<th>Method</th>
|
||||||
</thead>
|
<th>Rollback</th>
|
||||||
<tbody>
|
<th>Created</th>
|
||||||
<% snapshots.forEach((snap) => { %>
|
</tr>
|
||||||
<tr>
|
</thead>
|
||||||
<td><%= snap.type === 'plugin' ? `Plugin: ${snap.pluginId}` : 'Bot core' %></td>
|
<tbody>
|
||||||
<td><%= new Date(snap.createdAt).toLocaleString() %></td>
|
<% snapshots.forEach((snap) => { %>
|
||||||
</tr>
|
<tr>
|
||||||
<% }) %>
|
<td><%= snap.type === "plugin" ? `Plugin: ${snap.pluginId}` : "Core" %></td>
|
||||||
</tbody>
|
<td><%= snap.from_version || "?" %> -> <%= snap.to_version || "?" %></td>
|
||||||
</table>
|
<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>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</section>
|
</section>
|
||||||
<%- include("partials/layout-bottom") %>
|
<%- include("partials/layout-bottom") %>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user