UNPKG

app-builder-lib

Version:
346 lines 16.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.NodeModulesCollector = void 0; const builder_util_1 = require("builder-util"); const childProcess = require("child_process"); const fs = require("fs-extra"); const fs_extra_1 = require("fs-extra"); const lazy_val_1 = require("lazy-val"); const path = require("path"); const hoist_1 = require("./hoist"); const moduleManager_1 = require("./moduleManager"); const packageManager_1 = require("./packageManager"); class NodeModulesCollector { constructor(rootDir, tempDirManager) { this.rootDir = rootDir; this.tempDirManager = tempDirManager; this.nodeModules = []; this.allDependencies = new Map(); this.productionGraph = {}; this.cache = new moduleManager_1.ModuleManager(); this.isHoisted = new lazy_val_1.Lazy(async () => { const { manager } = this.installOptions; const command = (0, packageManager_1.getPackageManagerCommand)(manager); const config = (await this.asyncExec(command, ["config", "list"])).stdout; if (config == null) { builder_util_1.log.debug({ manager }, "unable to determine if node_modules are hoisted: no config output. falling back to hoisted mode"); return false; } const lines = Object.fromEntries(config.split("\n").map(line => line.split("=").map(s => s.trim()))); if (lines["node-linker"] === "hoisted") { builder_util_1.log.debug({ manager }, "node_modules are hoisted"); return true; } return false; }); } /** * Retrieves and collects all Node.js modules for a given package. * * This method orchestrates the entire module collection process by: * 1. Fetching the dependency tree from the package manager * 2. Collecting all dependencies recursively * 3. Extracting workspace references if applicable * 4. Building a production dependency graph * 5. Hoisting the dependencies to their final locations * 6. Resolving and returning module information */ async getNodeModules({ packageName }) { const tree = await this.getDependenciesTree(this.installOptions.manager); await this.collectAllDependencies(tree, packageName); const realTree = this.getTreeFromWorkspaces(tree, packageName); await this.extractProductionDependencyGraph(realTree, packageName); const hoisterResult = (0, hoist_1.hoist)(this.transformToHoisterTree(this.productionGraph, packageName), { check: builder_util_1.log.isDebugEnabled, }); await this._getNodeModules(hoisterResult.dependencies, this.nodeModules); builder_util_1.log.debug({ packageName, depCount: this.nodeModules.length }, "node modules collection complete"); return { nodeModules: this.nodeModules, logSummary: this.cache.logSummary }; } /** * Retrieves the dependency tree from the package manager. * * Executes the appropriate package manager command to fetch the dependency tree and writes * the output to a temporary file. Includes retry logic to handle transient failures such as * incomplete JSON output or missing files. Will retry up to 1 time with exponential backoff. */ async getDependenciesTree(pm) { const command = (0, packageManager_1.getPackageManagerCommand)(pm); const args = this.getArgs(); const tempOutputFile = await this.tempDirManager.getTempFile({ prefix: path.basename(command, path.extname(command)), suffix: "output.json", }); return (0, builder_util_1.retry)(async () => { await this.streamCollectorCommandToFile(command, args, this.rootDir, tempOutputFile); const shellOutput = await fs.readFile(tempOutputFile, { encoding: "utf8" }); const result = await Promise.resolve(this.parseDependenciesTree(shellOutput)); return result; }, { retries: 1, interval: 2000, backoff: 2000, shouldRetry: async (error) => { var _a; const fields = { error: error.message, tempOutputFile, cwd: this.rootDir, packageManager: pm }; if (!(await (0, builder_util_1.exists)(tempOutputFile))) { builder_util_1.log.debug(fields, "dependency tree output file missing, retrying"); return true; } const fileContent = await fs.readFile(tempOutputFile, { encoding: "utf8" }); fields.fileContentLength = fileContent.length.toString(); if (fileContent.trim().length === 0) { builder_util_1.log.debug(fields, "dependency tree output file empty, retrying"); return true; } // extract small start/end sample for debugging purposes (e.g. polluted console output) const lines = fileContent.split("\n"); const lineSampleSize = Math.min(5, lines.length / 2); if (2 * lineSampleSize > 5) { fields.sampleStart = lines.slice(0, lineSampleSize).join("\n"); fields.sampleEnd = lines.slice(-lineSampleSize).join("\n"); } else { fields.content = fileContent; } if ((_a = error.message) === null || _a === void 0 ? void 0 : _a.includes("Unexpected end of JSON input")) { builder_util_1.log.debug(fields, "JSON parse error in dependency tree, retrying"); return true; } builder_util_1.log.error(fields, "error parsing dependencies tree"); return false; }, }); } /** * Parses the dependencies tree from shell command output. * **/ parseDependenciesTree(shellOutput) { return this.extractJsonFromPollutedOutput(shellOutput); } extractJsonFromPollutedOutput(shellOutput) { const consoleOutput = shellOutput.trim(); try { // Please for the love of all that is holy, this should cover 99% of cases where npm/pnpm/yarn output is clean JSON return JSON.parse(consoleOutput); } catch { // ignore } // DEDICATED FALLBACK FOR POLLUTED OUTPUT, non-trivial to implement correctly, not needed in most cases, and highly inefficient // Find the first index that starts with { or [ const bracketOpen = Math.max(consoleOutput.indexOf("{"), 0); const bracketOpenSquare = Math.max(consoleOutput.indexOf("["), 0); const start = Math.min(bracketOpen, bracketOpenSquare); // always non-negative due to Math.max above for (let i = start; i < consoleOutput.length; i++) { const slice = consoleOutput.slice(start, i + 1); try { return JSON.parse(slice); } catch { // ignore, try next } } throw new Error("No JSON content found in output"); } cacheKey(pkg) { const rel = path.relative(this.rootDir, pkg.path); return `${pkg.name}::${pkg.version}::${rel !== null && rel !== void 0 ? rel : "."}`; } // We use the key (alias name) instead of value.name for npm aliased packages // e.g., { "foo": { name: "@scope/bar", ... } } should be stored as "foo@version" normalizePackageVersion(key, pkg) { return { id: `${key}@${pkg.version}`, pkgOverride: { ...pkg, name: key } }; } /** * Determines if a given dependency is a production dependency of a package. * * Checks both the dependencies and optionalDependencies of a package to see if * the specified dependency name is listed. * * @param depName - The name of the dependency to check * @param pkg - The package to search for the dependency in * @returns True if the dependency is found in either dependencies or optionalDependencies, false otherwise */ isProdDependency(depName, pkg) { const prodDeps = { ...pkg.dependencies, ...pkg.optionalDependencies }; return prodDeps[depName] != null; } async locatePackageWithVersion(depTree) { const result = await this.cache.locatePackageVersion({ parentDir: depTree.path, pkgName: depTree.name, requiredRange: depTree.version, }); return result; } /** * Parses a dependency identifier string into name and version components. * * Handles both scoped packages (e.g., "@scope/pkg@1.2.3") and regular packages (e.g., "pkg@1.2.3"). * If the identifier is malformed or cannot be parsed, defaults to treating the entire string as * the package name with an "unknown" version. */ parseNameVersion(identifier) { const lastAt = identifier.lastIndexOf("@"); if (lastAt <= 0) { // fallback for scoped packages or malformed strings return { name: identifier, version: "unknown" }; } const name = identifier.slice(0, lastAt); const version = identifier.slice(lastAt + 1); return { name, version }; } /** * Retrieves the dependency tree and handles workspace package self-references. * * If the project is a workspace project, this method removes the root package's self-reference * from the dependency tree to avoid circular dependencies. It promotes the root package's * direct dependencies to the top level of the tree. * * @param tree - The original dependency tree * @param packageName - The name of the package to check for and remove from the tree * @returns The extracted dependency subtree */ getTreeFromWorkspaces(tree, packageName) { if (tree.workspaces && tree.dependencies) { for (const [key, value] of Object.entries(tree.dependencies)) { if (key === packageName) { return value; } } } return tree; } transformToHoisterTree(obj, key, nodes = new Map()) { let node = nodes.get(key); const { name, version } = this.parseNameVersion(key); if (!node) { node = { name, identName: name, reference: version, dependencies: new Set(), peerNames: new Set(), }; nodes.set(key, node); const deps = (obj[key] || {}).dependencies || []; for (const dep of deps) { const child = this.transformToHoisterTree(obj, dep, nodes); node.dependencies.add(child); } } return node; } async _getNodeModules(dependencies, result) { var _a; if (dependencies.size === 0) { return; } for (const d of dependencies.values()) { const reference = [...d.references][0]; const key = `${d.name}@${reference}`; const p = (_a = this.allDependencies.get(key)) === null || _a === void 0 ? void 0 : _a.path; if (p === undefined) { this.cache.logSummary[moduleManager_1.LogMessageByKey.PKG_NOT_FOUND].push(key); continue; } // fix npm list issue // https://github.com/npm/cli/issues/8535 if (!(await this.cache.exists[p])) { this.cache.logSummary[moduleManager_1.LogMessageByKey.PKG_NOT_ON_DISK].push(key); continue; } const node = { name: d.name, version: reference, dir: await this.cache.realPath[p], }; result.push(node); if (d.dependencies.size > 0) { node.dependencies = []; await this._getNodeModules(d.dependencies, node.dependencies); } } result.sort((a, b) => a.name.localeCompare(b.name)); } async asyncExec(command, args, cwd = this.rootDir) { const file = await this.tempDirManager.getTempFile({ prefix: "exec-", suffix: ".txt" }); try { await this.streamCollectorCommandToFile(command, args, cwd, file); const result = await fs.readFile(file, { encoding: "utf8" }); return { stdout: result === null || result === void 0 ? void 0 : result.trim(), stderr: undefined }; } catch (error) { builder_util_1.log.debug({ error: error.message }, "failed to execute command"); return { stdout: undefined, stderr: error.message }; } } /** * Executes a command and streams its output to a file. * * Spawns a child process to execute the specified command with arguments, capturing stdout * to a file. Handles Windows-specific quirks by wrapping .cmd files in a temporary .bat file * when necessary. Enables corepack strict mode by default but allows process.env overrides. * * Special handling for `npm list` exit code 1, which is expected in certain scenarios. * * @param command - The command to execute * @param args - Array of command-line arguments * @param cwd - The working directory to execute the command in * @param tempOutputFile - The path to the temporary file where stdout will be written * @returns Promise that resolves when the command completes successfully or rejects if it fails * @throws {Error} If the child process spawn fails or exits with a non-zero code */ async streamCollectorCommandToFile(command, args, cwd, tempOutputFile) { const execName = path.basename(command, path.extname(command)); const isWindowsScriptFile = process.platform === "win32" && path.extname(command).toLowerCase() === ".cmd"; if (isWindowsScriptFile) { // If the command is a Windows script file (.cmd), we need to wrap it in a .bat file to ensure it runs correctly with cmd.exe // This is necessary because .cmd files are not directly executable in the same way as .bat files. // We create a temporary .bat file that calls the .cmd file with the provided arguments. The .bat file will be executed by cmd.exe. // Note: This is a workaround for Windows command execution quirks when using `shell: true` const tempBatFile = await this.tempDirManager.getTempFile({ prefix: execName, suffix: ".bat", }); const batScript = `@echo off\r\n"${command}" %*\r\n`; // <-- CRLF required for .bat await fs.writeFile(tempBatFile, batScript, { encoding: "utf8" }); command = "cmd.exe"; args = ["/c", `"${tempBatFile}"`, ...args]; } await new Promise((resolve, reject) => { const outStream = (0, fs_extra_1.createWriteStream)(tempOutputFile); const child = childProcess.spawn(command, args, { cwd, env: { COREPACK_ENABLE_STRICT: "0", ...process.env }, // allow `process.env` overrides shell: true, // `true`` is now required: https://github.com/electron-userland/electron-builder/issues/9488 }); let stderr = ""; child.stdout.pipe(outStream); child.stderr.on("data", chunk => { stderr += chunk.toString(); }); child.on("error", err => { reject(new Error(`Node module collector spawn failed: ${err.message}`)); }); child.on("close", code => { outStream.close(); // https://github.com/npm/npm/issues/17624 const shouldIgnore = code === 1 && "npm" === execName.toLowerCase() && args.includes("list"); if (shouldIgnore) { builder_util_1.log.debug(null, "`npm list` returned non-zero exit code, but it MIGHT be expected (https://github.com/npm/npm/issues/17624). Check stderr for details."); } if (stderr.length > 0) { builder_util_1.log.debug({ stderr }, "note: there was node module collector output on stderr"); this.cache.logSummary[moduleManager_1.LogMessageByKey.PKG_COLLECTOR_OUTPUT].push(stderr); } const shouldResolve = code === 0 || shouldIgnore; return shouldResolve ? resolve() : reject(new Error(`Node module collector process exited with code ${code}:\n${stderr}`)); }); }); } } exports.NodeModulesCollector = NodeModulesCollector; //# sourceMappingURL=nodeModulesCollector.js.map