Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/detect-missing-package-managers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-cli": patch
---

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
5 changes: 5 additions & 0 deletions .changeset/drop-import-local-cross-spawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"webpack-cli": patch
---

chore: replace the `import-local` and `cross-spawn` dependencies with Node.js built-ins
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"Nitin",
"nwjs",
"Oikawa",
"PATHEXT",
"pathinfo",
"plopfile",
"pnpm",
Expand Down
2 changes: 0 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 48 additions & 2 deletions packages/webpack-cli/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 0 additions & 2 deletions packages/webpack-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
138 changes: 107 additions & 31 deletions packages/webpack-cli/src/webpack-cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type SpawnOptions } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
Expand Down Expand Up @@ -628,9 +629,93 @@ class WebpackCLI {
};
}

async getDefaultPackageManager(): Promise<PackageManager | undefined> {
const { sync } = await import("cross-spawn");
/**
* Runs a command, the same as `cross-spawn` does.
* @param {string} command command
* @param {string[]} args arguments
* @param {SpawnOptions} options options
* @returns {Promise<{ status: number | null, error?: Error }>} exit status, or the error when the command fails to start
*/
async #spawn(
command: string,
args: string[],
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 run(command, args, options);
}

// 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 (
(await fs.promises.stat(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 run(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")}"`.replaceAll(metaChars, "^$1");

return isShim ? quoted.replaceAll(metaChars, "^$1") : quoted;
});

return run(
[path.normalize(command).replaceAll(metaChars, "^$1"), ...escapedArgs].join(" "),
[],
{ ...options, shell: true },
);
}

async getDefaultPackageManager(): Promise<PackageManager | undefined> {
try {
await fs.promises.access(path.resolve(process.cwd(), "package-lock.json"), fs.constants.F_OK);
return "npm";
Expand All @@ -652,37 +737,20 @@ class WebpackCLI {
// Nothing
}

try {
// the sync function below will fail if npm is not installed,
// an error will be thrown
if (sync("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.#spawn(packageManager, ["--version"], {
stdio: "ignore",
});

try {
// the sync function below will fail if yarn is not installed,
// an error will be thrown
if (sync("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 (sync("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<boolean> {
Expand Down Expand Up @@ -812,10 +880,18 @@ class WebpackCLI {
}

if (needInstall) {
const { sync } = await import("cross-spawn");

try {
sync(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);

Expand Down
Loading
Loading