Lumi/src/services/update-index.js
2026-06-16 10:05:37 +02:00

291 lines
9.7 KiB
JavaScript

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