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/**/
!plugins/*/data/**/.gitkeep !plugins/*/data/**/.gitkeep
.env .env
.env.* .env.*
!.env.example !.env.example
.bot details.md .bot details.md
*.db *.db
*.db-* *.db-*
*.sqlite *.sqlite
*.sqlite-* *.sqlite-*
npm-debug.log npm-debug.log
security-audit-*.json security-audit-*.json
security-audit-*.md security-audit-*.md
taskfile.txt taskfile.txt
codex-guidelines codex-guidelines
Twitch.png Twitch.png
twitch-credentials-lumi.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 path = require("path");
const fs = require("fs"); const fs = require("fs");
const { spawnSync } = require("child_process");
const { syncPluginRegistry, setPluginEnabled } = require("./plugins"); const { syncPluginRegistry, setPluginEnabled } = require("./plugins");
const { const {
createSnapshot, createSnapshot,
finalizeSnapshot, finalizeSnapshot,
discardSnapshot, discardSnapshot,
restoreSnapshot restoreSnapshot,
applyCoreFilesFromDirectory,
applyPluginFiles
} = require("./update-manager"); } = require("./update-manager");
const { getUpdateStatus } = require("./update-index"); const { getUpdateStatus } = require("./update-index");
const {
ensureManagedRepo,
writeUpdateState
} = require("./update-repository");
const { const {
createRecoveryMarker, createRecoveryMarker,
updateRecoveryMarker, updateRecoveryMarker,
@ -19,17 +24,6 @@ const {
const repoRoot = path.join(__dirname, "..", ".."); const repoRoot = path.join(__dirname, "..", "..");
const activeOperations = new Set(); 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) { function withOperation(key, fn) {
if (activeOperations.has(key)) { if (activeOperations.has(key)) {
throw new Error("An update is already running for this target."); throw new Error("An update is already running for this target.");
@ -62,9 +56,19 @@ function verifyPluginFiles(pluginId) {
JSON.parse(fs.readFileSync(manifest, "utf8")); 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 () => { return withOperation("core", async () => {
const status = getUpdateStatus({ source }); const status = getUpdateStatus({ source, remote });
const target = status.core; const target = status.core;
if (target.blocked) throw new Error(target.blocked_reason || "Core update is blocked."); 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."); 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 major_crossing: target.major_crossing
}); });
let snapshot = null; let snapshot = null;
let snapshotRecord = null;
try { try {
emitProgress(publish, "update:queued", { target: "core" }); emitProgress(publish, "update:queued", { target: "core" });
emitProgress(publish, "update:checking", { target: "core" }); emitProgress(publish, "update:checking", { target: "core" });
@ -100,32 +105,64 @@ async function applyCoreUpdate({ source = "stable", publish } = {}) {
danger_notes: target.dangers 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:recovery_marker", { target: "core", marker_id: marker.id });
emitProgress(publish, "update:download", { target: "core", branch: target.source_branch }); 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" }); emitProgress(publish, "update:apply", { target: "core" });
runGit(["pull", "--ff-only", status.remote, target.source_branch]); applyCoreFilesFromDirectory(managed.path);
updateRecoveryMarker({ status: "verifying" }); updateRecoveryMarker({ status: "verifying" });
emitProgress(publish, "update:verify", { target: "core" }); emitProgress(publish, "update:verify", { target: "core" });
verifyCoreFiles(); verifyCoreFiles();
const record = finalizeSnapshot(snapshot); const record = snapshotRecord;
markRecoveryMarkerComplete({ snapshot_id: record.id }); 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:restart_required", { target: "core" });
emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id }); emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id });
return { status: "complete", restart_required: true, snapshot: record, target }; return { status: "complete", restart_required: true, snapshot: record, target };
} catch (error) { } 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); 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 }); emitProgress(publish, "update:failed", { target: "core", error: error.message });
throw error; throw error;
} }
}); });
} }
async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish } = {}) { async function applyPluginUpdateFromRepo(pluginId, { source = "stable", remote = null, publish } = {}) {
return withOperation(`plugin:${pluginId}`, async () => { return withOperation(`plugin:${pluginId}`, async () => {
const status = getUpdateStatus({ source }); const status = getUpdateStatus({ source, remote });
const target = status.plugins.find((plugin) => plugin.id === pluginId); const target = status.plugins.find((plugin) => plugin.id === pluginId);
if (!target) throw new Error("Plugin is not installed."); if (!target) throw new Error("Plugin is not installed.");
if (target.blocked) throw new Error(target.blocked_reason || "Plugin update is blocked."); 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, from_version: target.current_version,
to_version: target.safe_target_version, to_version: target.safe_target_version,
source_branch: target.source_branch, source_branch: target.source_branch,
update_method: "git", update_method: target.installed === false ? "git_install" : "git",
rollback_safe: target.rollback_safe, rollback_safe: target.rollback_safe,
major_crossing: target.major_crossing major_crossing: target.major_crossing
}); });
let snapshot = null; let snapshot = null;
let snapshotRecord = null;
try { try {
emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId }); emitProgress(publish, "update:queued", { target: "plugin", plugin_id: pluginId });
emitProgress(publish, "update:metadata", target); emitProgress(publish, "update:metadata", target);
@ -154,7 +192,7 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
from_version: target.current_version, from_version: target.current_version,
to_version: target.safe_target_version, to_version: target.safe_target_version,
source_branch: target.source_branch, source_branch: target.source_branch,
update_method: "git", update_method: target.installed === false ? "git_install" : "git",
rollback_safe: target.rollback_safe, rollback_safe: target.rollback_safe,
recovery_marker_id: marker.id, recovery_marker_id: marker.id,
major_crossing: target.major_crossing, major_crossing: target.major_crossing,
@ -162,22 +200,56 @@ async function applyPluginUpdateFromRepo(pluginId, { source = "stable", publish
danger_notes: target.dangers 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 }); 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 }); 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" }); updateRecoveryMarker({ status: "verifying" });
emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId }); emitProgress(publish, "update:verify", { target: "plugin", plugin_id: pluginId });
verifyPluginFiles(pluginId); verifyPluginFiles(pluginId);
syncPluginRegistry(); syncPluginRegistry();
const record = finalizeSnapshot(snapshot); const record = snapshotRecord;
markRecoveryMarkerComplete({ snapshot_id: record.id }); 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 }); emitProgress(publish, "update:complete", { target: "plugin", plugin_id: pluginId, snapshot_id: record.id });
return { status: "complete", restart_required: false, snapshot: record, target }; return { status: "complete", restart_required: false, snapshot: record, target };
} catch (error) { } 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); 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 }); emitProgress(publish, "update:failed", { target: "plugin", plugin_id: pluginId, error: error.message });
throw error; throw error;
} }

View File

@ -1,6 +1,5 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawnSync } = require("child_process");
const { getSetting } = require("./settings"); const { getSetting } = require("./settings");
const { scanPluginDirectories, getPlugins } = require("./plugins"); const { scanPluginDirectories, getPlugins } = require("./plugins");
const { const {
@ -12,58 +11,21 @@ const {
} = require("./versioning"); } = require("./versioning");
const { listSnapshots } = require("./update-manager"); const { listSnapshots } = require("./update-manager");
const { safeModeStatus } = require("./recovery-mode"); const { safeModeStatus } = require("./recovery-mode");
const {
createMetadataReader,
normalizeRepositoryTarget,
resolveSourceBranch
} = require("./update-repository");
const repoRoot = path.join(__dirname, "..", ".."); const repoRoot = path.join(__dirname, "..", "..");
const LUMI_AI_TOOL_ID = /^lumi_ai_[a-z0-9_-]+$/i;
function runGit(args, options = {}) { function isLumiAiToolId(id) {
const result = spawnSync("git", args, { return LUMI_AI_TOOL_ID.test(String(id || "")) && String(id || "") !== "lumi_ai";
cwd: repoRoot,
encoding: "utf8",
timeout: options.timeout || 20000
});
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout || "Git command failed.").trim());
}
return result.stdout.trim();
} }
function tryGit(args, fallback = "") { function readJsonFromReader(reader, filePath) {
try { const raw = reader.readFile(filePath);
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);
if (!raw) return null; if (!raw) return null;
try { try {
return JSON.parse(raw); 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) { function parseMarkdownChangelog(raw) {
if (!raw) return []; if (!raw) return [];
const entries = []; const entries = [];
@ -122,11 +60,11 @@ function parseMarkdownChangelog(raw) {
return entries; return entries;
} }
function changelogEntries(ref, basePath = "") { function changelogEntries(reader, basePath = "") {
const json = readJsonGitFile(ref, path.posix.join(basePath, "changelog.json")); const json = readJsonFromReader(reader, path.posix.join(basePath, "changelog.json"));
if (Array.isArray(json)) return json; if (Array.isArray(json)) return json;
if (Array.isArray(json?.versions)) return json.versions; 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); return parseMarkdownChangelog(md);
} }
@ -142,10 +80,10 @@ function manifestRawVersion(manifest, fallbackVersion) {
return String(manifest?.version || fallbackVersion || "").trim(); return String(manifest?.version || fallbackVersion || "").trim();
} }
function coreManifest(ref) { function coreManifest(reader) {
return readJsonGitFile(ref, "update-manifest.json") || return readJsonFromReader(reader, "update-manifest.json") ||
readJsonGitFile(ref, "lumi.manifest.json") || readJsonFromReader(reader, "lumi.manifest.json") ||
readJsonGitFile(ref, "package.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 versions = manifestVersions(manifest, manifest?.version);
const rawVersion = manifestRawVersion(manifest); const rawVersion = manifestRawVersion(manifest);
const targetVersion = rawVersion || latestEntry(versions)?.version || ""; const targetVersion = rawVersion || latestEntry(versions)?.version || "";
@ -206,6 +253,7 @@ function buildStatus({ kind, id, name, currentVersion, manifest, changelog, sour
return { return {
kind, kind,
id: id || kind, id: id || kind,
installed,
name: name || manifest?.name || id || "Lumi core", name: name || manifest?.name || id || "Lumi core",
current_version: currentVersion, current_version: currentVersion,
latest_available_version: latest?.version || rawVersion || 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 = {}) { 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 requestedSource = options.source || "stable";
const sourceBranch = resolveSourceBranch(remote, requestedSource); const sourceBranch = resolveSourceBranch(configuredRemote, requestedSource);
fetchRemote(remote, isRepositoryUrl(remote) ? sourceBranch : null); const reader = createMetadataReader(configuredRemote, sourceBranch);
const ref = remoteRef(remote, sourceBranch); const remote = reader.repository || configuredRemote;
const core = buildStatus({ try {
kind: "core", const core = buildStatus({
currentVersion: localCoreVersion(), kind: "core",
manifest: coreManifest(ref), currentVersion: localCoreVersion(),
changelog: changelogEntries(ref), manifest: coreManifest(reader),
sourceBranch, changelog: changelogEntries(reader),
channel: requestedSource === "experimental" ? "experimental" : "stable"
});
const installed = scanPluginDirectories();
const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
const remoteDirs = new Set(remotePluginDirs(ref));
const plugins = installed.map((plugin) => {
const basePath = `plugins/${plugin.id}`;
const manifest = readJsonGitFile(ref, `${basePath}/plugin.json`) || {};
const changelog = changelogEntries(ref, basePath);
const dbPlugin = registry.get(plugin.id);
return buildStatus({
kind: "plugin",
id: plugin.id,
name: plugin.name,
currentVersion: dbPlugin?.version || plugin.version || "0.0.0",
manifest: remoteDirs.has(plugin.id) ? manifest : { version: plugin.version, name: plugin.name },
changelog,
sourceBranch, sourceBranch,
channel: requestedSource === "experimental" ? "experimental" : "stable" channel: requestedSource === "experimental" ? "experimental" : "stable"
}); });
}); const registry = new Map(getPlugins().map((plugin) => [plugin.id, plugin]));
return { const remoteDirs = new Set(reader.listPluginDirs());
generated_at: new Date().toISOString(), const remotePluginDirs = new Set();
source_branch: sourceBranch, const remoteTools = new Map();
requested_source: requestedSource, const candidates = localPluginCandidates(registry);
remote, for (const pluginId of remoteDirs) {
core, if (isLumiAiToolId(pluginId)) {
plugins, const toolMetadata = readJsonFromReader(reader, `plugins/${pluginId}/tool_info.json`);
plugins_summary: { if (toolMetadata) {
installed_plugins: installed.length, remoteTools.set(pluginId, {
total_plugins: remoteDirs.size || installed.length, ...toolMetadata,
updatable_plugins: plugins.filter((plugin) => plugin.update_available).length, repository_path: toolMetadata.repository_path || `plugins/${pluginId}`
blocked_plugins: plugins.filter((plugin) => plugin.blocked).length });
}, continue;
recovery: safeModeStatus() }
}; }
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 = { module.exports = {
getUpdateStatus, getUpdateStatus,
resolveSourceBranch, resolveSourceBranch
fetchRemote,
runGit,
readGitFile
}; };

View File

@ -21,6 +21,36 @@ const dataDir = path.join(repoRoot, "data");
const snapshotsDir = path.join(dataDir, "snapshots"); const snapshotsDir = path.join(dataDir, "snapshots");
const indexPath = path.join(snapshotsDir, "index.json"); const indexPath = path.join(snapshotsDir, "index.json");
const maxSnapshots = 20; 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() { function ensureSnapshotsDir() {
fs.mkdirSync(snapshotsDir, { recursive: true }); fs.mkdirSync(snapshotsDir, { recursive: true });
@ -66,9 +96,12 @@ async function createSnapshot({ type, pluginId, metadata = {} }) {
let pluginExisted = false; let pluginExisted = false;
let pluginZip = null; let pluginZip = null;
let fullZip = null;
if (type === "bot") { if (type === "bot") {
const coreZip = path.join(snapshotPath, "core.zip"); const coreZip = path.join(snapshotPath, "core.zip");
zipCore(coreZip); zipCore(coreZip);
fullZip = path.join(snapshotPath, "full.zip");
zipFullInstall(fullZip);
} }
if (type === "plugin" && pluginId) { 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) { function finalizeSnapshot(snapshot) {
@ -187,15 +220,9 @@ function resolveZipRoot(extractedDir) {
} }
function resolvePatchRoot(extractedDir) { function resolvePatchRoot(extractedDir) {
const entries = fs.readdirSync(extractedDir, { withFileTypes: true }); // Patch archives are copied relative to the repository root. Do not infer a
const dirs = entries.filter((entry) => entry.isDirectory()); // nested root from a single top-level directory such as src/, or patches will
const files = entries.filter((entry) => entry.isFile()); // be applied to services/ and web/ instead of src/services/ and src/web/.
if (files.length > 0) {
return extractedDir;
}
if (dirs.length === 1) {
return path.join(extractedDir, dirs[0].name);
}
return extractedDir; return extractedDir;
} }
@ -262,6 +289,15 @@ function zipCore(destination) {
zip.writeZip(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) { function zipFolder(source, destination, options) {
if (!AdmZip) { if (!AdmZip) {
throw new Error("adm-zip is not installed. Run npm install."); 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 }); const entries = fs.readdirSync(folderPath, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const fullPath = path.join(folderPath, entry.name); const fullPath = path.join(folderPath, entry.name);
const relPath = path.relative(basePath, fullPath); const relPath = normalizeRelative(path.relative(basePath, fullPath));
const topLevel = relPath.split(path.sep)[0]; const topLevel = relPath.split("/")[0];
if (ignore.has(topLevel)) { if (ignore.has(relPath) || ignore.has(topLevel)) {
continue; continue;
} }
if (entry.isDirectory()) { 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 }); const entries = fs.readdirSync(source, { withFileTypes: true });
for (const entry of entries) { 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; continue;
} }
const srcPath = path.join(source, entry.name);
const destPath = path.join(target, entry.name); const destPath = path.join(target, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
fs.mkdirSync(destPath, { recursive: true }); fs.mkdirSync(destPath, { recursive: true });
copyDirectory(srcPath, destPath, ignore); copyDirectory(srcPath, destPath, ignore, { base });
} else if (entry.isFile()) { } else if (entry.isFile()) {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.copyFileSync(srcPath, destPath); fs.copyFileSync(srcPath, destPath);
} }
} }
} }
function applyCoreUpdate(rootPath) { function removeGeneratedPaths() {
resetCoreFiles(); 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( copyDirectory(
rootPath, rootPath,
repoRoot, repoRoot,
new Set([".git", "node_modules", "data", "plugins"]) PRESERVE_RELATIVE_PATHS,
{ base: rootPath }
); );
} }
@ -331,7 +398,8 @@ function applyCorePatch(rootPath) {
copyDirectory( copyDirectory(
rootPath, rootPath,
repoRoot, 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") { if (mode === "patch") {
applyCorePatch(rootPath); applyCorePatch(rootPath);
} else { } else {
applyCoreUpdate(rootPath); applyCoreFilesFromDirectory(rootPath);
} }
const record = finalizeSnapshot(snapshot); const record = finalizeSnapshot(snapshot);
markRecoveryMarkerComplete({ snapshot_id: record.id }); markRecoveryMarkerComplete({ snapshot_id: record.id });
@ -525,14 +593,16 @@ function restoreSnapshot(id, options = {}) {
} }
if (entry.type === "bot") { if (entry.type === "bot") {
const fullZip = path.join(entry.path, "full.zip");
const coreZip = path.join(entry.path, "core.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."); throw new Error("Snapshot core archive missing.");
} }
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-restore-"));
extractZip(coreZip, tempDir); extractZip(restoreZip, tempDir);
const rootPath = resolveZipRoot(tempDir); const rootPath = resolveZipRoot(tempDir);
applyCoreUpdate(rootPath); applyCoreFilesFromDirectory(rootPath);
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }
@ -565,6 +635,7 @@ module.exports = {
createSnapshot, createSnapshot,
finalizeSnapshot, finalizeSnapshot,
discardSnapshot, discardSnapshot,
applyCoreFilesFromDirectory,
applyPluginFiles, applyPluginFiles,
resetPluginCode, resetPluginCode,
replacePluginDirectory, 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 { getUpdateStatus } = require("./update-index");
const fs = require("fs"); const { applyCoreUpdate } = require("./repo-update");
const os = require("os"); const {
const { spawnSync } = require("child_process"); normalizeRepositoryTarget,
let AdmZip = null; resolveSourceBranch
try { } = require("./update-repository");
AdmZip = require("adm-zip");
} catch {
AdmZip = null;
}
const { applyBotUpdate } = require("./update-manager");
const repoRoot = path.join(__dirname, "..", ".."); function normalizeUpdateSource(branch) {
const requested = String(branch || "main").trim();
function runGit(args, options = {}) { if (requested === "main" || requested === "stable") return "stable";
const result = spawnSync("git", args, { if (requested === "experimental") return "experimental";
cwd: options.cwd || repoRoot, return requested || "stable";
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 checkForUpdates(remote, branch) { function checkForUpdates(remote, branch) {
const target = normalizeRepositoryTarget(remote); const target = normalizeRepositoryTarget(remote);
if (!isGitRepository()) { const source = normalizeUpdateSource(branch);
runGit(["ls-remote", "--heads", target, branch]); const status = getUpdateStatus({ remote: target, source });
return true; return Boolean(status.core?.update_available);
}
runGit(["fetch", target, branch]);
const count = runGit([
"rev-list",
`HEAD..${remoteRef(target, branch)}`,
"--count"
]);
return Number(count) > 0;
} }
async function pullUpdates(remote, branch) { async function pullUpdates(remote, branch) {
const target = normalizeRepositoryTarget(remote); const target = normalizeRepositoryTarget(remote);
if (isGitRepository()) { const source = normalizeUpdateSource(branch);
return runGit(["pull", target, branch]); const result = await applyCoreUpdate({ remote: target, source });
} return `Applied core update from ${result.target.source_branch}.`;
return applyRepositorySnapshot(target, branch);
} }
async function applyRepositorySnapshot(remote, branch) { function resolveUpdateBranch(remote, branch) {
if (!AdmZip) { return resolveSourceBranch(normalizeRepositoryTarget(remote), normalizeUpdateSource(branch));
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 requestRestart() { function requestRestart() {
@ -116,5 +38,6 @@ module.exports = {
checkForUpdates, checkForUpdates,
pullUpdates, pullUpdates,
normalizeRepositoryTarget, normalizeRepositoryTarget,
resolveUpdateBranch,
requestRestart requestRestart
}; };

View File

@ -4926,9 +4926,10 @@ function createWebServer({ loadPlugins, discordClient }) {
app.get("/admin/plugins", requireRole("admin"), (req, res) => { app.get("/admin/plugins", requireRole("admin"), (req, res) => {
syncPluginRegistry(); syncPluginRegistry();
const plugins = getPlugins().filter((plugin) => !/^lumi_ai_[a-z0-9_-]+$/i.test(plugin.id || ""));
res.render("admin-plugins", { res.render("admin-plugins", {
title: "Plugins", title: "Plugins",
plugins: getPlugins() plugins
}); });
}); });
@ -5099,17 +5100,33 @@ function createWebServer({ loadPlugins, discordClient }) {
app.get("/admin/updates", requireRole("admin"), (req, res) => { app.get("/admin/updates", requireRole("admin"), (req, res) => {
let updateStatus = null; let updateStatus = null;
let updateStatusError = null; let updateStatusError = null;
let snapshots = [];
let recoveryStatus = {};
try { try {
updateStatus = getUpdateStatus({ source: updateSourceFrom(req) }); updateStatus = getUpdateStatus({ source: updateSourceFrom(req) });
} catch (error) { } catch (error) {
updateStatusError = error.message; 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", { res.render("admin-updates", {
title: "Updates", title: "Updates",
snapshots: listSnapshots(), snapshots,
updateStatus, updateStatus,
updateStatusError, 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)" /> <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> <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>
<div class="field"> <div class="field">
<label>Git branch</label> <label>Update branch</label>
<input name="git_branch" value="<%= settings.git_branch || 'main' %>" /> <% const selectedUpdateBranch = settings.git_branch === 'experimental' ? 'experimental' : 'main'; %>
</div> <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="field full">
<div class="inline-actions"> <div class="inline-actions">
<%- include("partials/state-button", { <%- include("partials/state-button", {

View File

@ -9,6 +9,8 @@
const selectedSource = status.requested_source || "stable"; const selectedSource = status.requested_source || "stable";
const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success"; const badgeClass = (item) => item?.blocked ? "danger" : item?.update_available ? "warning" : "success";
const badgeText = (item) => item?.blocked ? "Blocked" : item?.update_available ? "Update available" : "Current"; 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 changelogItems = (item) => Array.isArray(item?.changelog_range) ? item.changelog_range : [];
const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target"; const applyLabel = (item) => item?.unversioned_update ? "Apply manual repo update" : "Apply safe target";
const applyConfirmText = (item, label) => { const applyConfirmText = (item, label) => {
@ -32,8 +34,8 @@
<label class="field compact"> <label class="field compact">
<span>Update source</span> <span>Update source</span>
<select name="source"> <select name="source">
<option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable main</option> <option value="stable" <%= selectedSource === "stable" ? "selected" : "" %>>Stable</option>
<option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Newest experimental-*</option> <option value="experimental" <%= selectedSource === "experimental" ? "selected" : "" %>>Experimental</option>
</select> </select>
</label> </label>
<button class="button subtle" type="submit">Reload metadata</button> <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> <p class="hint">Stable checks read repo metadata from <code>main</code>. Experimental branches are considered only when selected here.</p>
</section> </section>
<section class="card">
<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) { %> <% if (recovery.has_incomplete_marker || marker) { %>
<section class="card update-recovery-banner"> <section class="card update-recovery-banner">
<h2>Safe Mode / Recovery</h2> <h2>Safe Mode / Recovery</h2>
@ -162,7 +193,7 @@
<summary> <summary>
<span> <span>
<strong><%= plugin.name %></strong> <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>
<span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span> <span class="badge <%= badgeClass(plugin) %>"><%= badgeText(plugin) %></span>
</summary> </summary>
@ -174,6 +205,52 @@
<div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div> <div><span>Snapshot</span><strong><%= plugin.snapshot.available ? plugin.snapshot.latest_snapshot_id : "None" %></strong></div>
</div> </div>
<p><%= plugin.version_description %></p> <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.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.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><% } %> <% 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>
<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) %>"> <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 %>" /> <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> </form>
<% if (plugin.snapshot.available) { %> <% 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"> <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> <button class="button danger" type="submit">Revert previous</button>
</form> </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"> <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> <button class="button danger" type="submit">Disable for recovery</button>
</form> </form>
<% } %>
</div> </div>
<details class="inline-details"> <details class="inline-details">
<summary>Show advanced plugin ZIP options</summary> <summary>Show advanced plugin ZIP options</summary>