From 18986268ab541213f6e66c73a599be43d7a215b2 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:59:52 +0000 Subject: [PATCH 1/7] chore: replace import-local and cross-spawn with Node.js built-ins The CLI used one function of each package, both are a few lines with node:module and node:child_process. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- .changeset/drop-import-local-cross-spawn.md | 5 ++ package-lock.json | 2 - packages/webpack-cli/bin/cli.js | 50 +++++++++++++- packages/webpack-cli/package.json | 2 - packages/webpack-cli/src/webpack-cli.ts | 47 ++++++++++--- test/api/get-default-package-manager.test.js | 4 +- test/api/install-package.test.js | 69 +++++++++++++------- test/build/import-local/import-local.test.js | 48 +++++++++++--- 8 files changed, 180 insertions(+), 47 deletions(-) create mode 100644 .changeset/drop-import-local-cross-spawn.md diff --git a/.changeset/drop-import-local-cross-spawn.md b/.changeset/drop-import-local-cross-spawn.md new file mode 100644 index 00000000000..3b80eee6d5b --- /dev/null +++ b/.changeset/drop-import-local-cross-spawn.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +chore: replace the `import-local` and `cross-spawn` dependencies with Node.js built-ins diff --git a/package-lock.json b/package-lock.json index 49a920cf6da..8f9605385dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21066,9 +21066,7 @@ "dependencies": { "@discoveryjs/json-ext": "^1.1.0", "commander": "^14.0.3", - "cross-spawn": "^7.0.6", "envinfo": "^7.21.0", - "import-local": "^3.2.0", "interpret": "^3.1.1", "rechoir": "^1.0.0", "webpack-merge": "^6.0.1" diff --git a/packages/webpack-cli/bin/cli.js b/packages/webpack-cli/bin/cli.js index 0ebc85ef4ac..7575a0dc556 100755 --- a/packages/webpack-cli/bin/cli.js +++ b/packages/webpack-cli/bin/cli.js @@ -25,10 +25,56 @@ if (enableCompileCache) { } } +/** + * Runs the local installation of `webpack-cli` resolved from the current directory, unless it is + * this file, the same as `import-local` does. + * @returns {boolean} whether the local installation was run + */ +const runLocalCLI = () => { + const path = require("node:path"); + + let cwd; + + try { + cwd = require("node:fs").realpathSync(process.cwd()); + } catch (error) { + if (error.code !== "ENOENT") { + return false; + } + + cwd = path.resolve(process.cwd()); + } + + let localFile; + + try { + localFile = require("node:module") + .createRequire(path.join(cwd, "noop.js")) + .resolve("webpack-cli/bin/cli.js"); + } catch { + return false; + } + + const localNodeModules = path.join(process.cwd(), "node_modules"); + const isInLocalNodeModules = + !path.relative(localNodeModules, __filename).startsWith("..") && + // On Windows, `path.relative()` returns the second path when the paths are on different drives + path.parse(localNodeModules).root === path.parse(__filename).root; + + // `path.relative()` because the case of `__filename` is inconsistent on Windows + if (isInLocalNodeModules || path.relative(localFile, __filename) === "") { + return false; + } + + require(localFile); + + return true; +}; + // Prefer the local installation of `webpack-cli` when one exists. Run this // before requiring the (heavier) CLI implementation: a delegated run then never -// loads it, and `WEBPACK_CLI_SKIP_IMPORT_LOCAL` skips loading `import-local` too. -if (!process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL && require("import-local")(__filename)) { +// loads it, and `WEBPACK_CLI_SKIP_IMPORT_LOCAL` skips the lookup too. +if (!process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL && runLocalCLI()) { return; } diff --git a/packages/webpack-cli/package.json b/packages/webpack-cli/package.json index 944acfa7547..bf0489a9311 100644 --- a/packages/webpack-cli/package.json +++ b/packages/webpack-cli/package.json @@ -33,9 +33,7 @@ "dependencies": { "@discoveryjs/json-ext": "^1.1.0", "commander": "^14.0.3", - "cross-spawn": "^7.0.6", "envinfo": "^7.21.0", - "import-local": "^3.2.0", "interpret": "^3.1.1", "rechoir": "^1.0.0", "webpack-merge": "^6.0.1" diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 6633eb7ef60..cf38ba9bb1e 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -1,3 +1,4 @@ +import { type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; @@ -628,9 +629,41 @@ class WebpackCLI { }; } - async getDefaultPackageManager(): Promise { - const { sync } = await import("cross-spawn"); + /** + * Runs a command, the same as `cross-spawn` does. + * @param {string} command command + * @param {string[]} args arguments + * @param {SpawnSyncOptions} options options + * @returns {SpawnSyncReturns} result + */ + async #spawnSync( + command: string, + args: string[], + options: SpawnSyncOptions = {}, + ): Promise> { + const { spawnSync } = await import("node:child_process"); + + if (process.platform !== "win32") { + return spawnSync(command, args, options); + } + + // Package managers are `.cmd` files on Windows, which only a shell runs, and the shell doesn't + // escape arguments + const escapedArgs = args.map((arg) => { + const quoted = `"${arg + // Double the backslashes before a quote and escape the quote + .replaceAll(/(?=(\\+?)?)\1"/g, '$1$1\\"') + // Double the backslashes at the end, they precede the closing quote + .replace(/(?=(\\+?)?)\1$/, "$1$1")}"`; + + // Escape the characters `cmd.exe` treats specially + return quoted.replaceAll(/([()\][%!^"`<>&|;, *?])/g, "^$1"); + }); + + return spawnSync([command, ...escapedArgs].join(" "), { ...options, shell: true }); + } + async getDefaultPackageManager(): Promise { try { await fs.promises.access(path.resolve(process.cwd(), "package-lock.json"), fs.constants.F_OK); return "npm"; @@ -655,7 +688,7 @@ class WebpackCLI { try { // the sync function below will fail if npm is not installed, // an error will be thrown - if (sync("npm", ["--version"])) { + if (await this.#spawnSync("npm", ["--version"])) { return "npm"; } } catch { @@ -665,7 +698,7 @@ class WebpackCLI { try { // the sync function below will fail if yarn is not installed, // an error will be thrown - if (sync("yarn", ["--version"])) { + if (await this.#spawnSync("yarn", ["--version"])) { return "yarn"; } } catch { @@ -675,7 +708,7 @@ class WebpackCLI { try { // the sync function below will fail if pnpm is not installed, // an error will be thrown - if (sync("pnpm", ["--version"])) { + if (await this.#spawnSync("pnpm", ["--version"])) { return "pnpm"; } } catch { @@ -812,10 +845,8 @@ class WebpackCLI { } if (needInstall) { - const { sync } = await import("cross-spawn"); - try { - sync(packageManager, commandArguments, { stdio: "inherit" }); + await this.#spawnSync(packageManager, commandArguments, { stdio: "inherit" }); } catch (error) { this.logger.error(error); diff --git a/test/api/get-default-package-manager.test.js b/test/api/get-default-package-manager.test.js index 349a6f1cd66..994a6568792 100644 --- a/test/api/get-default-package-manager.test.js +++ b/test/api/get-default-package-manager.test.js @@ -5,9 +5,7 @@ const CLI = require("../../packages/webpack-cli/lib/webpack-cli").default; const syncMock = jest.fn(() => ({ stdout: "1.0.0", })); -jest.setMock("cross-spawn", { - sync: syncMock, -}); +jest.unstable_mockModule("node:child_process", () => ({ spawnSync: syncMock })); describe("getPackageManager", () => { let cli; diff --git a/test/api/install-package.test.js b/test/api/install-package.test.js index 51907d6555d..cb94d726888 100644 --- a/test/api/install-package.test.js +++ b/test/api/install-package.test.js @@ -14,7 +14,26 @@ jest.unstable_mockModule("node:readline", () => ({ const spawnMock = jest.fn(); -jest.mock("cross-spawn", () => ({ sync: spawnMock })); +jest.unstable_mockModule("node:child_process", () => ({ spawnSync: spawnMock })); + +/** + * Checks the command which installs the package. + * @param {string} packageManager package manager + * @param {string[]} args arguments + */ +const expectInstallCommand = (packageManager, args) => { + expect(spawnMock).toHaveBeenCalledTimes(1); + + if (process.platform === "win32") { + // Package managers are `.cmd` files on Windows, so they run in a shell with escaped arguments + expect(spawnMock).toHaveBeenCalledWith( + [packageManager, ...args.map((arg) => `^"${arg}^"`)].join(" "), + { stdio: "inherit", shell: true }, + ); + } else { + expect(spawnMock).toHaveBeenCalledWith(packageManager, args, { stdio: "inherit" }); + } +}; describe("installPackage", () => { let cli; @@ -45,9 +64,7 @@ describe("installPackage", () => { "Would you like to install 'test-package' package? (That will run 'npm install -D test-package')", ); - // install the package using npm - expect(spawnMock.mock.calls[0][0]).toBe("npm"); - expect(spawnMock.mock.calls[0][1]).toEqual(["install", "-D", "test-package"]); + expectInstallCommand("npm", ["install", "-D", "test-package"]); }); it("should prompt to install using yarn if yarn is package manager", async () => { @@ -63,9 +80,7 @@ describe("installPackage", () => { "Would you like to install 'test-package' package? (That will run 'yarn add -D test-package')", ); - // install the package using yarn - expect(spawnMock.mock.calls[0][0]).toBe("yarn"); - expect(spawnMock.mock.calls[0][1]).toEqual(["add", "-D", "test-package"]); + expectInstallCommand("yarn", ["add", "-D", "test-package"]); }); it("should prompt to install using pnpm if pnpm is package manager", async () => { @@ -81,9 +96,27 @@ describe("installPackage", () => { "Would you like to install 'test-package' package? (That will run 'pnpm install -D test-package')", ); - // install the package using npm - expect(spawnMock.mock.calls[0][0]).toBe("pnpm"); - expect(spawnMock.mock.calls[0][1]).toEqual(["install", "-D", "test-package"]); + expectInstallCommand("pnpm", ["install", "-D", "test-package"]); + }); + + it("should escape the arguments for the shell on Windows", async () => { + readlineQuestionMock.mockImplementation((_questionTest, cb) => cb("y")); + getDefaultPackageManagerSpy.mockReturnValue("npm"); + + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + + Object.defineProperty(process, "platform", { value: "win32" }); + + try { + await cli.installPackage('a"b&c'); + } finally { + Object.defineProperty(process, "platform", platform); + } + + expect(spawnMock).toHaveBeenCalledWith('npm ^"install^" ^"-D^" ^"a\\^"b^&c^"', { + stdio: "inherit", + shell: true, + }); }); it("should support pre message", async () => { @@ -101,9 +134,7 @@ describe("installPackage", () => { "Would you like to install 'test-package' package? (That will run 'npm install -D test-package')", ); - // install the package using npm - expect(spawnMock.mock.calls[0][0]).toBe("npm"); - expect(spawnMock.mock.calls[0][1]).toEqual(["install", "-D", "test-package"]); + expectInstallCommand("npm", ["install", "-D", "test-package"]); }); it("should prompt to install and install using 'y'", async () => { @@ -119,9 +150,7 @@ describe("installPackage", () => { "Would you like to install 'test-package' package? (That will run 'npm install -D test-package')", ); - // install the package using npm - expect(spawnMock.mock.calls[0][0]).toBe("npm"); - expect(spawnMock.mock.calls[0][1]).toEqual(["install", "-D", "test-package"]); + expectInstallCommand("npm", ["install", "-D", "test-package"]); }); it("should prompt to install and install using 'yes'", async () => { @@ -137,9 +166,7 @@ describe("installPackage", () => { "Would you like to install 'test-package' package? (That will run 'npm install -D test-package')", ); - // install the package using npm - expect(spawnMock.mock.calls[0][0]).toBe("npm"); - expect(spawnMock.mock.calls[0][1]).toEqual(["install", "-D", "test-package"]); + expectInstallCommand("npm", ["install", "-D", "test-package"]); }); it("should prompt to install and install using 'yEs'", async () => { @@ -155,9 +182,7 @@ describe("installPackage", () => { "Would you like to install 'test-package' package? (That will run 'npm install -D test-package')", ); - // install the package using npm - expect(spawnMock.mock.calls[0][0]).toBe("npm"); - expect(spawnMock.mock.calls[0][1]).toEqual(["install", "-D", "test-package"]); + expectInstallCommand("npm", ["install", "-D", "test-package"]); }); it("should not install if install is not confirmed", async () => { diff --git a/test/build/import-local/import-local.test.js b/test/build/import-local/import-local.test.js index f9c086114a1..0001f41d82f 100644 --- a/test/build/import-local/import-local.test.js +++ b/test/build/import-local/import-local.test.js @@ -1,20 +1,52 @@ "use strict"; +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const { cleanStderr, run } = require("../../utils/test-utils"); -const importLocalMock = jest.fn(); -jest.setMock("import-local", importLocalMock); - describe("import local", () => { - beforeEach(() => { - importLocalMock.mockClear(); + let directory; + + beforeAll(() => { + // A project with its own installation of `webpack-cli` + directory = fs.mkdtempSync(path.join(os.tmpdir(), "webpack-cli-import-local-")); + + const bin = path.join(directory, "node_modules", "webpack-cli", "bin"); + + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync( + path.join(directory, "node_modules", "webpack-cli", "package.json"), + JSON.stringify({ name: "webpack-cli", main: "bin/cli.js" }), + ); + fs.writeFileSync(path.join(bin, "cli.js"), 'console.log("local webpack-cli");\n'); }); - it("should skip import local when supplied", async () => { - const { exitCode, stderr, stdout } = await run(__dirname, [], { + afterAll(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("should run the local installation", async () => { + const { exitCode, stderr, stdout } = await run(directory, ["--version"]); + + expect(exitCode).toBe(0); + expect(cleanStderr(stderr)).toBeFalsy(); + expect(stdout.trim()).toBe("local webpack-cli"); + }); + + it("should skip the local installation when supplied", async () => { + const { exitCode, stdout } = await run(directory, ["--version"], { env: { WEBPACK_CLI_SKIP_IMPORT_LOCAL: true }, }); - expect(importLocalMock).not.toHaveBeenCalled(); + + expect(exitCode).toBe(0); + expect(stdout).toBeTruthy(); + expect(stdout).not.toContain("local webpack-cli"); + }); + + it("should build without a local installation", async () => { + const { exitCode, stderr, stdout } = await run(__dirname, []); + expect(exitCode).toBe(0); expect(cleanStderr(stderr)).toBeFalsy(); expect(stdout).toBeTruthy(); From b7ba1114319e7743d03c1e0e639d8ea9e7e82bb8 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:17:23 +0000 Subject: [PATCH 2/7] test: ignore engines of packages installed by Yarn in generator tests webpack-cli@7.3.0 depends on rechoir 1, which requires Node.js 22.15, and Yarn 1 fails the install on Node.js 20. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- test/create-webpack-app/test.utils.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/create-webpack-app/test.utils.js b/test/create-webpack-app/test.utils.js index 541bcfad14b..6721fff7c91 100644 --- a/test/create-webpack-app/test.utils.js +++ b/test/create-webpack-app/test.utils.js @@ -22,6 +22,9 @@ const packageManagerEnv = { os.tmpdir(), `webpack-cli-yarn-cache-${process.env.JEST_WORKER_ID || 1}`, ), + // Yarn 1 fails on packages which require a newer Node.js version than the one running tests, + // the tests check the generated projects and not the engines of the installed packages + YARN_IGNORE_ENGINES: "true", }; /** From e76d9abc8b3cc502967e687e641c82328e1f4a93 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:08:38 +0000 Subject: [PATCH 3/7] fix: find the command file on Windows like cross-spawn Run .exe and .com files without a shell and escape the arguments twice for the .cmd shims of packages, they pass them to another shell. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- .cspell.json | 1 + packages/webpack-cli/src/webpack-cli.ts | 57 +++++++++++++++++-- test/api/install-package.test.js | 75 +++++++++++++++++++++---- 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/.cspell.json b/.cspell.json index 565018f91da..3d9a5243b1c 100644 --- a/.cspell.json +++ b/.cspell.json @@ -64,6 +64,7 @@ "Nitin", "nwjs", "Oikawa", + "PATHEXT", "pathinfo", "plopfile", "pnpm", diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index cf38ba9bb1e..31bfeb65ca0 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -647,20 +647,65 @@ class WebpackCLI { return spawnSync(command, args, options); } - // Package managers are `.cmd` files on Windows, which only a shell runs, and the shell doesn't - // escape arguments + // Find the file of the command like `which`, on Windows the current directory goes first + const extensions = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";"); + + if (command.includes(".") && extensions[0] !== "") { + extensions.unshift(""); + } + + const directories = /[/\\]/.test(command) + ? [""] + : [process.cwd(), ...(process.env.PATH || "").split(";")]; + let file: string | undefined; + + search: for (const directory of directories) { + for (const extension of extensions) { + const candidate = path.resolve( + path.join(directory.replace(/^"(.*)"$/, "$1"), command) + extension, + ); + + try { + if ( + fs.statSync(candidate).isFile() && + extensions.some( + (extension) => + extension !== "" && candidate.toLowerCase().endsWith(extension.toLowerCase()), + ) + ) { + file = candidate; + + break search; + } + } catch { + // Not found + } + } + } + + // Executables run directly + if (file && /\.(?:com|exe)$/i.test(file)) { + return spawnSync(command, args, options); + } + + // Other files, e.g. `npm.cmd`, only run in a shell, which doesn't escape arguments, and the + // `.cmd` shims of packages pass them to another shell, so they are escaped twice + const isShim = Boolean(file && /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i.test(file)); + const metaChars = /([()\][%!^"`<>&|;, *?])/g; const escapedArgs = args.map((arg) => { const quoted = `"${arg // Double the backslashes before a quote and escape the quote .replaceAll(/(?=(\\+?)?)\1"/g, '$1$1\\"') // Double the backslashes at the end, they precede the closing quote - .replace(/(?=(\\+?)?)\1$/, "$1$1")}"`; + .replace(/(?=(\\+?)?)\1$/, "$1$1")}"`.replaceAll(metaChars, "^$1"); - // Escape the characters `cmd.exe` treats specially - return quoted.replaceAll(/([()\][%!^"`<>&|;, *?])/g, "^$1"); + return isShim ? quoted.replaceAll(metaChars, "^$1") : quoted; }); - return spawnSync([command, ...escapedArgs].join(" "), { ...options, shell: true }); + return spawnSync( + [path.normalize(command).replaceAll(metaChars, "^$1"), ...escapedArgs].join(" "), + { ...options, shell: true }, + ); } async getDefaultPackageManager(): Promise { diff --git a/test/api/install-package.test.js b/test/api/install-package.test.js index cb94d726888..bacde514164 100644 --- a/test/api/install-package.test.js +++ b/test/api/install-package.test.js @@ -1,5 +1,8 @@ "use strict"; +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); const { stripVTControlCharacters } = require("node:util"); const CLI = require("../../packages/webpack-cli/lib/webpack-cli").default; @@ -99,23 +102,71 @@ describe("installPackage", () => { expectInstallCommand("pnpm", ["install", "-D", "test-package"]); }); - it("should escape the arguments for the shell on Windows", async () => { - readlineQuestionMock.mockImplementation((_questionTest, cb) => cb("y")); - getDefaultPackageManagerSpy.mockReturnValue("npm"); + describe("on Windows", () => { + let directory; + let platform; + let env; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "webpack-cli-install-package-")); + platform = Object.getOwnPropertyDescriptor(process, "platform"); + env = { PATH: process.env.PATH, PATHEXT: process.env.PATHEXT }; + + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.PATH = directory; + process.env.PATHEXT = ".exe;.cmd"; + readlineQuestionMock.mockImplementation((_questionTest, cb) => cb("y")); + getDefaultPackageManagerSpy.mockReturnValue("npm"); + }); - const platform = Object.getOwnPropertyDescriptor(process, "platform"); + afterEach(() => { + Object.defineProperty(process, "platform", platform); - Object.defineProperty(process, "platform", { value: "win32" }); + for (const [name, value] of Object.entries(env)) { + if (typeof value === "undefined") { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it("should escape the arguments for the shell", async () => { + fs.writeFileSync(path.join(directory, "npm.cmd"), ""); - try { await cli.installPackage('a"b&c'); - } finally { - Object.defineProperty(process, "platform", platform); - } - expect(spawnMock).toHaveBeenCalledWith('npm ^"install^" ^"-D^" ^"a\\^"b^&c^"', { - stdio: "inherit", - shell: true, + expect(spawnMock).toHaveBeenCalledWith('npm ^"install^" ^"-D^" ^"a\\^"b^&c^"', { + stdio: "inherit", + shell: true, + }); + }); + + it("should escape the arguments twice for the shims of packages", async () => { + const bin = path.join(directory, "node_modules", ".bin"); + + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, "npm.cmd"), ""); + process.env.PATH = bin; + + await cli.installPackage('a"b&c'); + + expect(spawnMock).toHaveBeenCalledWith( + 'npm ^^^"install^^^" ^^^"-D^^^" ^^^"a\\^^^"b^^^&c^^^"', + { stdio: "inherit", shell: true }, + ); + }); + + it("should run executables without a shell", async () => { + fs.writeFileSync(path.join(directory, "npm.exe"), ""); + + await cli.installPackage("test-package"); + + expect(spawnMock).toHaveBeenCalledWith("npm", ["install", "-D", "test-package"], { + stdio: "inherit", + }); }); }); From bbc02d77313b3275908ea35f397a58d6547b911b Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:14:10 +0000 Subject: [PATCH 4/7] fix: detect missing package managers spawnSync returns a result instead of throwing, so the CLI always chose npm without a lock file, even when npm was missing. Check the error and the exit status, so yarn and pnpm are found too. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- packages/webpack-cli/src/webpack-cli.ts | 33 +++++--------------- test/api/get-default-package-manager.test.js | 31 ++++++++++++++---- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 31bfeb65ca0..5887a727845 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -730,37 +730,18 @@ class WebpackCLI { // Nothing } - try { - // the sync function below will fail if npm is not installed, - // an error will be thrown - if (await this.#spawnSync("npm", ["--version"])) { - return "npm"; - } - } catch { - // Nothing - } + // A missing package manager fails to start, or exits with an error in the shell on Windows + for (const packageManager of ["npm", "yarn", "pnpm"] as const) { + const { error, status } = await this.#spawnSync(packageManager, ["--version"]); - try { - // the sync function below will fail if yarn is not installed, - // an error will be thrown - if (await this.#spawnSync("yarn", ["--version"])) { - return "yarn"; + if (!error && status === 0) { + return packageManager; } - } catch { - // Nothing } - try { - // the sync function below will fail if pnpm is not installed, - // an error will be thrown - if (await this.#spawnSync("pnpm", ["--version"])) { - return "pnpm"; - } - } catch { - this.logger.error("No package manager found."); + this.logger.error("No package manager found."); - process.exit(2); - } + process.exit(2); } async isPackageInstalled(packageName: string): Promise { diff --git a/test/api/get-default-package-manager.test.js b/test/api/get-default-package-manager.test.js index 994a6568792..c6feaa11e8a 100644 --- a/test/api/get-default-package-manager.test.js +++ b/test/api/get-default-package-manager.test.js @@ -2,9 +2,9 @@ const fs = require("node:fs"); const path = require("node:path"); const CLI = require("../../packages/webpack-cli/lib/webpack-cli").default; -const syncMock = jest.fn(() => ({ - stdout: "1.0.0", -})); +const installed = () => ({ status: 0, stdout: "1.0.0" }); +const missing = () => ({ error: Object.assign(new Error("spawnSync ENOENT"), { code: "ENOENT" }) }); +const syncMock = jest.fn(installed); jest.unstable_mockModule("node:child_process", () => ({ spawnSync: syncMock })); describe("getPackageManager", () => { @@ -38,6 +38,7 @@ describe("getPackageManager", () => { cli = new CLI(); syncMock.mockClear(); + syncMock.mockImplementation(installed); }); it("should find yarn.lock", async () => { @@ -96,11 +97,26 @@ describe("getPackageManager", () => { expect(syncMock).toHaveBeenCalledTimes(1); }); + it("should find yarn when npm is missing", async () => { + cwdSpy.mockReturnValue(noLockPath); + syncMock.mockImplementationOnce(missing); + + expect(await cli.getDefaultPackageManager()).toBe("yarn"); + expect(syncMock).toHaveBeenCalledTimes(2); + }); + + it("should find pnpm when npm fails and yarn is missing", async () => { + cwdSpy.mockReturnValue(noLockPath); + // On Windows a missing command fails in the shell + syncMock.mockReturnValueOnce({ status: 1 }).mockImplementationOnce(missing); + + expect(await cli.getDefaultPackageManager()).toBe("pnpm"); + expect(syncMock).toHaveBeenCalledTimes(3); + }); + it("should throw error if no package manager is found", async () => { cwdSpy.mockReturnValue(noLockPath); - syncMock.mockImplementation(() => { - throw new Error("error"); - }); + syncMock.mockImplementation(missing); const mockExit = jest.spyOn(process, "exit").mockImplementation(() => {}); // Do not print warning in CI const consoleMock = jest.spyOn(console, "error").mockImplementation(() => {}); @@ -109,5 +125,8 @@ describe("getPackageManager", () => { expect(mockExit).toHaveBeenCalledWith(2); expect(consoleMock).toHaveBeenCalledTimes(1); expect(syncMock).toHaveBeenCalledTimes(3); // 3 calls for npm, yarn and pnpm + + mockExit.mockRestore(); + consoleMock.mockRestore(); }); }); From 9ce652f0a9c6dd4354e8c7bd651d627c66fa27c1 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:14:28 +0000 Subject: [PATCH 5/7] chore: add a changeset for the package manager detection fix Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- .changeset/detect-missing-package-managers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/detect-missing-package-managers.md diff --git a/.changeset/detect-missing-package-managers.md b/.changeset/detect-missing-package-managers.md new file mode 100644 index 00000000000..50f3083d021 --- /dev/null +++ b/.changeset/detect-missing-package-managers.md @@ -0,0 +1,5 @@ +--- +"webpack-cli": patch +--- + +fix: detect missing package managers, without a lock file the CLI chose npm even when it wasn't installed instead of yarn or pnpm From e2f2dcb50218a8da55985f6d8fd6a8e9d889c1b5 Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:30:32 +0000 Subject: [PATCH 6/7] refactor: run package managers asynchronously getDefaultPackageManager and installPackage are async, so spawn the commands and look up their files on Windows without blocking. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- packages/webpack-cli/src/webpack-cli.ts | 35 +++++++----- test/api/get-default-package-manager.test.js | 58 +++++++++++++------- test/api/install-package.test.js | 17 +++++- 3 files changed, 74 insertions(+), 36 deletions(-) diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 5887a727845..91136b70a23 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -1,4 +1,4 @@ -import { type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process"; +import { type SpawnOptions } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; @@ -633,18 +633,24 @@ class WebpackCLI { * Runs a command, the same as `cross-spawn` does. * @param {string} command command * @param {string[]} args arguments - * @param {SpawnSyncOptions} options options - * @returns {SpawnSyncReturns} result + * @param {SpawnOptions} options options + * @returns {Promise<{ status: number | null, error?: Error }>} exit status, or the error when the command fails to start */ - async #spawnSync( + async #spawn( command: string, args: string[], - options: SpawnSyncOptions = {}, - ): Promise> { - const { spawnSync } = await import("node:child_process"); + options: SpawnOptions = {}, + ): Promise<{ status: number | null; error?: Error }> { + const { spawn } = await import("node:child_process"); + const run = (command: string, args: string[], options: SpawnOptions) => + new Promise<{ status: number | null; error?: Error }>((resolve) => { + spawn(command, args, options) + .once("error", (error) => resolve({ status: null, error })) + .once("close", (status) => resolve({ status })); + }); if (process.platform !== "win32") { - return spawnSync(command, args, options); + return run(command, args, options); } // Find the file of the command like `which`, on Windows the current directory goes first @@ -667,7 +673,7 @@ class WebpackCLI { try { if ( - fs.statSync(candidate).isFile() && + (await fs.promises.stat(candidate)).isFile() && extensions.some( (extension) => extension !== "" && candidate.toLowerCase().endsWith(extension.toLowerCase()), @@ -685,7 +691,7 @@ class WebpackCLI { // Executables run directly if (file && /\.(?:com|exe)$/i.test(file)) { - return spawnSync(command, args, options); + return run(command, args, options); } // Other files, e.g. `npm.cmd`, only run in a shell, which doesn't escape arguments, and the @@ -702,8 +708,9 @@ class WebpackCLI { return isShim ? quoted.replaceAll(metaChars, "^$1") : quoted; }); - return spawnSync( + return run( [path.normalize(command).replaceAll(metaChars, "^$1"), ...escapedArgs].join(" "), + [], { ...options, shell: true }, ); } @@ -732,7 +739,9 @@ class WebpackCLI { // A missing package manager fails to start, or exits with an error in the shell on Windows for (const packageManager of ["npm", "yarn", "pnpm"] as const) { - const { error, status } = await this.#spawnSync(packageManager, ["--version"]); + const { error, status } = await this.#spawn(packageManager, ["--version"], { + stdio: "ignore", + }); if (!error && status === 0) { return packageManager; @@ -872,7 +881,7 @@ class WebpackCLI { if (needInstall) { try { - await this.#spawnSync(packageManager, commandArguments, { stdio: "inherit" }); + await this.#spawn(packageManager, commandArguments, { stdio: "inherit" }); } catch (error) { this.logger.error(error); diff --git a/test/api/get-default-package-manager.test.js b/test/api/get-default-package-manager.test.js index c6feaa11e8a..e252b1fc86e 100644 --- a/test/api/get-default-package-manager.test.js +++ b/test/api/get-default-package-manager.test.js @@ -1,11 +1,29 @@ +const { EventEmitter } = require("node:events"); const fs = require("node:fs"); const path = require("node:path"); const CLI = require("../../packages/webpack-cli/lib/webpack-cli").default; -const installed = () => ({ status: 0, stdout: "1.0.0" }); -const missing = () => ({ error: Object.assign(new Error("spawnSync ENOENT"), { code: "ENOENT" }) }); -const syncMock = jest.fn(installed); -jest.unstable_mockModule("node:child_process", () => ({ spawnSync: syncMock })); +/** + * Creates a process which exits with the status, or fails to start with the error. + * @param {number | Error} result exit status or error + * @returns {() => EventEmitter} process + */ +const processWith = (result) => () => { + // eslint-disable-next-line unicorn/prefer-event-target -- Child processes are event emitters + const child = new EventEmitter(); + + process.nextTick(() => + result instanceof Error ? child.emit("error", result) : child.emit("close", result), + ); + + return child; +}; + +const installed = processWith(0); +const missing = processWith(Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" })); +const spawnMock = jest.fn(installed); + +jest.unstable_mockModule("node:child_process", () => ({ spawn: spawnMock })); describe("getPackageManager", () => { let cli; @@ -37,86 +55,86 @@ describe("getPackageManager", () => { beforeEach(() => { cli = new CLI(); - syncMock.mockClear(); - syncMock.mockImplementation(installed); + spawnMock.mockClear(); + spawnMock.mockImplementation(installed); }); it("should find yarn.lock", async () => { cwdSpy.mockReturnValue(testYarnLockPath); expect(await cli.getDefaultPackageManager()).toBe("yarn"); - expect(syncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("should find package-lock.json", async () => { cwdSpy.mockReturnValue(testNpmLockPath); expect(await cli.getDefaultPackageManager()).toBe("npm"); - expect(syncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("should find pnpm-lock.yaml", async () => { cwdSpy.mockReturnValue(testPnpmLockPath); expect(await cli.getDefaultPackageManager()).toBe("pnpm"); - expect(syncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("should prioritize npm over pnpm", async () => { cwdSpy.mockReturnValue(testNpmAndPnpmPath); expect(await cli.getDefaultPackageManager()).toBe("npm"); - expect(syncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("should prioritize npm over yarn", async () => { cwdSpy.mockReturnValue(testNpmAndYarnPath); expect(await cli.getDefaultPackageManager()).toBe("npm"); - expect(syncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("should prioritize yarn over pnpm", async () => { cwdSpy.mockReturnValue(testYarnAndPnpmPath); expect(await cli.getDefaultPackageManager()).toBe("yarn"); - expect(syncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("should prioritize npm with many lock files", async () => { cwdSpy.mockReturnValue(testAllPath); expect(await cli.getDefaultPackageManager()).toBe("npm"); - expect(syncMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); }); it("should prioritize global npm over other package managers", async () => { cwdSpy.mockReturnValue(noLockPath); expect(await cli.getDefaultPackageManager()).toBe("npm"); - expect(syncMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledTimes(1); }); it("should find yarn when npm is missing", async () => { cwdSpy.mockReturnValue(noLockPath); - syncMock.mockImplementationOnce(missing); + spawnMock.mockImplementationOnce(missing); expect(await cli.getDefaultPackageManager()).toBe("yarn"); - expect(syncMock).toHaveBeenCalledTimes(2); + expect(spawnMock).toHaveBeenCalledTimes(2); }); it("should find pnpm when npm fails and yarn is missing", async () => { cwdSpy.mockReturnValue(noLockPath); // On Windows a missing command fails in the shell - syncMock.mockReturnValueOnce({ status: 1 }).mockImplementationOnce(missing); + spawnMock.mockImplementationOnce(processWith(1)).mockImplementationOnce(missing); expect(await cli.getDefaultPackageManager()).toBe("pnpm"); - expect(syncMock).toHaveBeenCalledTimes(3); + expect(spawnMock).toHaveBeenCalledTimes(3); }); it("should throw error if no package manager is found", async () => { cwdSpy.mockReturnValue(noLockPath); - syncMock.mockImplementation(missing); + spawnMock.mockImplementation(missing); const mockExit = jest.spyOn(process, "exit").mockImplementation(() => {}); // Do not print warning in CI const consoleMock = jest.spyOn(console, "error").mockImplementation(() => {}); @@ -124,7 +142,7 @@ describe("getPackageManager", () => { expect(await cli.getDefaultPackageManager()).toBeFalsy(); expect(mockExit).toHaveBeenCalledWith(2); expect(consoleMock).toHaveBeenCalledTimes(1); - expect(syncMock).toHaveBeenCalledTimes(3); // 3 calls for npm, yarn and pnpm + expect(spawnMock).toHaveBeenCalledTimes(3); // 3 calls for npm, yarn and pnpm mockExit.mockRestore(); consoleMock.mockRestore(); diff --git a/test/api/install-package.test.js b/test/api/install-package.test.js index bacde514164..3fe5800cf0e 100644 --- a/test/api/install-package.test.js +++ b/test/api/install-package.test.js @@ -1,5 +1,6 @@ "use strict"; +const { EventEmitter } = require("node:events"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); @@ -15,9 +16,17 @@ jest.unstable_mockModule("node:readline", () => ({ }), })); -const spawnMock = jest.fn(); +// A process which exits successfully +const spawnMock = jest.fn(() => { + // eslint-disable-next-line unicorn/prefer-event-target -- Child processes are event emitters + const child = new EventEmitter(); -jest.unstable_mockModule("node:child_process", () => ({ spawnSync: spawnMock })); + process.nextTick(() => child.emit("close", 0)); + + return child; +}); + +jest.unstable_mockModule("node:child_process", () => ({ spawn: spawnMock })); /** * Checks the command which installs the package. @@ -31,6 +40,7 @@ const expectInstallCommand = (packageManager, args) => { // Package managers are `.cmd` files on Windows, so they run in a shell with escaped arguments expect(spawnMock).toHaveBeenCalledWith( [packageManager, ...args.map((arg) => `^"${arg}^"`)].join(" "), + [], { stdio: "inherit", shell: true }, ); } else { @@ -138,7 +148,7 @@ describe("installPackage", () => { await cli.installPackage('a"b&c'); - expect(spawnMock).toHaveBeenCalledWith('npm ^"install^" ^"-D^" ^"a\\^"b^&c^"', { + expect(spawnMock).toHaveBeenCalledWith('npm ^"install^" ^"-D^" ^"a\\^"b^&c^"', [], { stdio: "inherit", shell: true, }); @@ -155,6 +165,7 @@ describe("installPackage", () => { expect(spawnMock).toHaveBeenCalledWith( 'npm ^^^"install^^^" ^^^"-D^^^" ^^^"a\\^^^"b^^^&c^^^"', + [], { stdio: "inherit", shell: true }, ); }); From 7c0b979cc51f39120c29a5e8b0c8cb8eb93a43dc Mon Sep 17 00:00:00 2001 From: Alexander Akait <4567934+alexander-akait@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:35:46 +0000 Subject: [PATCH 7/7] fix: exit when a package fails to install The result of the package manager was ignored, so the CLI continued as if the package was installed. Claude-Session: https://claude.ai/code/session_013FhutRFjuQemBzekJMYcqL --- .changeset/detect-missing-package-managers.md | 2 +- packages/webpack-cli/src/webpack-cli.ts | 12 ++++++- test/api/install-package.test.js | 36 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.changeset/detect-missing-package-managers.md b/.changeset/detect-missing-package-managers.md index 50f3083d021..efd4f4023aa 100644 --- a/.changeset/detect-missing-package-managers.md +++ b/.changeset/detect-missing-package-managers.md @@ -2,4 +2,4 @@ "webpack-cli": patch --- -fix: detect missing package managers, without a lock file the CLI chose npm even when it wasn't installed instead of yarn or pnpm +fix: detect missing package managers and failed installations, without a lock file the CLI chose npm even when it wasn't installed instead of yarn or pnpm, and it continued after a package failed to install diff --git a/packages/webpack-cli/src/webpack-cli.ts b/packages/webpack-cli/src/webpack-cli.ts index 91136b70a23..12eae0a3794 100644 --- a/packages/webpack-cli/src/webpack-cli.ts +++ b/packages/webpack-cli/src/webpack-cli.ts @@ -881,7 +881,17 @@ class WebpackCLI { if (needInstall) { try { - await this.#spawn(packageManager, commandArguments, { stdio: "inherit" }); + const { error, status } = await this.#spawn(packageManager, commandArguments, { + stdio: "inherit", + }); + + if (error) { + throw error; + } + + if (status !== 0) { + throw new Error(`'${commandToBeRun}' exited with code ${status}`); + } } catch (error) { this.logger.error(error); diff --git a/test/api/install-package.test.js b/test/api/install-package.test.js index 3fe5800cf0e..1f9976a4b49 100644 --- a/test/api/install-package.test.js +++ b/test/api/install-package.test.js @@ -247,6 +247,42 @@ describe("installPackage", () => { expectInstallCommand("npm", ["install", "-D", "test-package"]); }); + it.each([ + [ + "exits with an error", + (child) => child.emit("close", 1), + "'npm install -D test-package' exited with code 1", + ], + [ + "fails to start", + (child) => child.emit("error", new Error("spawn npm ENOENT")), + "spawn npm ENOENT", + ], + ])("should exit when the installation %s", async (_name, fail, message) => { + readlineQuestionMock.mockImplementation((_questionTest, cb) => cb("y")); + getDefaultPackageManagerSpy.mockReturnValue("npm"); + spawnMock.mockImplementationOnce(() => { + // eslint-disable-next-line unicorn/prefer-event-target -- Child processes are event emitters + const child = new EventEmitter(); + + process.nextTick(() => fail(child)); + + return child; + }); + + const mockExit = jest.spyOn(process, "exit").mockImplementation(() => {}); + const consoleMock = jest.spyOn(console, "error").mockImplementation(() => {}); + + await cli.installPackage("test-package"); + + expect(mockExit).toHaveBeenCalledWith(2); + expect(consoleMock).toHaveBeenCalledTimes(1); + expect(stripVTControlCharacters(consoleMock.mock.calls[0][0])).toContain(message); + + mockExit.mockRestore(); + consoleMock.mockRestore(); + }); + it("should not install if install is not confirmed", async () => { readlineQuestionMock.mockImplementation((_questionTest, cb) => cb("n"));