diff --git a/.gitignore b/.gitignore index 823709b..01056ee 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1c71f7a --- /dev/null +++ b/TODO.md @@ -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. diff --git a/src/services/repo-update.js b/src/services/repo-update.js index 7a78c9e..acb88f9 100644 --- a/src/services/repo-update.js +++ b/src/services/repo-update.js @@ -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; } diff --git a/src/services/update-index.js b/src/services/update-index.js index 5ba54b7..af33115 100644 --- a/src/services/update-index.js +++ b/src/services/update-index.js @@ -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 }; diff --git a/src/services/update-manager.js b/src/services/update-manager.js index b224c1a..369f8ed 100644 --- a/src/services/update-manager.js +++ b/src/services/update-manager.js @@ -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, diff --git a/src/services/update-repository.js b/src/services/update-repository.js new file mode 100644 index 0000000..bb6c385 --- /dev/null +++ b/src/services/update-repository.js @@ -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 +}; diff --git a/src/services/updater.js b/src/services/updater.js index d84433e..be1e602 100644 --- a/src/services/updater.js +++ b/src/services/updater.js @@ -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 }; diff --git a/src/web/server.js b/src/web/server.js index e024528..2353757 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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 }); }); diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index 2c1e899..bf76d8c 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -36,10 +36,15 @@
Use a remote alias such as origin or a repository URL. The .git suffix is optional.
Stable uses main. Experimental uses the newest experimental-* branch.
Stable checks read repo metadata from main. Experimental branches are considered only when selected here.
These upload paths stay available even when repository metadata cannot be loaded.
+Use a full core ZIP or a patch ZIP containing files relative to the repo root.
+ +Use this fallback when plugin metadata is unavailable. Plugin ZIPs must include a valid plugin.json.
<%= plugin.version_description %>
+ <% const tools = Array.isArray(plugin.tools) ? plugin.tools : []; %> + <% if (plugin.id === "lumi_ai" && tools.length) { %> + <% const toolSummary = plugin.tools_summary || {}; %> + + <% } %> <% if (plugin.warnings?.length) { %>This plugin update is to or from an unversioned state. It remains available, but rollback safety and version ordering cannot be verified from metadata.