fix updates for zip-origin installs

This commit is contained in:
Franz Rolfsvaag 2026-06-17 12:08:35 +02:00
parent 79f4dfe37b
commit 1c329bd551
10 changed files with 893 additions and 308 deletions

21
.gitignore vendored
View File

@ -5,17 +5,18 @@ plugins/*/data/**
!plugins/*/data/**/
!plugins/*/data/**/.gitkeep
.env
.env.*
!.env.example
.bot details.md
*.db
*.db-*
*.sqlite
*.sqlite-*
npm-debug.log
security-audit-*.json
security-audit-*.md
.env.*
!.env.example
.bot details.md
*.db
*.db-*
*.sqlite
*.sqlite-*
npm-debug.log
security-audit-*.json
security-audit-*.md
taskfile.txt
codex-guidelines
Twitch.png
twitch-credentials-lumi.png
.secrets

48
TODO.md Normal file
View File

@ -0,0 +1,48 @@
# TODO
This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note.
## OKF Knowledge System
- Add `knowledge/core`, `knowledge/plugins`, `knowledge/community`, and `knowledge/corrections` directories with documented ownership rules.
- Implement OKF Markdown/frontmatter parsing with stable IDs, scopes, status, priority, tags, generated/editable flags, and timestamps.
- Generate fixed core/plugin OKF from routes, commands, schemas, plugin metadata, README docs, and defaults.
- Add admin-editable community OKF for community names, owner/admin names, bot name, currency, items, roles, lore, links, moderation rules, command rules, and local terminology.
- Add corrections OKF files created only through admin review.
- Implement safe placeholder resolution for core/plugin OKF references such as `{{community.currency.primary_name}}`.
- Preserve source metadata for every retrieved OKF chunk: path, id, heading, score, and excerpt.
## OKF Retrieval and Indexing
- Implement or adapt an OKF indexer that chunks Markdown by heading.
- Re-index only changed OKF files when possible.
- Enforce retrieval priority: active corrections, community OKF, plugin OKF, core OKF.
- Add retrieval boosts for corrections.
- Ensure missing placeholders never crash context injection.
## Feedback Loop
- Add thumbs up/down controls to Lumi chat answers.
- Store feedback payloads with rating, prompt, response, retrieved context, optional comment, model/provider, timestamp, and review status.
- Show an optional comment field for thumbs down and keep thumbs up lightweight.
- Ensure feedback never directly modifies OKF.
## Admin Feedback Review
- Add an admin feedback review page or section.
- Show prompt, response, rating, comment, retrieved OKF sources, model/provider, and timestamp for each feedback item.
- Add review actions: reviewed/no action, good, bad retrieval, bad response, missing knowledge, archive/delete.
- Add correction creation UI with title, tags, affected topic/file, and Markdown body.
- Save approved corrections as OKF files and re-index after save.
- Add links from feedback rows to generated correction files.
## Storage and Preservation
- Store feedback queue, review status, source context snapshots, and correction links in SQLite or existing app storage.
- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates.
- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits.
## Done
- 2026-06-17: Added permanent repo-update architecture for ZIP-origin installs using `data/update-cache/repo`, non-live-git metadata checks, snapshot-copy apply, update-state recording, and preservation of user-owned paths.
- 2026-06-17: Separated Lumi AI `tool_info.json` tools from normal plugin update rows and rendered tools under the `lumi_ai` plugin row.

View File

@ -1,14 +1,19 @@
const path = require("path");
const fs = require("fs");
const { spawnSync } = require("child_process");
const { syncPluginRegistry, setPluginEnabled } = require("./plugins");
const {
createSnapshot,
finalizeSnapshot,
discardSnapshot,
restoreSnapshot
restoreSnapshot,
applyCoreFilesFromDirectory,
applyPluginFiles
} = require("./update-manager");
const { getUpdateStatus } = require("./update-index");
const {
ensureManagedRepo,
writeUpdateState
} = require("./update-repository");
const {
createRecoveryMarker,
updateRecoveryMarker,
@ -19,17 +24,6 @@ const {
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.");
@ -62,9 +56,19 @@ function verifyPluginFiles(pluginId) {
JSON.parse(fs.readFileSync(manifest, "utf8"));
}
async function applyCoreUpdate({ source = "stable", publish } = {}) {
function applyPluginFromRepositorySnapshot(remote, branch, pluginId) {
const managed = ensureManagedRepo(remote, branch);
const pluginRoot = path.join(managed.path, "plugins", pluginId);
if (!fs.existsSync(path.join(pluginRoot, "plugin.json"))) {
throw new Error(`Plugin ${pluginId} was not found in ${branch}.`);
}
applyPluginFiles(pluginRoot, pluginId, { preserveData: true });
return managed;
}
async function applyCoreUpdate({ source = "stable", remote = null, publish } = {}) {
return withOperation("core", async () => {
const status = getUpdateStatus({ source });
const status = getUpdateStatus({ source, remote });
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.");
@ -79,6 +83,7 @@ async function applyCoreUpdate({ source = "stable", publish } = {}) {
major_crossing: target.major_crossing
});
let snapshot = null;
let snapshotRecord = null;
try {
emitProgress(publish, "update:queued", { target: "core" });
emitProgress(publish, "update:checking", { target: "core" });
@ -100,32 +105,64 @@ async function applyCoreUpdate({ source = "stable", publish } = {}) {
danger_notes: target.dangers
}
});
emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshot.id });
snapshotRecord = finalizeSnapshot(snapshot);
emitProgress(publish, "update:snapshot", { target: "core", snapshot_id: snapshotRecord.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]);
const managed = ensureManagedRepo(status.remote, target.source_branch);
emitProgress(publish, "update:apply", { target: "core" });
runGit(["pull", "--ff-only", status.remote, target.source_branch]);
applyCoreFilesFromDirectory(managed.path);
updateRecoveryMarker({ status: "verifying" });
emitProgress(publish, "update:verify", { target: "core" });
verifyCoreFiles();
const record = finalizeSnapshot(snapshot);
const record = snapshotRecord;
markRecoveryMarkerComplete({ snapshot_id: record.id });
writeUpdateState({
remote: managed.repository,
branch: managed.branch,
last_update_at: new Date().toISOString(),
last_update_status: "complete",
last_snapshot_id: record.id,
last_target_kind: "core",
last_target_version: target.safe_target_version
});
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);
if (snapshotRecord) {
try {
restoreSnapshot(snapshotRecord.id, {
expectedType: "bot",
currentVersion: target.safe_target_version,
allowUnsafeMajorRollback: true
});
} catch (restoreError) {
markRecoveryMarkerFailed(restoreError);
}
} else if (snapshot) {
try {
discardSnapshot(snapshot);
} catch {
// Ignore cleanup failures.
}
}
markRecoveryMarkerFailed(error);
writeUpdateState({
last_update_at: new Date().toISOString(),
last_update_status: "failed",
last_target_kind: "core",
last_error: error.message
});
emitProgress(publish, "update:failed", { target: "core", error: error.message });
throw error;
}
});
}
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) {
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote = null, publish } = {}) {
return withOperation(`plugin:${pluginId}`, async () => {
const status = getUpdateStatus({ source });
const status = getUpdateStatus({ source, remote });
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.");
@ -136,11 +173,12 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
from_version: target.current_version,
to_version: target.safe_target_version,
source_branch: target.source_branch,
update_method: "git",
update_method: target.installed === false ? "git_install" : "git",
rollback_safe: target.rollback_safe,
major_crossing: target.major_crossing
});
let snapshot = null;
let snapshotRecord = null;
try {
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
emitProgress(publish, "update:metadata", target);
@ -154,7 +192,7 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
from_version: target.current_version,
to_version: target.safe_target_version,
source_branch: target.source_branch,
update_method: "git",
update_method: target.installed === false ? "git_install" : "git",
rollback_safe: target.rollback_safe,
recovery_marker_id: marker.id,
major_crossing: target.major_crossing,
@ -162,22 +200,56 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
danger_notes: target.dangers
}
});
emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshot.id });
snapshotRecord = finalizeSnapshot(snapshot);
emitProgress(publish, "update:snapshot", { target: "plugin", plugin_id: pluginId, snapshot_id: snapshotRecord.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}`]);
const managed = applyPluginFromRepositorySnapshot(status.remote, target.source_branch, pluginId);
updateRecoveryMarker({ status: "verifying" });
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
verifyPluginFiles(pluginId);
syncPluginRegistry();
const record = finalizeSnapshot(snapshot);
const record = snapshotRecord;
markRecoveryMarkerComplete({ snapshot_id: record.id });
writeUpdateState({
remote: managed.repository,
branch: managed.branch,
last_update_at: new Date().toISOString(),
last_update_status: "complete",
last_snapshot_id: record.id,
last_target_kind: "plugin",
last_target_id: pluginId,
last_target_version: target.safe_target_version
});
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);
if (snapshotRecord) {
try {
restoreSnapshot(snapshotRecord.id, {
expectedType: "plugin",
expectedPluginId: pluginId,
currentVersion: target.safe_target_version,
allowUnsafeMajorRollback: true
});
} catch (restoreError) {
markRecoveryMarkerFailed(restoreError);
}
} else if (snapshot) {
try {
discardSnapshot(snapshot);
} catch {
// Ignore cleanup failures.
}
}
markRecoveryMarkerFailed(error);
writeUpdateState({
last_update_at: new Date().toISOString(),
last_update_status: "failed",
last_target_kind: "plugin",
last_target_id: pluginId,
last_error: error.message
});
emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message });
throw error;
}

View File

@ -1,6 +1,5 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { getSetting } = require("./settings");
const { scanPluginDirectories, getPlugins } = require("./plugins");
const {
@ -12,58 +11,21 @@ const {
} = require("./versioning");
const { listSnapshots } = require("./update-manager");
const { safeModeStatus } = require("./recovery-mode");
const {
createMetadataReader,
normalizeRepositoryTarget,
resolveSourceBranch
} = require("./update-repository");
const repoRoot = path.join(__dirname, "..", "..");
const LUMI_AI_TOOL_ID = /^lumi_ai_[a-z0-9_-]+$/i;
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 isLumiAiToolId(id) {
return LUMI_AI_TOOL_ID.test(String(id || "")) && String(id || "") !== "lumi_ai";
}
function tryGit(args, fallback = "") {
try {
return runGit(args);
} catch {
return fallback;
}
}
function fetchRemote(remote, branch = null) {
const args = ["fetch", "--prune", remote];
if (branch) args.push(branch);
runGit(args);
}
function normalizeRepositoryTarget(value) {
const target = String(value || "origin").trim() || "origin";
if (/^(?:https?:\/\/|ssh:\/\/|git@)/i.test(target)) {
return target.replace(/\.git\/?$/i, "").replace(/\/+$/, "");
}
return target;
}
function isRepositoryUrl(value) {
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(String(value || ""));
}
function remoteRef(remote, branch) {
return isRepositoryUrl(remote) ? "FETCH_HEAD" : `${remote}/${branch}`;
}
function readGitFile(ref, filePath) {
const output = tryGit(["show", `${ref}:${filePath}`], "");
return output || null;
}
function readJsonGitFile(ref, filePath) {
const raw = readGitFile(ref, filePath);
function readJsonFromReader(reader, filePath) {
const raw = reader.readFile(filePath);
if (!raw) return null;
try {
return JSON.parse(raw);
@ -80,30 +42,6 @@ function readLocalJson(filePath) {
}
}
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 = [];
@ -122,11 +60,11 @@ function parseMarkdownChangelog(raw) {
return entries;
}
function changelogEntries(ref, basePath = "") {
const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json"));
function changelogEntries(reader, basePath = "") {
const json = readJsonFromReader(reader, 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"));
const md = reader.readFile(path.posix.join(basePath, "CHANGELOG.md"));
return parseMarkdownChangelog(md);
}
@ -142,10 +80,10 @@ function manifestRawVersion(manifest, fallbackVersion) {
return String(manifest?.version || fallbackVersion || "").trim();
}
function coreManifest(ref) {
return readJsonGitFile(ref, "update-manifest.json") ||
readJsonGitFile(ref, "lumi.manifest.json") ||
readJsonGitFile(ref, "package.json") ||
function coreManifest(reader) {
return readJsonFromReader(reader, "update-manifest.json") ||
readJsonFromReader(reader, "lumi.manifest.json") ||
readJsonFromReader(reader, "package.json") ||
{};
}
@ -171,7 +109,116 @@ function snapshotAvailability(kind, id = null) {
};
}
function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) {
function localPluginCandidates(registry) {
const candidates = new Map();
for (const plugin of scanPluginDirectories()) {
candidates.set(plugin.id, { ...plugin, installed: true });
}
for (const plugin of registry.values()) {
if (isLumiAiToolId(plugin.id)) {
continue;
}
if (candidates.has(plugin.id)) {
continue;
}
const pluginPath = plugin.path || "";
const installed = Boolean(pluginPath && fs.existsSync(path.join(pluginPath, "plugin.json")));
candidates.set(plugin.id, {
id: plugin.id,
name: plugin.name || plugin.id,
version: plugin.version || "0.0.0",
description: "",
main: "index.js",
dir: pluginPath,
installed
});
}
return candidates;
}
function localToolCandidates() {
const tools = new Map();
const pluginsPath = path.join(repoRoot, "plugins");
let entries = [];
try {
entries = fs.readdirSync(pluginsPath, { withFileTypes: true });
} catch {
return tools;
}
for (const entry of entries) {
if (!entry.isDirectory() || !isLumiAiToolId(entry.name)) {
continue;
}
const metadataPath = path.join(pluginsPath, entry.name, "tool_info.json");
if (!fs.existsSync(metadataPath)) {
continue;
}
try {
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
tools.set(entry.name, {
id: metadata.tool_id || entry.name,
dir: path.join(pluginsPath, entry.name),
metadata,
installed: true
});
} catch {
continue;
}
}
return tools;
}
function toolDisplayName(metadata, id) {
return metadata?.display_name || metadata?.name || id;
}
function buildToolStatus({ id, localMetadata, remoteMetadata, sourceBranch, installed = true }) {
const metadata = remoteMetadata || localMetadata || {};
const currentVersion = installed
? manifestRawVersion(localMetadata, localMetadata?.version) || "0.0.0"
: "0.0.0";
const targetVersion = manifestRawVersion(remoteMetadata, remoteMetadata?.version) || currentVersion;
const currentIsVersioned = Boolean(parseSemver(currentVersion));
const targetIsVersioned = Boolean(parseSemver(targetVersion));
const updateAvailable = installed === false
? Boolean(remoteMetadata)
: Boolean(remoteMetadata) && (
currentIsVersioned && targetIsVersioned
? compareSemver(targetVersion, currentVersion) > 0
: targetVersion !== currentVersion
);
const warnings = [];
if (!remoteMetadata) warnings.push("Remote tool metadata is missing.");
if (remoteMetadata?.remote_invalid) warnings.push(remoteMetadata.remote_error || "Remote tool metadata is invalid.");
return {
kind: "tool",
id,
tool_id: metadata.tool_id || id,
name: toolDisplayName(metadata, id),
installed,
current_version: currentVersion,
latest_available_version: targetVersion,
safe_target_version: targetVersion || null,
update_available: updateAvailable,
blocked: Boolean(remoteMetadata?.remote_invalid),
blocked_reason: remoteMetadata?.remote_error || null,
source_branch: sourceBranch,
channel: "lumi_ai",
version_description: installed === false
? `Available to install at ${targetVersion || "unknown"}`
: `${currentVersion} -> ${targetVersion || currentVersion}`,
description: metadata.description || "",
capabilities: Array.isArray(metadata.capabilities) ? metadata.capabilities : [],
limitations: Array.isArray(metadata.limitations) ? metadata.limitations : [],
minimum_lumi_ai_version: metadata.minimum_lumi_ai_version || "",
repository_path: metadata.repository_path || `plugins/${id}`,
warnings,
dangers: remoteMetadata?.remote_invalid ? [remoteMetadata.remote_error || "Remote tool metadata is invalid."] : [],
metadata
};
}
function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel, installed = true }) {
const versions = manifestVersions(manifest, manifest?.version);
const rawVersion = manifestRawVersion(manifest);
const targetVersion = rawVersion || latestEntry(versions)?.version || "";
@ -206,6 +253,7 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
return {
kind,
id: id || kind,
installed,
name: name || manifest?.name || id || "Lumi core",
current_version: currentVersion,
latest_available_version: latest?.version || rawVersion || currentVersion,
@ -236,69 +284,132 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
};
}
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 = normalizeRepositoryTarget(options.remote || getSetting("git_remote", "origin"));
const configuredRemote = normalizeRepositoryTarget(options.remote || getSetting("git_remote", "origin"));
const requestedSource = options.source || "stable";
const sourceBranch = resolveSourceBranch(remote, requestedSource);
fetchRemote(remote, isRepositoryUrl(remote) ? sourceBranch : null);
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,
const sourceBranch = resolveSourceBranch(configuredRemote, requestedSource);
const reader = createMetadataReader(configuredRemote, sourceBranch);
const remote = reader.repository || configuredRemote;
try {
const core = buildStatus({
kind: "core",
currentVersion: localCoreVersion(),
manifest: coreManifest(reader),
changelog: changelogEntries(reader),
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()
};
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
const remoteDirs = new Set(reader.listPluginDirs());
const remotePluginDirs = new Set();
const remoteTools = new Map();
const candidates = localPluginCandidates(registry);
for (const pluginId of remoteDirs) {
if (isLumiAiToolId(pluginId)) {
const toolMetadata = readJsonFromReader(reader, `plugins/${pluginId}/tool_info.json`);
if (toolMetadata) {
remoteTools.set(pluginId, {
...toolMetadata,
repository_path: toolMetadata.repository_path || `plugins/${pluginId}`
});
continue;
}
}
const manifest = readJsonFromReader(reader, `plugins/${pluginId}/plugin.json`);
if (!manifest) {
continue;
}
remotePluginDirs.add(pluginId);
if (candidates.has(pluginId)) {
continue;
}
candidates.set(pluginId, {
id: pluginId,
name: manifest.name || pluginId,
version: "0.0.0",
description: manifest.description || "",
main: manifest.main || "index.js",
dir: "",
installed: false
});
}
const installed = Array.from(candidates.values());
const plugins = installed.map((plugin) => {
const basePath = `plugins/${plugin.id}`;
const manifest = readJsonFromReader(reader, `${basePath}/plugin.json`) || {};
const changelog = changelogEntries(reader, 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: remotePluginDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name },
changelog,
sourceBranch,
channel: requestedSource === "experimental" ? "experimental" : "stable",
installed: plugin.installed !== false
});
});
const localTools = localToolCandidates();
for (const [toolId, tool] of localTools.entries()) {
if (!remoteTools.has(toolId)) {
remoteTools.set(toolId, null);
}
}
const aiTools = Array.from(new Set([
...Array.from(localTools.keys()),
...Array.from(remoteTools.keys())
])).sort((a, b) => a.localeCompare(b)).map((toolId) => {
const localTool = localTools.get(toolId);
return buildToolStatus({
id: toolId,
localMetadata: localTool?.metadata || null,
remoteMetadata: remoteTools.get(toolId) || null,
sourceBranch,
installed: localTool?.installed === true
});
});
const lumiAiPlugin = plugins.find((plugin) => plugin.id === "lumi_ai");
if (lumiAiPlugin) {
lumiAiPlugin.tools = aiTools;
lumiAiPlugin.tools_summary = {
installed_tools: aiTools.filter((tool) => tool.installed !== false).length,
total_tools: aiTools.length,
updatable_tools: aiTools.filter((tool) => tool.update_available && tool.installed !== false).length,
available_tools: aiTools.filter((tool) => tool.installed === false).length,
blocked_tools: aiTools.filter((tool) => tool.blocked).length
};
}
return {
generated_at: new Date().toISOString(),
source_branch: sourceBranch,
requested_source: requestedSource,
remote,
core,
plugins,
ai_tools: aiTools,
tools_summary: {
installed_tools: aiTools.filter((tool) => tool.installed !== false).length,
total_tools: aiTools.length,
updatable_tools: aiTools.filter((tool) => tool.update_available && tool.installed !== false).length,
available_tools: aiTools.filter((tool) => tool.installed === false).length,
blocked_tools: aiTools.filter((tool) => tool.blocked).length
},
plugins_summary: {
installed_plugins: installed.filter((plugin) => plugin.installed !== false).length,
total_plugins: remotePluginDirs.size || installed.length,
updatable_plugins: plugins.filter((plugin) => plugin.update_available).length,
blocked_plugins: plugins.filter((plugin) => plugin.blocked).length
},
recovery: safeModeStatus()
};
} finally {
reader.cleanup();
}
}
module.exports = {
getUpdateStatus,
resolveSourceBranch,
fetchRemote,
runGit,
readGitFile
resolveSourceBranch
};

View File

@ -21,6 +21,36 @@ const dataDir = path.join(repoRoot, "data");
const snapshotsDir = path.join(dataDir, "snapshots");
const indexPath = path.join(snapshotsDir, "index.json");
const maxSnapshots = 20;
const PRESERVE_RELATIVE_PATHS = new Set([
".git",
"node_modules",
"data",
"config",
"storage",
"uploads",
"logs",
"database",
"databases",
"plugins",
"knowledge/community",
"knowledge/corrections",
".env",
".env.local",
".env.production",
".secrets",
"codex-guidelines"
]);
const GENERATED_RELATIVE_PATHS = new Set([
"dist",
"build",
"coverage",
".cache",
".parcel-cache",
".turbo",
"tmp",
"temp",
"data/update-cache"
]);
function ensureSnapshotsDir() {
fs.mkdirSync(snapshotsDir, { recursive: true });
@ -66,9 +96,12 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
let pluginExisted = false;
let pluginZip = null;
let fullZip = null;
if (type === "bot") {
const coreZip = path.join(snapshotPath, "core.zip");
zipCore(coreZip);
fullZip = path.join(snapshotPath, "full.zip");
zipFullInstall(fullZip);
}
if (type === "plugin" && pluginId) {
@ -83,7 +116,7 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
}
}
return { id, type, pluginId, pluginExisted, pluginZip, snapshotPath, metadata };
return { id, type, pluginId, pluginExisted, pluginZip, fullZip, snapshotPath, metadata };
}
function finalizeSnapshot(snapshot) {
@ -187,15 +220,9 @@ function resolveZipRoot(extractedDir) {
}
function resolvePatchRoot(extractedDir) {
const entries = fs.readdirSync(extractedDir, { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
const files = entries.filter((entry) => entry.isFile());
if (files.length > 0) {
return extractedDir;
}
if (dirs.length === 1) {
return path.join(extractedDir, dirs[0].name);
}
// Patch archives are copied relative to the repository root. Do not infer a
// nested root from a single top-level directory such as src/, or patches will
// be applied to services/ and web/ instead of src/services/ and src/web/.
return extractedDir;
}
@ -262,6 +289,15 @@ function zipCore(destination) {
zip.writeZip(destination);
}
function zipFullInstall(destination) {
if (!AdmZip) {
throw new Error("adm-zip is not installed. Run npm install.");
}
const zip = new AdmZip();
addFolder(zip, repoRoot, repoRoot, new Set([".git", "node_modules", "data/update-cache", "data/snapshots"]));
zip.writeZip(destination);
}
function zipFolder(source, destination, options) {
if (!AdmZip) {
throw new Error("adm-zip is not installed. Run npm install.");
@ -276,9 +312,9 @@ function addFolder(zip, folderPath, basePath, ignore) {
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(folderPath, entry.name);
const relPath = path.relative(basePath, fullPath);
const topLevel = relPath.split(path.sep)[0];
if (ignore.has(topLevel)) {
const relPath = normalizeRelative(path.relative(basePath, fullPath));
const topLevel = relPath.split("/")[0];
if (ignore.has(relPath) || ignore.has(topLevel)) {
continue;
}
if (entry.isDirectory()) {
@ -301,29 +337,60 @@ function resetCoreFiles() {
}
}
function copyDirectory(source, target, ignore) {
function normalizeRelative(value) {
return String(value || "").split(path.sep).join("/");
}
function isRelativePathIgnored(relativePath, ignore) {
const rel = normalizeRelative(relativePath);
if (!rel) return false;
if (ignore.has(rel)) return true;
const parts = rel.split("/");
for (let index = 1; index <= parts.length; index += 1) {
if (ignore.has(parts.slice(0, index).join("/"))) {
return true;
}
}
return false;
}
function copyDirectory(source, target, ignore, options = {}) {
const base = options.base || source;
const entries = fs.readdirSync(source, { withFileTypes: true });
for (const entry of entries) {
if (ignore.has(entry.name)) {
const srcPath = path.join(source, entry.name);
const relPath = normalizeRelative(path.relative(base, srcPath));
if (isRelativePathIgnored(relPath, ignore)) {
continue;
}
const srcPath = path.join(source, entry.name);
const destPath = path.join(target, entry.name);
if (entry.isDirectory()) {
fs.mkdirSync(destPath, { recursive: true });
copyDirectory(srcPath, destPath, ignore);
copyDirectory(srcPath, destPath, ignore, { base });
} else if (entry.isFile()) {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(srcPath, destPath);
}
}
}
function applyCoreUpdate(rootPath) {
resetCoreFiles();
function removeGeneratedPaths() {
for (const relativePath of GENERATED_RELATIVE_PATHS) {
const target = path.join(repoRoot, relativePath);
if (target.startsWith(path.join(repoRoot, "data", "snapshots"))) {
continue;
}
fs.rmSync(target, { recursive: true, force: true });
}
}
function applyCoreFilesFromDirectory(rootPath) {
removeGeneratedPaths();
copyDirectory(
rootPath,
repoRoot,
new Set([".git", "node_modules", "data", "plugins"])
PRESERVE_RELATIVE_PATHS,
{ base: rootPath }
);
}
@ -331,7 +398,8 @@ function applyCorePatch(rootPath) {
copyDirectory(
rootPath,
repoRoot,
new Set([".git", "node_modules", "data", "plugins"])
PRESERVE_RELATIVE_PATHS,
{ base: rootPath }
);
}
@ -425,7 +493,7 @@ async function applyBotUpdate(zipPath, options = {}) {
if (mode === "patch") {
applyCorePatch(rootPath);
} else {
applyCoreUpdate(rootPath);
applyCoreFilesFromDirectory(rootPath);
}
const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id });
@ -525,14 +593,16 @@ function restoreSnapshot(id, options = {}) {
}
if (entry.type === "bot") {
const fullZip = path.join(entry.path, "full.zip");
const coreZip = path.join(entry.path, "core.zip");
if (!fs.existsSync(coreZip)) {
const restoreZip = fs.existsSync(fullZip) ? fullZip : coreZip;
if (!fs.existsSync(restoreZip)) {
throw new Error("Snapshot core archive missing.");
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
extractZip(coreZip, tempDir);
extractZip(restoreZip, tempDir);
const rootPath = resolveZipRoot(tempDir);
applyCoreUpdate(rootPath);
applyCoreFilesFromDirectory(rootPath);
fs.rmSync(tempDir, { recursive: true, force: true });
}
@ -565,6 +635,7 @@ module.exports = {
createSnapshot,
finalizeSnapshot,
discardSnapshot,
applyCoreFilesFromDirectory,
applyPluginFiles,
resetPluginCode,
replacePluginDirectory,

View File

@ -0,0 +1,258 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const repoRoot = path.join(__dirname, "..", "..");
const dataDir = path.join(repoRoot, "data");
const updateCacheDir = path.join(dataDir, "update-cache");
const managedRepoDir = path.join(updateCacheDir, "repo");
const updateStatePath = path.join(dataDir, "update-state.json");
const DEFAULT_REPOSITORY = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi";
const PRESERVED_PATHS = [
"data",
"config",
"storage",
"uploads",
"logs",
"database",
"databases",
"plugins",
"knowledge/community",
"knowledge/corrections",
".env",
".env.local",
".env.production",
".secrets",
"codex-guidelines"
];
function runGit(args, options = {}) {
const result = spawnSync("git", args, {
cwd: options.cwd || repoRoot,
encoding: "utf8",
timeout: options.timeout || 120000
});
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
}
return result.stdout.trim();
}
function tryGit(args, options = {}, fallback = "") {
try {
return runGit(args, options);
} catch {
return fallback;
}
}
function isRepositoryUrl(value) {
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(String(value || ""));
}
function normalizeRepositoryTarget(value) {
const target = String(value || DEFAULT_REPOSITORY).trim() || DEFAULT_REPOSITORY;
if (isRepositoryUrl(target)) {
return target.replace(/\.git\/?$/i, "").replace(/\/+$/, "");
}
return target;
}
function isGitRepository(directory = repoRoot) {
return tryGit(["rev-parse", "--is-inside-work-tree"], { cwd: directory }, "") === "true";
}
function readUpdateState() {
try {
const state = JSON.parse(fs.readFileSync(updateStatePath, "utf8"));
return state && typeof state === "object" ? state : {};
} catch {
return {};
}
}
function writeUpdateState(values = {}) {
fs.mkdirSync(dataDir, { recursive: true });
const current = readUpdateState();
const next = {
schema_version: 1,
preserve_paths: PRESERVED_PATHS,
managed_repo_path: managedRepoDir,
updated_at: new Date().toISOString(),
...current,
...values
};
fs.writeFileSync(updateStatePath, JSON.stringify(next, null, 2), "utf8");
return next;
}
function resolveRepositoryRemote(value) {
const target = normalizeRepositoryTarget(value);
if (isRepositoryUrl(target)) {
return target;
}
const state = readUpdateState();
if (isRepositoryUrl(state.remote)) {
return normalizeRepositoryTarget(state.remote);
}
if (isGitRepository(managedRepoDir)) {
const cached = tryGit(["remote", "get-url", target], { cwd: managedRepoDir }, "");
if (cached) return normalizeRepositoryTarget(cached);
const origin = tryGit(["remote", "get-url", "origin"], { cwd: managedRepoDir }, "");
if (origin) return normalizeRepositoryTarget(origin);
}
if (isGitRepository(repoRoot)) {
const live = tryGit(["remote", "get-url", target], { cwd: repoRoot }, "");
if (live) return normalizeRepositoryTarget(live);
}
if (target === "origin") {
return DEFAULT_REPOSITORY;
}
throw new Error("Configure Git remote / repository target as a repository URL for ZIP-origin installs.");
}
function resolveSourceBranch(remote, requested = "stable") {
const repository = resolveRepositoryRemote(remote);
if (requested === "experimental") {
const refs = runGit(["ls-remote", "--heads", repository, "experimental-*"], { timeout: 60000 });
const branches = refs
.split(/\r?\n/)
.map((line) => line.trim().split(/\s+/)[1] || "")
.map((ref) => ref.replace(/^refs\/heads\//, ""))
.filter(Boolean)
.sort((a, b) => b.localeCompare(a));
if (branches[0]) return branches[0];
return "experimental";
}
if (requested && requested !== "stable" && requested !== "main") {
return String(requested).replace(/^origin\//, "");
}
return "main";
}
function migrateZipEraLayout(remote) {
fs.mkdirSync(updateCacheDir, { recursive: true });
const state = readUpdateState();
if (state.migrated_zip_layout_at) {
return state;
}
return writeUpdateState({
migrated_zip_layout_at: new Date().toISOString(),
migration: "zip-era-layout",
remote: resolveRepositoryRemote(remote),
live_install_git_repository: isGitRepository(repoRoot)
});
}
function resetManagedRepo(repository) {
fs.rmSync(managedRepoDir, { recursive: true, force: true });
fs.mkdirSync(updateCacheDir, { recursive: true });
runGit(["clone", repository, managedRepoDir], {
cwd: updateCacheDir,
timeout: 300000
});
}
function ensureManagedRepo(remote, branch) {
const repository = resolveRepositoryRemote(remote);
migrateZipEraLayout(repository);
fs.mkdirSync(updateCacheDir, { recursive: true });
if (!isGitRepository(managedRepoDir)) {
resetManagedRepo(repository);
} else {
const currentRemote = normalizeRepositoryTarget(
tryGit(["remote", "get-url", "origin"], { cwd: managedRepoDir }, "")
);
if (currentRemote && currentRemote !== repository) {
runGit(["remote", "set-url", "origin", repository], { cwd: managedRepoDir });
} else if (!currentRemote) {
resetManagedRepo(repository);
}
}
runGit(["fetch", "--prune", "origin"], {
cwd: managedRepoDir,
timeout: 300000
});
runGit(["checkout", "-B", branch, `origin/${branch}`], {
cwd: managedRepoDir,
timeout: 120000
});
runGit(["reset", "--hard", `origin/${branch}`], {
cwd: managedRepoDir,
timeout: 120000
});
runGit(["clean", "-fdx"], {
cwd: managedRepoDir,
timeout: 120000
});
writeUpdateState({
remote: repository,
branch,
managed_repo_path: managedRepoDir,
last_fetch_at: new Date().toISOString(),
live_install_git_repository: isGitRepository(repoRoot)
});
return {
repository,
branch,
path: managedRepoDir
};
}
function createMetadataReader(remote, branch) {
const managed = ensureManagedRepo(remote, branch);
return {
repository: managed.repository,
branch: managed.branch,
root: managed.path,
readFile(filePath) {
const target = path.resolve(managed.path, filePath);
if (!target.startsWith(managed.path + path.sep) && target !== managed.path) {
return null;
}
try {
return fs.readFileSync(target, "utf8");
} catch {
return null;
}
},
listPluginDirs() {
const pluginsPath = path.join(managed.path, "plugins");
try {
return fs.readdirSync(pluginsPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
return [];
}
},
cleanup() {}
};
}
module.exports = {
DEFAULT_REPOSITORY,
PRESERVED_PATHS,
updateCacheDir,
managedRepoDir,
updateStatePath,
runGit,
isRepositoryUrl,
isGitRepository,
normalizeRepositoryTarget,
resolveRepositoryRemote,
resolveSourceBranch,
migrateZipEraLayout,
ensureManagedRepo,
createMetadataReader,
readUpdateState,
writeUpdateState
};

View File

@ -1,111 +1,33 @@
const path = require("path");
const fs = require("fs");
const os = require("os");
const { spawnSync } = require("child_process");
let AdmZip = null;
try {
AdmZip = require("adm-zip");
} catch {
AdmZip = null;
}
const { applyBotUpdate } = require("./update-manager");
const { getUpdateStatus } = require("./update-index");
const { applyCoreUpdate } = require("./repo-update");
const {
normalizeRepositoryTarget,
resolveSourceBranch
} = require("./update-repository");
const repoRoot = path.join(__dirname, "..", "..");
function runGit(args, options = {}) {
const result = spawnSync("git", args, {
cwd: options.cwd || repoRoot,
encoding: "utf8"
});
if (result.status !== 0) {
throw new Error(result.stderr || "Git command failed.");
}
return result.stdout.trim();
}
function isGitRepository() {
try {
return runGit(["rev-parse", "--is-inside-work-tree"]) === "true";
} catch {
return false;
}
}
function normalizeRepositoryTarget(value) {
const target = String(value || "origin").trim() || "origin";
if (/^(?:https?:\/\/|ssh:\/\/|git@)/i.test(target)) {
return target.replace(/\.git\/?$/i, "").replace(/\/+$/, "");
}
return target;
}
function remoteRef(remote, branch) {
return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(remote)
? "FETCH_HEAD"
: `${remote}/${branch}`;
function normalizeUpdateSource(branch) {
const requested = String(branch || "main").trim();
if (requested === "main" || requested === "stable") return "stable";
if (requested === "experimental") return "experimental";
return requested || "stable";
}
function checkForUpdates(remote, branch) {
const target = normalizeRepositoryTarget(remote);
if (!isGitRepository()) {
runGit(["ls-remote", "--heads", target, branch]);
return true;
}
runGit(["fetch", target, branch]);
const count = runGit([
"rev-list",
`HEAD..${remoteRef(target, branch)}`,
"--count"
]);
return Number(count) > 0;
const source = normalizeUpdateSource(branch);
const status = getUpdateStatus({ remote: target, source });
return Boolean(status.core?.update_available);
}
async function pullUpdates(remote, branch) {
const target = normalizeRepositoryTarget(remote);
if (isGitRepository()) {
return runGit(["pull", target, branch]);
}
return applyRepositorySnapshot(target, branch);
const source = normalizeUpdateSource(branch);
const result = await applyCoreUpdate({ remote: target, source });
return `Applied core update from ${result.target.source_branch}.`;
}
async function applyRepositorySnapshot(remote, branch) {
if (!AdmZip) {
throw new Error("adm-zip is not installed. Run npm install before applying repository updates from a ZIP install.");
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repo-update-"));
const cloneDir = path.join(tempDir, "repo");
const zipPath = path.join(tempDir, "core.zip");
try {
runGit(["clone", "--depth", "1", "--branch", branch, remote, cloneDir], { cwd: tempDir });
const zip = new AdmZip();
addFolderToZip(zip, cloneDir, cloneDir, new Set([".git", "node_modules", "data", "plugins", "updates"]));
zip.writeZip(zipPath);
await applyBotUpdate(zipPath, {
mode: "full",
metadata: {
source_branch: branch,
update_method: "repo_clone",
rollback_safe: false
}
});
return `Applied core update from ${remote} (${branch}).`;
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function addFolderToZip(zip, folderPath, basePath, ignore) {
for (const entry of fs.readdirSync(folderPath, { withFileTypes: true })) {
const fullPath = path.join(folderPath, entry.name);
const relPath = path.relative(basePath, fullPath);
const topLevel = relPath.split(path.sep)[0];
if (ignore.has(topLevel)) continue;
if (entry.isDirectory()) {
addFolderToZip(zip, fullPath, basePath, ignore);
} else if (entry.isFile()) {
zip.addLocalFile(fullPath, path.dirname(relPath) === "." ? "" : path.dirname(relPath));
}
}
function resolveUpdateBranch(remote, branch) {
return resolveSourceBranch(normalizeRepositoryTarget(remote), normalizeUpdateSource(branch));
}
function requestRestart() {
@ -116,5 +38,6 @@ module.exports = {
checkForUpdates,
pullUpdates,
normalizeRepositoryTarget,
resolveUpdateBranch,
requestRestart
};

View File

@ -4926,9 +4926,10 @@ function createWebServer({ loadPlugins, discordClient }) {
app.get("/admin/plugins", requireRole("admin"), (req, res) => {
syncPluginRegistry();
const plugins = getPlugins().filter((plugin) => !/^lumi_ai_[a-z0-9_-]+$/i.test(plugin.id || ""));
res.render("admin-plugins", {
title: "Plugins",
plugins: getPlugins()
plugins
});
});
@ -5099,17 +5100,33 @@ function createWebServer({ loadPlugins, discordClient }) {
app.get("/admin/updates", requireRole("admin"), (req, res) => {
let updateStatus = null;
let updateStatusError = null;
let snapshots = [];
let recoveryStatus = {};
try {
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
} catch (error) {
updateStatusError = error.message;
}
try {
snapshots = listSnapshots();
} catch (error) {
updateStatusError = [updateStatusError, `Snapshots could not be loaded: ${error.message}`]
.filter(Boolean)
.join(" ");
}
try {
recoveryStatus = safeModeStatus();
} catch (error) {
updateStatusError = [updateStatusError, `Recovery status could not be loaded: ${error.message}`]
.filter(Boolean)
.join(" ");
}
res.render("admin-updates", {
title: "Updates",
snapshots: listSnapshots(),
snapshots,
updateStatus,
updateStatusError,
recoveryStatus: safeModeStatus()
recoveryStatus
});
});

View File

@ -36,10 +36,15 @@
<input name="git_remote" value="<%= settings.git_remote || 'origin' %>" placeholder="origin or https://git.example/owner/repo(.git)" />
<p class="hint">Use a remote alias such as <code>origin</code> or a repository URL. The <code>.git</code> suffix is optional.</p>
</div>
<div class="field">
<label>Git branch</label>
<input name="git_branch" value="<%= settings.git_branch || 'main' %>" />
</div>
<div class="field">
<label>Update branch</label>
<% const selectedUpdateBranch = settings.git_branch === 'experimental' ? 'experimental' : 'main'; %>
<select name="git_branch">
<option value="main" <%= selectedUpdateBranch === 'main' ? 'selected' : '' %>>Stable</option>
<option value="experimental" <%= selectedUpdateBranch === 'experimental' ? 'selected' : '' %>>Experimental</option>
</select>
<p class="hint">Stable uses <code>main</code>. Experimental uses the newest <code>experimental-*</code> branch.</p>
</div>
<div class="field full">
<div class="inline-actions">
<%- include("partials/state-button", {

View File

@ -9,6 +9,8 @@
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 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 changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
const applyConfirmText = (item, label) => {
@ -32,8 +34,8 @@
<label class="field compact">
<span>Update source</span>
<select name="source">
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option>
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option>
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable</option>
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Experimental</option>
</select>
</label>
<button class="button subtle" type="submit">Reload metadata</button>
@ -41,6 +43,35 @@
<p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
</section>
<section class="card">
<h2>Manual ZIP updates</h2>
<p class="hint">These upload paths stay available even when repository metadata cannot be loaded.</p>
<div class="grid">
<div class="card">
<h3>Core ZIP</h3>
<p class="hint">Use a full core ZIP or a patch ZIP containing files relative to the repo root.</p>
<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>
</div>
<div class="card">
<h3>Plugin ZIP</h3>
<p class="hint">Use this fallback when plugin metadata is unavailable. Plugin ZIPs must include a valid <code>plugin.json</code>.</p>
<form method="post" action="/admin/updates/plugin" enctype="multipart/form-data" class="form-grid">
<div class="field full input-action-row">
<input type="file" name="plugin_zip" accept=".zip" required />
<button type="submit" class="button">Upload plugin ZIP</button>
</div>
</form>
</div>
</div>
</section>
<% if (recovery.has_incomplete_marker || marker) { %>
<section class="card update-recovery-banner">
<h2>Safe Mode / Recovery</h2>
@ -162,7 +193,7 @@
<summary>
<span>
<strong><%= plugin.name %></strong>
<span class="hint"><%= plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
<span class="hint"><%= plugin.installed === false ? "Not installed" : plugin.current_version %> -> <%= plugin.safe_target_version || "none" %> · <%= plugin.source_branch %></span>
</span>
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
</summary>
@ -174,6 +205,52 @@
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
</div>
<p><%= plugin.version_description %></p>
<% const tools = Array.isArray(plugin.tools) ? plugin.tools : []; %>
<% if (plugin.id === "lumi_ai" && tools.length) { %>
<% const toolSummary = plugin.tools_summary || {}; %>
<details class="lumi-expandable-settings plugin-tool-list" <%= tools.some((tool) => tool.blocked || tool.update_available || tool.installed === false) ? "open" : "" %>>
<summary>
<span>
<strong>Lumi AI tools</strong>
<span class="hint"><%= toolSummary.installed_tools || 0 %> installed · <%= toolSummary.total_tools || tools.length %> known · <%= toolSummary.updatable_tools || 0 %> updatable · <%= toolSummary.available_tools || 0 %> available</span>
</span>
<span class="badge <%= toolSummary.blocked_tools ? "danger" : (toolSummary.updatable_tools || toolSummary.available_tools) ? "warning" : "success" %>"><%= toolSummary.blocked_tools ? `${toolSummary.blocked_tools} blocked` : `${(toolSummary.updatable_tools || 0) + (toolSummary.available_tools || 0)} actions` %></span>
</summary>
<div class="lumi-expandable-body plugin-update-list">
<% tools.forEach((tool) => { %>
<details class="lumi-expandable-settings plugin-update-row" <%= tool.blocked || tool.update_available || tool.installed === false ? "open" : "" %>>
<summary>
<span>
<strong><%= tool.name %></strong>
<span class="hint"><%= tool.installed === false ? "Not installed" : tool.current_version %> -> <%= tool.safe_target_version || "none" %> · <%= tool.repository_path || `plugins/${tool.id}` %></span>
</span>
<span class="badge <%= toolBadgeClass(tool) %>"><%= toolBadgeText(tool) %></span>
</summary>
<div class="lumi-expandable-body update-detail-grid">
<div class="update-meta-grid">
<div><span>Current</span><strong><%= tool.installed === false ? "Not installed" : tool.current_version %></strong></div>
<div><span>Target</span><strong><%= tool.safe_target_version || "None" %></strong></div>
<div><span>Minimum Lumi AI</span><strong><%= tool.minimum_lumi_ai_version || "Not specified" %></strong></div>
<div><span>Source branch</span><strong><%= tool.source_branch %></strong></div>
</div>
<% if (tool.description) { %><p><%= tool.description %></p><% } %>
<% if (tool.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% tool.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (tool.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% tool.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (tool.capabilities?.length) { %>
<div>
<h3>Capabilities</h3>
<ul class="update-changelog">
<% tool.capabilities.forEach((item) => { %><li><span><%= item %></span></li><% }) %>
</ul>
</div>
<% } %>
<p class="hint">Lumi AI tools are installed and managed by the Lumi AI tool manager. They are not normal plugin registry entries.</p>
</div>
</details>
<% }) %>
</div>
</details>
<% } %>
<% if (plugin.warnings?.length) { %><div class="callout"><strong>Warnings</strong><ul><% plugin.warnings.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
<% if (plugin.unversioned_update) { %><div class="callout danger"><strong>Manual confirmation required</strong><p>This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.</p></div><% } %>
<% if (plugin.dangers?.length) { %><div class="callout danger"><strong>Dangers</strong><ul><% plugin.dangers.forEach((item) => { %><li><%= item %></li><% }) %></ul></div><% } %>
@ -195,7 +272,7 @@
</form>
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/apply" data-update-action data-confirm-mode="modal" data-confirm-title="<%= plugin.unversioned_update ? `Apply unversioned ${plugin.name} update` : "Apply plugin update" %>" data-confirm-text="<%= applyConfirmText(plugin, `${plugin.name} update`) %>" data-confirm-label="<%= applyLabel(plugin) %>">
<input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= applyLabel(plugin) %></button>
<button class="button" type="submit" <%= plugin.blocked || !plugin.update_available ? "disabled" : "" %>><%= plugin.installed === false ? "Install from repo" : applyLabel(plugin) %></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">
@ -204,9 +281,11 @@
<button class="button danger" type="submit">Revert previous</button>
</form>
<% } %>
<% if (plugin.installed !== false) { %>
<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>