416 lines
15 KiB
JavaScript
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
|
|
};
|