fix: clarify update checks and version pickers

This commit is contained in:
Franz Rolfsvaag 2026-07-18 16:12:57 +02:00
parent 46187137e6
commit a6e49d1248
16 changed files with 137 additions and 66 deletions

View File

@ -1,5 +1,10 @@
# Lumi changelog # Lumi changelog
## 0.2.1
- Fixed repository update checks being handled twice and incorrectly ending in a failed button state after a successful result.
- Simplified update-result wording and moved exact core/plugin version selection into collapsed, module-specific advanced sections.
## 0.2.0 ## 0.2.0
- Added the OBS overlay management and Browser Source system, including scenes, live updates, visual editing, multimedia, external web sources, and OBS bridge extension points. - Added the OBS overlay management and Browser Source system, including scenes, live updates, visual editing, multimedia, external web sources, and OBS bridge extension points.

View File

@ -677,6 +677,7 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no
## Done ## Done
- 2026-07-18: Released the follow-up core 0.2.1 update-page fix: update checks now have one client-side handler, successful results use clear module-specific wording, and exact core/plugin version controls are collapsed and list only the selected modules versions.
- 2026-07-18: Audited the 0.1.9-to-0.2.0 update boundary, removed core's direct dependency on OKF plugin files, made OBS WebSocket optional, added locked runtime dependency repair, corrected 1.2.0 to 0.2.0, added immutable release metadata and exact-version core/plugin installs, and produced a checksummed data-preserving core repair patch. - 2026-07-18: Audited the 0.1.9-to-0.2.0 update boundary, removed core's direct dependency on OKF plugin files, made OBS WebSocket optional, added locked runtime dependency repair, corrected 1.2.0 to 0.2.0, added immutable release metadata and exact-version core/plugin installs, and produced a checksummed data-preserving core repair patch.
### 2026-07-18 ### 2026-07-18

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.2.0 Version: 0.2.1
## Routes ## Routes
- GET /api/events - GET /api/events
- POST /api/destructive-confirmations - POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.0", "version": "0.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.0", "version": "0.2.1",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.2.0", "version": "0.2.1",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.2.1",
"ref": "refs/tags/v0.2.1",
"released_at": "2026-07-18",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Fixes update-check status handling and makes exact version selection collapsed, module-specific, and easier to understand.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.2",
"moderation": "0.1.5",
"okf": "0.1.1",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{ {
"version": "0.2.0", "version": "0.2.0",
"ref": "refs/tags/v0.2.0", "ref": "refs/tags/v0.2.0",

View File

@ -4,7 +4,7 @@ const path = require("path");
const AdmZip = require("adm-zip"); const AdmZip = require("adm-zip");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.0-repair.zip"); const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.1-repair.zip");
const files = [ const files = [
"CHANGELOG.md", "CHANGELOG.md",
"README.md", "README.md",
@ -21,6 +21,7 @@ const files = [
"scripts/build-core-repair-patch.js", "scripts/build-core-repair-patch.js",
"scripts/verify-core-repair-patch.js", "scripts/verify-core-repair-patch.js",
"scripts/verify-update-system.js", "scripts/verify-update-system.js",
"scripts/verify-webui.js",
"src/services/dependency-manager.js", "src/services/dependency-manager.js",
"src/services/overlay-connectors.js", "src/services/overlay-connectors.js",
"src/services/repo-update.js", "src/services/repo-update.js",
@ -28,6 +29,7 @@ const files = [
"src/services/update-manager.js", "src/services/update-manager.js",
"src/services/update-repository.js", "src/services/update-repository.js",
"src/web/server.js", "src/web/server.js",
"src/web/public/app.js",
"src/web/views/admin-updates.ejs" "src/web/views/admin-updates.ejs"
]; ];
@ -45,10 +47,10 @@ for (const relativePath of files) {
const manifest = { const manifest = {
schema_version: 1, schema_version: 1,
name: "Lumi core 0.2.0 repair", name: "Lumi core 0.2.1 repair",
target: "core", target: "core",
from_versions: ["0.1.9", "1.2.0", "0.2.0"], from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1"],
to_version: "0.2.0", to_version: "0.2.1",
data_policy: "preserve", data_policy: "preserve",
dependency_policy: "sync_on_restart", dependency_policy: "sync_on_restart",
created_at: new Date().toISOString(), created_at: new Date().toISOString(),

View File

@ -7,7 +7,7 @@ const AdmZip = require("adm-zip");
const { verifyPatchPackage } = require("../src/services/update-manager"); const { verifyPatchPackage } = require("../src/services/update-manager");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.0-repair.zip"); const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.1-repair.zip");
assert.equal(fs.existsSync(archivePath), true, "build the repair patch first"); assert.equal(fs.existsSync(archivePath), true, "build the repair patch first");
const zip = new AdmZip(archivePath); const zip = new AdmZip(archivePath);
const entries = zip.getEntries().filter((entry) => !entry.isDirectory); const entries = zip.getEntries().filter((entry) => !entry.isDirectory);
@ -15,9 +15,9 @@ const names = new Set(entries.map((entry) => entry.entryName.replace(/\\/g, "/")
assert.equal(names.has("patch-manifest.json"), true); assert.equal(names.has("patch-manifest.json"), true);
const manifest = JSON.parse(zip.readAsText("patch-manifest.json")); const manifest = JSON.parse(zip.readAsText("patch-manifest.json"));
assert.equal(manifest.target, "core"); assert.equal(manifest.target, "core");
assert.equal(manifest.to_version, "0.2.0"); assert.equal(manifest.to_version, "0.2.1");
assert.equal(manifest.data_policy, "preserve"); assert.equal(manifest.data_policy, "preserve");
assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0"]); assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1"]);
const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/; const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/;
for (const entry of entries) { for (const entry of entries) {
@ -29,7 +29,7 @@ for (const [relativePath, expected] of Object.entries(manifest.files)) {
assert.equal(actual, expected, `${relativePath} checksum`); assert.equal(actual, expected, `${relativePath} checksum`);
} }
assert.equal(Object.keys(manifest.files).length + 1, entries.length, "every repair file must be checksummed"); assert.equal(Object.keys(manifest.files).length + 1, entries.length, "every repair file must be checksummed");
assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.0"); assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.1");
const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-")); const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-"));
try { try {
@ -47,11 +47,11 @@ try {
fs.writeFileSync(target, `preserve:${sentinel}`); fs.writeFileSync(target, `preserve:${sentinel}`);
} }
zip.extractAllTo(simulation, true); zip.extractAllTo(simulation, true);
assert.equal(verifyPatchPackage(simulation).to_version, "0.2.0"); assert.equal(verifyPatchPackage(simulation).to_version, "0.2.1");
for (const sentinel of sentinels) { for (const sentinel of sentinels) {
assert.equal(fs.readFileSync(path.join(simulation, sentinel), "utf8"), `preserve:${sentinel}`); assert.equal(fs.readFileSync(path.join(simulation, sentinel), "utf8"), `preserve:${sentinel}`);
} }
assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.0"); assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.1");
assert.equal(fs.existsSync(path.join(simulation, "src", "services", "dependency-manager.js")), true); assert.equal(fs.existsSync(path.join(simulation, "src", "services", "dependency-manager.js")), true);
} finally { } finally {
fs.rmSync(simulation, { recursive: true, force: true }); fs.rmSync(simulation, { recursive: true, force: true });

View File

@ -4,8 +4,9 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.2.0"; const releaseVersion = "0.2.1";
const previousCoreVersion = "0.1.9"; const previousCoreVersion = "0.2.0";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = { const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
birthday: { from: "0.1.2", to: "0.1.3", knowledge: "birthday" }, birthday: { from: "0.1.2", to: "0.1.3", knowledge: "birthday" },
@ -44,13 +45,14 @@ assert.equal(packageLock.version, releaseVersion);
assert.equal(packageLock.packages?.[""]?.version, releaseVersion); assert.equal(packageLock.packages?.[""]?.version, releaseVersion);
assert.equal(coreManifest.version, releaseVersion); assert.equal(coreManifest.version, releaseVersion);
assert.equal(coreManifest.channel, "stable"); assert.equal(coreManifest.channel, "stable");
assert.equal(coreManifest.compatible_from, previousCoreVersion); assert.equal(coreManifest.compatible_from, earliestCompatibleCoreVersion);
assert.equal(coreManifest.rollback_safe, true); assert.equal(coreManifest.rollback_safe, true);
assert.deepEqual(coreManifest.replaces_versions, ["1.2.0"]); assert.deepEqual(coreManifest.replaces_versions, ["1.2.0"]);
assert.equal(releaseIndex.releases[0].version, releaseVersion); assert.equal(releaseIndex.releases[0].version, releaseVersion);
assert.equal(releaseIndex.releases[0].ref, `refs/tags/v${releaseVersion}`); assert.equal(releaseIndex.releases[0].ref, `refs/tags/v${releaseVersion}`);
assert.equal(releaseIndex.releases[1].version, previousCoreVersion); assert.equal(releaseIndex.releases[1].version, previousCoreVersion);
assert.equal(releaseIndex.releases[1].ref, `refs/tags/v${previousCoreVersion}`); assert.equal(releaseIndex.releases[1].ref, `refs/tags/v${previousCoreVersion}`);
assert.equal(releaseIndex.releases[2].version, earliestCompatibleCoreVersion);
assert.equal(hasVersionHeading(readText("CHANGELOG.md"), releaseVersion), true); assert.equal(hasVersionHeading(readText("CHANGELOG.md"), releaseVersion), true);
assert.match(readText("knowledge/core/lumi-core.md"), new RegExp(`^Version: ${escapeRegex(releaseVersion)}$`, "m")); assert.match(readText("knowledge/core/lumi-core.md"), new RegExp(`^Version: ${escapeRegex(releaseVersion)}$`, "m"));
@ -81,8 +83,8 @@ for (const [pluginId, expected] of Object.entries(changedPlugins)) {
const webSearch = readJson("plugins/lumi_ai_web_search/tool_info.json"); const webSearch = readJson("plugins/lumi_ai_web_search/tool_info.json");
assert.equal(webSearch.version, "0.1.1"); assert.equal(webSearch.version, "0.1.1");
assert.equal(webSearch.minimum_lumi_version, releaseVersion); assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to); assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to);
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.0 and 11 changed plugin/tool packages."); console.log("Release metadata verification passed: core 0.2.1 and 11 changed plugin/tool packages.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.0", "0.1.9"]); assert.deepEqual(releaseVersions, ["0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = { const baseTarget = {
current_version: "0.2.0", current_version: "0.2.0",
available_versions: [ available_versions: [
{ version: "0.2.1", ref: "refs/tags/v0.2.1", rollback_safe: true },
{ version: "0.2.0", ref: "refs/tags/v0.2.0", rollback_safe: true }, { version: "0.2.0", ref: "refs/tags/v0.2.0", rollback_safe: true },
{ version: "0.1.9", ref: "refs/tags/v0.1.9", rollback_safe: true } { version: "0.1.9", ref: "refs/tags/v0.1.9", rollback_safe: true }
], ],
@ -60,7 +61,7 @@ const corrected = buildStatus({
channel: "stable" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.0"); assert.equal(corrected.safe_target_version, "0.2.1");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -191,15 +191,23 @@ function verifyUpdateCachePreserved() {
function verifySharedUpdateActions() { function verifySharedUpdateActions() {
const appSource = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8"); const appSource = fs.readFileSync(path.join(root, "src", "web", "public", "app.js"), "utf8");
const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8"); const serverSource = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
const updates = fs.readFileSync(path.join(root, "src", "web", "views", "admin-updates.ejs"), "utf8");
const dashboard = fs.readFileSync(path.join(root, "src", "web", "views", "admin-dashboard.ejs"), "utf8"); const dashboard = fs.readFileSync(path.join(root, "src", "web", "views", "admin-dashboard.ejs"), "utf8");
const settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8"); const settings = fs.readFileSync(path.join(root, "src", "web", "views", "admin-settings.ejs"), "utf8");
assert(appSource.includes('button[data-update-action]')); assert(appSource.includes('button[data-update-action]'));
assert(appSource.includes("submitter?.formAction || form.action")); assert(appSource.includes("submitter?.formAction || form.action"));
assert(appSource.includes('actionPath.endsWith("/check")')); assert(appSource.includes('actionPath.endsWith("/check")'));
assert(appSource.includes('form.matches("[data-update-check-form]")'));
assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action')); assert(dashboard.includes('action="/admin/check-update" class="inline-form" data-update-action'));
assert(settings.includes('formaction=\\"/admin/check-update\\" formmethod=\\"post\\" data-update-action')); assert(settings.includes('formaction=\\"/admin/check-update\\" formmethod=\\"post\\" data-update-action'));
assert(serverSource.includes('sendUpdateResult(req, res, {\n status,')); assert(serverSource.includes('sendUpdateResult(req, res, {\n status,'));
assert(serverSource.includes('message: "Core update applied. Lumi will restart in a few seconds."')); assert(serverSource.includes('message: "Core update applied. Lumi will restart in a few seconds."'));
assert(serverSource.includes('`Core version ${status.core.safe_target_version} is available.`'));
assert(serverSource.includes('`${plugin.name} version ${plugin.safe_target_version} is available.`'));
assert(updates.includes("data-update-check-form"));
assert(updates.includes('class="lumi-expandable-settings update-version-picker"'));
assert(!updates.includes("reinstall current"));
assert(!updates.includes(" · core <%= release.core_version %>"));
} }
function verifyHomepageEmbeds() { function verifyHomepageEmbeds() {

View File

@ -292,9 +292,9 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
rollback_safe: false, rollback_safe: false,
unversioned: true unversioned: true
}; };
const replacementTarget = versions.find((entry) => const replacementTarget = versions.filter((entry) =>
Array.isArray(entry.replaces_versions) && entry.replaces_versions.includes(currentVersion) Array.isArray(entry.replaces_versions) && entry.replaces_versions.includes(currentVersion)
); ).at(-1);
const targetResult = replacementTarget const targetResult = replacementTarget
? { ? {
target: replacementTarget, target: replacementTarget,

View File

@ -1515,6 +1515,7 @@
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
const submitter = event.submitter || form.querySelector("button[type='submit']"); const submitter = event.submitter || form.querySelector("button[type='submit']");
if (!form.matches("[data-update-action]") && !submitter?.matches?.("[data-update-action]")) return; if (!form.matches("[data-update-action]") && !submitter?.matches?.("[data-update-action]")) return;
if (form.matches("[data-update-check-form]")) return;
const action = submitter?.formAction || form.action; const action = submitter?.formAction || form.action;
const actionPath = normalizeDestructiveAction(action); const actionPath = normalizeDestructiveAction(action);
if (updateLog && actionPath.startsWith("/admin/updates/") && actionPath.endsWith("/check")) return; if (updateLog && actionPath.startsWith("/admin/updates/") && actionPath.endsWith("/check")) return;

View File

@ -6594,10 +6594,10 @@ function createWebServer({ loadPlugins, discordClient }) {
sendUpdateResult(req, res, { sendUpdateResult(req, res, {
status, status,
message: status.core.update_available message: status.core.update_available
? `Safe core target ${status.core.safe_target_version} is available.` ? `Core version ${status.core.safe_target_version} is available.`
: status.core.blocked : status.core.blocked
? status.core.blocked_reason ? status.core.blocked_reason
: "No core updates found." : "Lumi core is already up to date."
}); });
} catch (error) { } catch (error) {
sendUpdateError(req, res, error); sendUpdateError(req, res, error);
@ -6751,10 +6751,10 @@ function createWebServer({ loadPlugins, discordClient }) {
sendUpdateResult(req, res, { sendUpdateResult(req, res, {
plugin, plugin,
message: plugin.update_available message: plugin.update_available
? `Safe plugin target ${plugin.safe_target_version} is available.` ? `${plugin.name} version ${plugin.safe_target_version} is available.`
: plugin.blocked : plugin.blocked
? plugin.blocked_reason ? plugin.blocked_reason
: "No plugin updates found." : `${plugin.name} is already up to date.`
}); });
} catch (error) { } catch (error) {
sendUpdateError(req, res, error); sendUpdateError(req, res, error);

View File

@ -12,7 +12,7 @@
const toolBadgeClass = (item) => item?.blocked ? "danger" : item?.update_available || item?.installed === false ? "warning" : "success"; const toolBadgeClass = (item) => item?.blocked ? "danger" : item?.update_available || item?.installed === false ? "warning" : "success";
const toolBadgeText = (item) => item?.blocked ? "Blocked" : item?.installed === false ? "Available" : item?.update_available ? "Update available" : "Current"; const toolBadgeText = (item) => item?.blocked ? "Blocked" : item?.installed === false ? "Available" : item?.update_available ? "Update available" : "Current";
const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : []; const changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
const applyLabel = (item) => item?.unversioned_update ? "Review and update" : "Use recommended version"; const applyLabel = (item) => item?.unversioned_update ? "Review and continue" : item?.installed === false ? "Install plugin" : "Apply update";
const applyConfirmText = (item, label) => { const applyConfirmText = (item, label) => {
const base = `${label}: create a backup, prepare recovery, apply ${item?.safe_target_version || "the selected update"}, and verify before finishing.`; const base = `${label}: create a backup, prepare recovery, apply ${item?.safe_target_version || "the selected update"}, and verify before finishing.`;
if (!item?.unversioned_update) return base; if (!item?.unversioned_update) return base;
@ -129,7 +129,7 @@
<% } %> <% } %>
</div> </div>
<div class="inline-actions update-action-row"> <div class="inline-actions update-action-row">
<form method="post" action="/admin/updates/core/check" data-update-action> <form method="post" action="/admin/updates/core/check" data-update-action data-update-check-form>
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit" data-update-check-button>Check for updates</button> <button class="button subtle" type="submit" data-update-check-button>Check for updates</button>
</form> </form>
@ -146,22 +146,26 @@
<% } %> <% } %>
</div> </div>
<% if (core.available_versions?.length) { %> <% if (core.available_versions?.length) { %>
<fieldset class="lumi-fieldset"> <details class="lumi-expandable-settings update-version-picker">
<legend>Install a specific core version</legend> <summary>
<form method="post" action="/admin/updates/core/apply" class="input-action-row" data-update-action data-confirm-mode="modal" data-confirm-title="Install selected core version" data-confirm-text="Create a backup, preserve local data, install the exact tagged core release, verify its version, and restart Lumi. Older code may not understand database changes made by a newer release." data-confirm-label="Install version"> <span><strong>Install a specific core version</strong><span class="hint">Advanced version selection</span></span>
</summary>
<div class="lumi-expandable-body">
<form method="post" action="/admin/updates/core/apply" class="input-action-row" data-update-action data-confirm-mode="modal" data-confirm-title="Install selected core version" data-confirm-text="Create a backup, preserve local data, install the exact tagged core release, verify its version, and restart Lumi. Older code may not understand database changes made by a newer release." data-confirm-label="Install selected version">
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<label class="field"> <label class="field">
<span>Version</span> <span>Core version</span>
<select name="version" required> <select name="version" required>
<% core.available_versions.forEach((release) => { %> <% core.available_versions.forEach((release) => { %>
<option value="<%= release.version %>"><%= release.version %><%= release.version === core.current_version ? " (reinstall current)" : "" %> · <%= release.ref.replace(/^refs\/tags\//, "") %></option> <option value="<%= release.version %>"><%= release.version %></option>
<% }) %> <% }) %>
</select> </select>
</label> </label>
<button class="button subtle" type="submit">Install selected version</button> <button class="button subtle" type="submit">Install selected version</button>
</form> </form>
<p class="hint">Versions are resolved from immutable repository tags and checked before any live files are replaced. Local databases, settings, plugins, models, uploads, and knowledge remain in place.</p> <p class="hint">The selected core version is read from its immutable repository tag and verified before files are replaced. Local data remains in place.</p>
</fieldset> </div>
</details>
<% } %> <% } %>
<% } %> <% } %>
</div> </div>
@ -259,7 +263,7 @@
</ul> </ul>
<% } %> <% } %>
<div class="inline-actions update-action-row"> <div class="inline-actions update-action-row">
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action> <form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action data-update-check-form>
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit" data-update-check-button>Check for updates</button> <button class="button subtle" type="submit" data-update-check-button>Check for updates</button>
</form> </form>
@ -281,22 +285,26 @@
<% } %> <% } %>
</div> </div>
<% if (plugin.available_versions?.length) { %> <% if (plugin.available_versions?.length) { %>
<fieldset class="lumi-fieldset"> <details class="lumi-expandable-settings update-version-picker">
<legend>Install a specific plugin version</legend> <summary>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" class="input-action-row" data-update-action data-confirm-mode="modal" data-confirm-title="Install selected <%= plugin.name %> version" data-confirm-text="Back up this plugin, preserve its data directory, install the exact tagged release, verify the plugin version, and restart Lumi. Older plugin code may not understand newer data changes." data-confirm-label="Install version"> <span><strong>Install a specific <%= plugin.name %> version</strong><span class="hint">Advanced version selection</span></span>
</summary>
<div class="lumi-expandable-body">
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" class="input-action-row" data-update-action data-confirm-mode="modal" data-confirm-title="Install selected <%= plugin.name %> version" data-confirm-text="Back up this plugin, preserve its data directory, install the exact tagged release, verify the plugin version, and restart Lumi. Older plugin code may not understand newer data changes." data-confirm-label="Install selected version">
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<label class="field"> <label class="field">
<span>Version</span> <span><%= plugin.name %> version</span>
<select name="version" required> <select name="version" required>
<% plugin.available_versions.forEach((release) => { %> <% plugin.available_versions.forEach((release) => { %>
<option value="<%= release.version %>"><%= release.version %><%= plugin.installed !== false && release.version === plugin.current_version ? " (reinstall current)" : "" %> · core <%= release.core_version %></option> <option value="<%= release.version %>"><%= release.version %></option>
<% }) %> <% }) %>
</select> </select>
</label> </label>
<button class="button subtle" type="submit">Install selected version</button> <button class="button subtle" type="submit">Install selected version</button>
</form> </form>
<p class="hint">The plugins <code>data</code> folder is retained. Lumi restarts so the selected plugin code is actually loaded.</p> <p class="hint">Only versions published for <%= plugin.name %> are listed. The plugins <code>data</code> folder is retained, and Lumi restarts to load the selected code.</p>
</fieldset> </div>
</details>
<% } %> <% } %>
</div> </div>
</details> </details>
@ -471,6 +479,7 @@
if (!form.action.endsWith("/check")) return; if (!form.action.endsWith("/check")) return;
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation();
const button = event.submitter || form.querySelector("[data-update-check-button]"); const button = event.submitter || form.querySelector("[data-update-check-button]");
const row = form.closest(".plugin-update-row, [data-update-target='core']"); const row = form.closest(".plugin-update-row, [data-update-target='core']");
setButtonLoading(button, true); setButtonLoading(button, true);

View File

@ -1,14 +1,14 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.2.0", "version": "0.2.1",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-18", "released_at": "2026-07-18",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
"migration_kind": "version_correction", "migration_kind": "patch",
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Corrects the mistakenly published 1.2.0 version to 0.2.0. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.", "migration_notes": "Includes the 1.2.0 version correction and improves update-page behavior. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -24,7 +24,19 @@
"1.2.0" "1.2.0"
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Automatic dependency synchronization and version correction only; preserved local data is not replaced." "migration_notes": "Corrected the mistakenly published 1.2.0 version and repaired core/plugin update boundaries."
},
{
"version": "0.2.1",
"channel": "stable",
"released_at": "2026-07-18",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Update-page behavior and wording fixes only; preserved local data is not replaced."
} }
] ]
} }