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