141 lines
5.5 KiB
JavaScript
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
|
|
};
|