const fs = require("fs"); const path = require("path"); const { getSetting } = require("./settings"); const { scanPluginDirectories, getPlugins } = require("./plugins"); const { parseSemver, compareSemver, findSafeTarget, collectChangelogRange, normalizeVersions } = 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 isLumiAiToolId(id) { return LUMI_AI_TOOL_ID.test(String(id || "")) && String(id || "") !== "lumi_ai"; } function readJsonFromReader(reader, filePath) { const raw = reader.readFile(filePath); if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } function readLocalJson(filePath) { try { return JSON.parse(fs.readFileSync(path.join(repoRoot, filePath), "utf8")); } catch { return null; } } function parseMarkdownChangelog(raw) { if (!raw) return []; const entries = []; const lines = raw.split(/\r?\n/); let current = null; for (const line of lines) { const heading = line.match(/^#{1,3}\s+\[?v?(\d+\.\d+\.\d+)\]?(.+)?$/i); if (heading) { if (current) entries.push(current); current = { version: heading[1], title: line.replace(/^#+\s+/, ""), changes: [] }; } else if (current && line.trim()) { current.changes.push(line.trim().replace(/^[-*]\s*/, "")); } } if (current) entries.push(current); return entries; } function changelogEntries(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 = reader.readFile(path.posix.join(basePath, "CHANGELOG.md")); return parseMarkdownChangelog(md); } function manifestVersions(manifest, fallbackVersion) { const entries = []; if (Array.isArray(manifest?.versions)) entries.push(...manifest.versions); if (manifest?.version) entries.push({ ...manifest, version: manifest.version }); if (fallbackVersion) entries.push({ version: fallbackVersion }); return normalizeVersions(entries); } function manifestRawVersion(manifest, fallbackVersion) { return String(manifest?.version || fallbackVersion || "").trim(); } function coreManifest(reader) { return readJsonFromReader(reader, "update-manifest.json") || readJsonFromReader(reader, "lumi.manifest.json") || readJsonFromReader(reader, "package.json") || {}; } function localCoreVersion() { return readLocalJson("package.json")?.version || "0.0.0"; } function latestEntry(entries) { return entries.length ? entries[entries.length - 1] : null; } function snapshotAvailability(kind, id = null) { const snapshots = listSnapshots() .filter((snap) => snap.type === kind || (kind === "core" && snap.type === "bot")) .filter((snap) => !id || snap.pluginId === id) .sort((a, b) => b.createdAt - a.createdAt); const latest = snapshots[0] || null; return { available: Boolean(latest), latest_snapshot_id: latest?.id || null, rollback_safe: latest?.rollback_safe !== false, latest }; } function 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 || ""; const currentIsVersioned = Boolean(parseSemver(currentVersion)); const targetIsVersioned = Boolean(parseSemver(targetVersion)); const unversionedUpdate = !currentIsVersioned || !targetIsVersioned; const latest = latestEntry(versions); const unversionedTarget = { ...(latest || manifest || {}), version: targetVersion || "unversioned", rollback_safe: false, unversioned: true }; const targetResult = unversionedUpdate ? { target: unversionedTarget, latest: latest || unversionedTarget, blocked: false, warning: "This update involves an unversioned source or target. It is available as a manual repo update, but version ordering, changelog range, and rollback safety cannot be verified." } : findSafeTarget(currentVersion, versions); const target = targetResult.target; const range = target && !unversionedUpdate ? collectChangelogRange(currentVersion, target.version, changelog.length ? changelog : versions) : changelog; const warnings = []; const dangers = []; if (!changelog.length) warnings.push("Changelog metadata is missing."); if (targetResult.warning) warnings.push(targetResult.warning); if (targetResult.blocked) dangers.push(targetResult.reason); if (target?.rollback_safe === false) warnings.push("Target metadata marks rollback as unsafe after migration."); return { kind, id: id || kind, installed, name: name || manifest?.name || id || "Lumi core", current_version: currentVersion, latest_available_version: latest?.version || rawVersion || currentVersion, safe_target_version: target?.version || null, update_available: Boolean( target && (unversionedUpdate || compareSemver(target.version, currentVersion) > 0) ), blocked: Boolean(targetResult.blocked), blocked_reason: targetResult.reason || null, source_branch: sourceBranch, channel: manifest?.channel || channel || "stable", version_description: target ? `${currentVersion} -> ${target.version}` : targetResult.reason || "No safe update target available.", changelog_range: range, size_delta: target?.size || manifest?.size || null, size_delta_label: target?.size || manifest?.size ? String(target.size || manifest.size) : "unknown", warnings, dangers, unversioned_update: unversionedUpdate, requires_manual_confirmation: unversionedUpdate, requirements: target?.requirements || manifest?.requirements || [], migration_notes: target?.migration_notes || manifest?.migration_notes || "", rollback_safe: target?.rollback_safe !== false, major_crossing: target && !unversionedUpdate ? target.version.split(".")[0] !== String(currentVersion).split(".")[0] : false, snapshot: snapshotAvailability(kind === "plugin" ? "plugin" : "bot", id), raw_target: target || null }; } function getUpdateStatus(options = {}) { const configuredRemote = normalizeRepositoryTarget(options.remote || getSetting("git_remote", "origin")); const requestedSource = options.source || "stable"; 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" }); 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 };