Lumi/src/services/update-index.js
2026-06-17 12:08:35 +02:00

416 lines
15 KiB
JavaScript

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
};