const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); 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 repoRoot = path.join(__dirname, "..", ".."); 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 tryGit(args, fallback = "") { try { return runGit(args); } catch { return fallback; } } function fetchRemote(remote) { runGit(["fetch", "--prune", remote]); } function remoteRef(remote, branch) { return `${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; 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 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 = []; 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(ref, basePath = "") { const json = readJsonGitFile(ref, 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")); 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(ref) { return readJsonGitFile(ref, "update-manifest.json") || readJsonGitFile(ref, "lumi.manifest.json") || readJsonGitFile(ref, "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 buildStatus({ kind, id, name, currentVersion, manifest, changelog, sourceBranch, channel }) { 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, 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 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 = options.remote || getSetting("git_remote", "origin"); const requestedSource = options.source || "stable"; fetchRemote(remote); const sourceBranch = resolveSourceBranch(remote, requestedSource); 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, 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() }; } module.exports = { getUpdateStatus, resolveSourceBranch, fetchRemote, runGit, readGitFile };