291 lines
9.7 KiB
JavaScript
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
|
|
};
|