Lumi/src/services/dependency-manager.js
2026-07-18 18:58:35 +02:00

141 lines
5.5 KiB
JavaScript

const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
function readJson(filePath, fallback = null) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function dependencyFingerprint(rootPath) {
const packageJson = fs.readFileSync(path.join(rootPath, "package.json"));
const lockPath = path.join(rootPath, "package-lock.json");
const lock = fs.existsSync(lockPath) ? fs.readFileSync(lockPath) : Buffer.from("");
return crypto.createHash("sha256").update(packageJson).update(lock).digest("hex");
}
function dependencyIssues(rootPath) {
const manifest = readJson(path.join(rootPath, "package.json"), {});
const lock = readJson(path.join(rootPath, "package-lock.json"), {});
const issues = [];
const dependencies = new Map([
...Object.keys(manifest.dependencies || {}).map((name) => [name, false]),
...Object.keys(manifest.optionalDependencies || {}).map((name) => [name, true])
]);
for (const [name, optional] of [...dependencies.entries()].sort(([left], [right]) => left.localeCompare(right))) {
const installedPath = path.join(rootPath, "node_modules", ...name.split("/"), "package.json");
const installed = readJson(installedPath);
const expected = lock.packages?.[`node_modules/${name}`]?.version || null;
if (!installed?.version) {
issues.push({ dependency: name, reason: "missing", expected, optional });
} else if (expected && installed.version !== expected) {
issues.push({ dependency: name, reason: "version_mismatch", expected, installed: installed.version, optional });
}
}
return issues;
}
function writeState(rootPath, values) {
const statePath = path.join(rootPath, "data", "update-dependencies.json");
fs.mkdirSync(path.dirname(statePath), { recursive: true });
const temporary = `${statePath}.${process.pid}.tmp`;
fs.writeFileSync(temporary, `${JSON.stringify(values, null, 2)}\n`);
fs.renameSync(temporary, statePath);
return statePath;
}
function npmInvocation(options = {}) {
const environment = options.env || process.env;
const execPath = path.resolve(options.execPath || process.execPath);
const configuredCli = String(environment.npm_execpath || "").trim();
if (configuredCli && fs.existsSync(configuredCli)) {
return { command: execPath, args: [configuredCli] };
}
const platform = options.platform || process.platform;
if (platform === "win32") {
const candidates = [
path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js"),
environment.ProgramFiles
? path.join(environment.ProgramFiles, "nodejs", "node_modules", "npm", "bin", "npm-cli.js")
: null
].filter(Boolean);
const npmCli = candidates.find((candidate) => fs.existsSync(candidate));
if (npmCli) return { command: execPath, args: [npmCli] };
return {
command: String(environment.ComSpec || environment.COMSPEC || "cmd.exe"),
args: ["/d", "/s", "/c", "npm.cmd"]
};
}
return { command: "npm", args: [] };
}
function ensureRuntimeDependencies(options = {}) {
const rootPath = path.resolve(options.rootPath || path.join(__dirname, "..", ".."));
const before = dependencyIssues(rootPath);
const fingerprint = dependencyFingerprint(rootPath);
if (!before.length) {
writeState(rootPath, {
schema_version: 1,
fingerprint,
status: "ready",
checked_at: new Date().toISOString()
});
return { ready: true, installed: false, issues: [] };
}
if (options.install === false) return { ready: false, installed: false, issues: before };
const invocation = npmInvocation(options);
const installArgs = fs.existsSync(path.join(rootPath, "package-lock.json"))
? ["ci", "--omit=dev", "--no-audit", "--no-fund"]
: ["install", "--omit=dev", "--no-audit", "--no-fund"];
const runner = options.runner || spawnSync;
const result = runner(invocation.command, [...invocation.args, ...installArgs], {
cwd: rootPath,
encoding: "utf8",
timeout: Math.max(60000, Number(options.timeoutMs) || 600000),
windowsHide: true
});
if (result?.error || result?.status !== 0) {
const detail = String(result?.stderr || result?.stdout || result?.error?.message || "npm failed").trim().slice(-4000);
if (before.every((issue) => issue.optional)) {
writeState(rootPath, {
schema_version: 1,
fingerprint,
status: "ready_without_optional_dependencies",
checked_at: new Date().toISOString(),
unresolved: before,
warning: detail
});
return { ready: true, installed: false, optional_failed: true, issues: before, warning: detail };
}
throw new Error(`Dependency synchronization failed. ${detail}`);
}
const after = dependencyIssues(rootPath);
const requiredAfter = after.filter((issue) => !issue.optional);
if (requiredAfter.length) {
throw new Error(`Dependency synchronization finished with unresolved packages: ${requiredAfter.map((item) => item.dependency).join(", ")}.`);
}
writeState(rootPath, {
schema_version: 1,
fingerprint,
status: after.length ? "ready_without_optional_dependencies" : "ready",
installed_at: new Date().toISOString(),
resolved: before.filter((issue) => !after.some((remaining) => remaining.dependency === issue.dependency)),
unresolved: after
});
return { ready: true, installed: true, issues: before, unresolved: after };
}
module.exports = {
dependencyFingerprint,
dependencyIssues,
ensureRuntimeDependencies,
npmInvocation
};