diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8c3c9b94db..9201bf382e 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2381[#2381]: Make node and npm pristine versioned tools with per-project npm global prefix The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/51?closed=1[milestone 2026.10.001]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java b/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java index d3215a5c09..b4ff2e9735 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java +++ b/cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java @@ -50,6 +50,16 @@ public class SystemPath { private final IdeContext context; + /** + * Tools whose binaries are also bundled inside another (runtime) tool's install folder and would therefore be shadowed by it on the PATH. + *
+ * {@code node} ships its own {@code npm}/{@code npx}/{@code corepack} inside its (flat, no {@code bin/}) install folder, so without an explicit order the
+ * pristine, independently versioned {@code npm} install and the node-bundled one would compete for the same binary names in an arbitrary order
+ * ({@code tool2pathMap} is a {@link HashMap}). Ordering these tools first guarantees the pristine installation deterministically wins. If another tool ever
+ * becomes independently versioned the same way, it is added here.
+ */
+ private static final List
+ * npm is installed as a pristine, versioned installation in the software repository (same model as the other tools) and is
+ * linked into each project's {@code software} folder. Global npm packages are installed into a per-project directory
+ * (see {@link #NPM_GLOBAL_FOLDER}) so that projects do not interfere with each other (see issue #352 and issue #2381).
*/
-public class Npm extends NpmBasedCommandlet {
+public class Npm extends LocalToolCommandlet {
+
+ private static final Logger LOG = LoggerFactory.getLogger(Npm.class);
private static final String NPM_HOME_FOLDER = "npm";
+ /** The npm CLI entry point inside a flat npm installation ({@code
+ * The npm registry tarball extracts a flat layout ({@code bin/npm-cli.js}) but ships the launcher shims in the layout npm uses when it is bundled inside
+ * a node distribution: {@code bin/npm.cmd}, {@code bin/npx.cmd}, {@code bin/npm.ps1}, {@code bin/npx.ps1}, {@code bin/npm} and {@code bin/npx} all point
+ * at a non-existent {@code node_modules/npm/bin/npm-cli.js} (or a sibling {@code node.exe}). Consequently, on Windows the {@code npm}/{@code npx} shims
+ * fail with {@code MODULE_NOT_FOUND}, and on Linux they silently run the npm that is bundled with the node distribution rather than this pristine npm.
+ * Since the npm bin folder is first on the PATH (see
+ * {@link com.devonfw.tools.ide.common.SystemPath#getToolPathsInResolutionOrder()}), the broken shims would otherwise shadow the correct tool for every
+ * invocation of {@code npm}/{@code npx}.
+ *
+ * This hook rewrites the shims to launch this installation's own CLI entry points ({@code bin/npm-cli.js}/{@code bin/npx-cli.js}) with the {@code node}
+ * runtime that is already on the PATH. This is a no-op for installations that do not contain a flat {@code bin/npm-cli.js} (e.g. a node-bundled npm or the
+ * pre-seeded test fixtures) so their shims are left untouched.
+ *
+ * @param extractedDir the {@link Path} to the folder with the unpacked npm tool (the package root, containing {@code bin/}).
+ */
+ @Override
+ protected void postExtract(Path extractedDir) {
+
+ super.postExtract(extractedDir);
+ Path bin = extractedDir.resolve(IdeContext.FOLDER_BIN);
+ if (!Files.isRegularFile(bin.resolve(NPM_CLI_JS))) {
+ return;
+ }
+ FileAccess fileAccess = this.context.getFileAccess();
+ boolean windows = this.context.getSystemInfo().isWindows();
+ if (windows) {
+ // The .cmd/.ps1 shims are only ever executed on Windows; on other platforms they are not run and thus left untouched.
+ repairShim(fileAccess, bin.resolve("npm.cmd"), cmdShim(NPM_CLI_JS));
+ repairShim(fileAccess, bin.resolve("npx.cmd"), cmdShim(NPX_CLI_JS));
+ repairShim(fileAccess, bin.resolve("npm.ps1"), psShim(NPM_CLI_JS));
+ repairShim(fileAccess, bin.resolve("npx.ps1"), psShim(NPX_CLI_JS));
+ } else {
+ // The POSIX shims are only ever executed on non-Windows systems; the .cmd/.ps1 shims are not run there and are left untouched.
+ repairShim(fileAccess, bin.resolve("npm"), posixShim(NPM_CLI_JS));
+ repairShim(fileAccess, bin.resolve("npx"), posixShim(NPX_CLI_JS));
+ }
+ LOG.debug("Repaired the npm launcher shims of the pristine npm installation at {} to use the flat layout.", bin);
+ }
+
+ /**
+ * Overwrites the given launcher shim with the provided flat-layout content and, on non-Windows systems, marks it executable so that the POSIX shims
+ * {@code bin/npm}/{@code bin/npx} are runnable.
+ *
+ * @param fileAccess the {@link FileAccess} to use.
+ * @param shim the {@link Path} of the shim to repair.
+ * @param content the new flat-layout content for the shim.
+ */
+ private void repairShim(FileAccess fileAccess, Path shim, String content) {
+
+ fileAccess.writeFileContent(content, shim, false);
+ // On non-Windows, make the POSIX shims (bin/npm, bin/npx) executable so they can be launched from the PATH.
+ // The .cmd/.ps1 shims are not launched as POSIX executables and do not need the execute bit.
+ if (!this.context.getSystemInfo().isWindows() && !shim.getFileName().toString().contains(".")) {
+ fileAccess.makeExecutable(shim);
+ }
+ }
+
+ /**
+ * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch.
+ * @return the {@code .cmd} (Windows) shim content for the flat npm layout.
+ */
+ private static String cmdShim(String cliJs) {
+
+ return "@ECHO OFF\r\n" //
+ + "SET \"NODE_CMD=" + NODE + "\"\r\n" //
+ + "\"%NODE_CMD%\" \"%~dp0" + cliJs + "\" %*\r\n";
+ }
+
+ /**
+ * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch.
+ * @return the {@code .ps1} (PowerShell) shim content for the flat npm layout.
+ */
+ private static String psShim(String cliJs) {
+
+ return "$node = \"" + NODE + "\"\r\n" //
+ + "& $node (Join-Path $PSScriptRoot \"" + cliJs + "\") @args\r\n";
+ }
+
+ /**
+ * @param cliJs the {@link #NPM_CLI_JS npm CLI entry point} (or {@link #NPX_CLI_JS} for npx) to launch.
+ * @return the POSIX shim content for the flat npm layout.
+ */
+ private static String posixShim(String cliJs) {
+
+ return "#!/usr/bin/env bash\r\n" //
+ + "basedir=\"$(dirname \"$0\")\"\r\n" //
+ + "exec node \"$basedir/" + cliJs + "\" \"$@\"\r\n";
}
}
diff --git a/cli/src/main/package/functions b/cli/src/main/package/functions
index 3fa7b8a15d..258cd71e80 100644
--- a/cli/src/main/package/functions
+++ b/cli/src/main/package/functions
@@ -239,6 +239,22 @@ function claude() {
fi
}
+function node() {
+ if [ -n "${IDE_HOME}" ]; then
+ ide node "$@"
+ else
+ command node "$@"
+ fi
+}
+
+function npm() {
+ if [ -n "${IDE_HOME}" ]; then
+ ide npm "$@"
+ else
+ command npm "$@"
+ fi
+}
+
_ide_create_project()
{
local found_create=false
diff --git a/cli/src/main/package/functions.ps1 b/cli/src/main/package/functions.ps1
index 9050a1cd54..3bd5666f35 100644
--- a/cli/src/main/package/functions.ps1
+++ b/cli/src/main/package/functions.ps1
@@ -312,6 +312,48 @@ function claude {
& $claudeCommand.Source @args
}
+function node {
+ if (-not [string]::IsNullOrEmpty($env:IDE_HOME)) {
+ ide node @args
+ return
+ }
+
+ # "node" is now a PowerShell function, so explicitly search for
+ # the external command to avoid recursively calling this function.
+ $nodeCommand = Get-Command node `
+ -CommandType Application, ExternalScript `
+ -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+
+ if ($null -eq $nodeCommand) {
+ Write-Error "The command 'node' could not be found."
+ return
+ }
+
+ & $nodeCommand.Source @args
+}
+
+function npm {
+ if (-not [string]::IsNullOrEmpty($env:IDE_HOME)) {
+ ide npm @args
+ return
+ }
+
+ # "npm" is now a PowerShell function, so explicitly search for
+ # the external command to avoid recursively calling this function.
+ $npmCommand = Get-Command npm `
+ -CommandType Application, ExternalScript `
+ -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+
+ if ($null -eq $npmCommand) {
+ Write-Error "The command 'npm' could not be found."
+ return
+ }
+
+ & $npmCommand.Source @args
+}
+
function _ide_completion {
$registerCommand = Get-Command Register-ArgumentCompleter -ErrorAction SilentlyContinue
diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java
index 6fc49aa501..cf4095013e 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/EnvironmentCommandletTest.java
@@ -10,6 +10,7 @@
import com.devonfw.tools.ide.log.IdeLogEntry;
import com.devonfw.tools.ide.log.IdeLogLevel;
import com.devonfw.tools.ide.os.SystemInfoMock;
+import com.devonfw.tools.ide.tool.npm.Npm;
/**
* Test of {@link EnvironmentCommandlet}.
@@ -126,6 +127,7 @@ void testRunInfoLogging() {
IdeLogEntry.ofProcessable("export MVN_HOME=\"" + softwarePath.resolve("mvn") + "\""),
IdeLogEntry.ofProcessable("MVN_VERSION=\"3.9.1\""), //
IdeLogEntry.ofProcessable("export NPM_CONFIG_USERCONFIG=\"" + normalize(context.getNpmConfigUserConfig()) + "\""), //
+ IdeLogEntry.ofProcessable("export NPM_HOME=\"" + context.getSoftwarePath() + FileSystems.getDefault().getSeparator() + "npm\""), //
new IdeLogEntry(IdeLogLevel.PROCESSABLE, "export PATH=", true), //
IdeLogEntry.ofProcessable("SOME=\"some-${UNDEFINED}\""), //
IdeLogEntry.ofProcessable("TEST_ARGS1=\" user1 settings1 workspace1 conf1\""), //
@@ -144,7 +146,9 @@ void testRunInfoLogging() {
IdeLogEntry.ofProcessable("TEST_ARGSd=\" user1 settings1 workspace1 conf1 userd workspaced\""), //
IdeLogEntry.ofProcessable("export UV_CACHE_DIR=\"" + normalize(context.getIdeRoot().resolve("_ide/software/uv")) + "\""), //
IdeLogEntry.ofProcessable("WORKSPACE=\"foo-test\""), //
- IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\"") //
+ IdeLogEntry.ofProcessable("WORKSPACE_PATH=\"" + normalize(context.getWorkspacePath()) + "\""), //
+ IdeLogEntry.ofProcessable(
+ "export npm_config_prefix=\"" + context.getIdeHome() + FileSystems.getDefault().getSeparator() + Npm.NPM_GLOBAL_FOLDER + "\"") //
);
}
diff --git a/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java b/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java
index 2fff021f0f..98b008fbb1 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/common/SystemPathTest.java
@@ -145,6 +145,29 @@ void testFindBinaryFindsNothingWithoutFilter() {
assertThat(result).isEqualTo(test);
}
+ @Test
+ void testPrecedenceToolBinPrecedesBundlingRuntimeBinForBothLookupAndPathString() throws IOException {
+ // arrange - node is a flat install (no bin/), so node's own bundled npm shadows the pristine npm unless npm is ordered first
+ // copyForMutation=true: this test creates files, so it must work on a scratch copy (in target/), never the committed fixtures
+ IdeTestContext context = newContext("find-binary", "project/workspaces", true);
+ Path npmTool = context.getSoftwarePath().resolve("npm");
+ Files.createDirectories(npmTool.resolve("bin"));
+ Files.writeString(npmTool.resolve("bin/npm"), "pristine-npm");
+ Path nodeTool = context.getSoftwarePath().resolve("node");
+ Files.createDirectories(nodeTool);
+ Files.writeString(nodeTool.resolve("npm"), "node-bundled-npm");
+ SystemPath systemPath = new SystemPath(context, "", context.getIdeRoot(), context.getSoftwarePath(), ';', new ArrayList<>());
+ Path pristine = npmTool.resolve("bin").resolve("npm");
+ Path bundled = nodeTool.resolve("npm");
+
+ // act & assert - findBinary resolves the pristine npm, not the node-bundled one
+ assertThat(systemPath.findBinary(Path.of("npm"))).isEqualTo(pristine).isNotEqualTo(bundled);
+ // act & assert - the pristine npm bin is also placed before the node dir in the PATH string
+ String pathString = systemPath.toString();
+ assertThat(pathString.indexOf(npmTool.resolve("bin").toString()))
+ .isNotNegative().isLessThan(pathString.indexOf(nodeTool.toString()));
+ }
+
@Test
void testFindBinaryFindsBinaryInExtraPathEntries() throws IOException {
// arrange
diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java
index fd7546f1e2..7b43aab8ed 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/tool/corepack/CorepackTest.java
@@ -88,7 +88,6 @@ void testCorepackRun(WireMockRuntimeInfo wireMockRuntimeInfo) {
private void checkInstallation(IdeTestContext context) {
assertThat(context).logAtInfo().hasMessageContaining("npm install -gf corepack@0.34.0");
- assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful");
assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed corepack in version 0.34.0");
}
}
diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java
index b5bdd75f0b..84ba1c0eac 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/tool/npm/NpmTest.java
@@ -1,9 +1,18 @@
package com.devonfw.tools.ide.tool.npm;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import org.junit.jupiter.api.Test;
import com.devonfw.tools.ide.context.AbstractIdeContextTest;
import com.devonfw.tools.ide.context.IdeTestContext;
+import com.devonfw.tools.ide.io.FileAccess;
+import com.devonfw.tools.ide.process.EnvironmentContext;
+import com.devonfw.tools.ide.tool.ToolInstallation;
+import com.devonfw.tools.ide.tool.claude.RecordingEnvironmentContext;
+import com.devonfw.tools.ide.version.VersionIdentifier;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
@@ -77,16 +86,92 @@ void testNpmUninstall(WireMockRuntimeInfo wireMockRuntimeInfo) {
// assert II
assertThat(context).logAtInfo().hasNoMessageContaining("npm uninstall -g npm");
- assertThat(context).logAtInfo().hasMessageContaining("IDEasy does not support uninstalling the tool npm since this will break your installation.\n"
- + "If you really want to uninstall it, please uninstall its parent tool via:\n"
- + "ide uninstall node");
assertThat(context).logAtSuccess().hasMessage("Successfully uninstalled npm");
}
+ /**
+ * Tests that {@link Npm#setEnvironment(EnvironmentContext, ToolInstallation, boolean)} points the npm global prefix at a per-project
+ * {@code .npm-global} folder inside the IDE home so that projects do not interfere with each other (see
+ * issue #352 and issue #2381).
+ */
+ @Test
+ void testSetEnvironmentPointsGlobalPrefixToPerProjectNpmGlobal() {
+
+ // arrange
+ IdeTestContext context = newContext(PROJECT_NPM, (String) null, false);
+ Npm commandlet = new Npm(context);
+ Path dummy = context.getSoftwarePath().resolve("npm");
+ ToolInstallation installation = new ToolInstallation(dummy, dummy, dummy, VersionIdentifier.of("9.9.2"), false);
+ RecordingEnvironmentContext environmentContext = new RecordingEnvironmentContext();
+
+ // act
+ commandlet.setEnvironment(environmentContext, installation, false);
+
+ // assert
+ Path npmGlobalPath = context.getIdeHome().resolve(Npm.NPM_GLOBAL_FOLDER);
+ assertThat(environmentContext.set).containsEntry("npm_config_prefix", npmGlobalPath.toString());
+ }
+
+ /**
+ * Tests that {@link Npm#postExtract(Path)} rewrites the npm launcher shims (that the npm registry tarball ships in the node-bundled layout) to the flat
+ * layout, so that {@code npm}/{@code npx} resolve to this pristine installation instead of the npm bundled with node (see issue #2381).
+ */
+ @Test
+ void testPostExtractRepairsFlatLayoutShims() {
+
+ // arrange - a flat npm installation (as extracted from the npm registry tarball) with the broken node-bundled-layout shims
+ IdeTestContext context = newContext(PROJECT_NPM, (String) null, false);
+ Npm commandlet = new Npm(context);
+ FileAccess fileAccess = context.getFileAccess();
+ Path extractedDir = null;
+ try {
+ extractedDir = Files.createTempDirectory("npm-shim-repair");
+ Path bin = extractedDir.resolve("bin");
+ fileAccess.mkdirs(bin);
+ // flat-layout CLI entry points (these are the ones the shims should launch)
+ fileAccess.touch(bin.resolve("npm-cli.js"));
+ fileAccess.touch(bin.resolve("npx-cli.js"));
+ // the node-bundled-layout shims that npm's registry tarball ships (broken - they point at node_modules/npm/bin/npm-cli.js)
+ String bundledShim = "node node_modules\\npm\\bin\\npm-cli.js";
+ for (String shim : new String[] { "npm.cmd", "npx.cmd", "npm.ps1", "npx.ps1", "npm", "npx" }) {
+ fileAccess.writeFileContent(bundledShim, bin.resolve(shim), false);
+ }
+
+ // act
+ commandlet.postExtract(extractedDir);
+
+ // assert - only the shims of the current platform were repaired and now launch this installation's own CLI entry
+ // points instead of the broken node-bundled layout (node_modules\npm\bin\npm-cli.js)
+ boolean windows = context.getSystemInfo().isWindows();
+ if (windows) {
+ String npmCmd = fileAccess.readFileContent(bin.resolve("npm.cmd"));
+ org.assertj.core.api.Assertions.assertThat(npmCmd).contains("npm-cli.js").doesNotContain("node_modules");
+ String npxCmd = fileAccess.readFileContent(bin.resolve("npx.cmd"));
+ org.assertj.core.api.Assertions.assertThat(npxCmd).contains("npx-cli.js").doesNotContain("node_modules");
+ String npmPs1 = fileAccess.readFileContent(bin.resolve("npm.ps1"));
+ org.assertj.core.api.Assertions.assertThat(npmPs1).contains("npm-cli.js").doesNotContain("node_modules");
+ } else {
+ String npmPosix = fileAccess.readFileContent(bin.resolve("npm"));
+ org.assertj.core.api.Assertions.assertThat(npmPosix).contains("npm-cli.js").doesNotContain("node_modules");
+ String npxPosix = fileAccess.readFileContent(bin.resolve("npx"));
+ org.assertj.core.api.Assertions.assertThat(npxPosix).contains("npx-cli.js").doesNotContain("node_modules");
+ }
+ // the CLI entry points themselves are left untouched
+ org.assertj.core.api.Assertions.assertThat(Files.isRegularFile(bin.resolve("npm-cli.js"))).isTrue();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ } finally {
+ if (extractedDir != null) {
+ fileAccess.delete(extractedDir);
+ }
+ }
+ }
+
private void checkInstallation(IdeTestContext context) {
assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed npm in version 9.9.2");
- assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful");
}
}
diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java
index 0750c3b179..345d10b474 100644
--- a/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java
+++ b/cli/src/test/java/com/devonfw/tools/ide/tool/yarn/YarnTest.java
@@ -105,7 +105,6 @@ void testYarnRun(WireMockRuntimeInfo wireMockRuntimeInfo) {
private void checkInstallation(IdeTestContext context) {
assertThat(context).logAtInfo().hasMessage("npm install -gf yarn@2.4.3");
- assertThat(context).logAtSuccess().hasMessageContaining("Setting npm config prefix to: " + context.getSoftwarePath().resolve("node") + " was successful");
assertThat(context).logAtSuccess().hasMessageContaining("Successfully installed yarn in version 2.4.3");
}
}
diff --git a/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..f5a0cfa6d0
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npm
@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ "${1}" == "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/build/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"
diff --git a/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls
new file mode 100644
index 0000000000..290f63d98e
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/cdk/_ide/urls/npm/npm/9.9.2/urls
@@ -0,0 +1 @@
+${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz
diff --git a/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..f5a0cfa6d0
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npm
@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ "${1}" == "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/cdk/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"
diff --git a/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls
new file mode 100644
index 0000000000..290f63d98e
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/corepack/_ide/urls/npm/npm/9.9.2/urls
@@ -0,0 +1 @@
+${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz
diff --git a/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..4eb4b307cf
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npm
@@ -0,0 +1,21 @@
+#!/bin/bash
+if [ "$1" = "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+if [ "$1" = "list" ] && [ "$2" = "-g" ]; then
+ # Report a version only if the package binary is actually present in the per-project
+ # global prefix (pristine npm reports nothing for a package that is not installed yet).
+ if [ -n "$npm_config_prefix" ] && [ -e "$npm_config_prefix/bin/$3" ]; then
+ echo "-- $3@0.34.0"
+ fi
+ exit 1
+fi
+if [ "$1" = "install" ]; then
+ # Pristine: global packages are installed into the per-project global prefix.
+ if [ -n "$npm_config_prefix" ]; then
+ mkdir -p "$npm_config_prefix/bin"
+ cp target/ide-projects/corepack/repository/corepack/corepack/default/corepack "$npm_config_prefix/bin/corepack"
+ fi
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/corepack/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"
diff --git a/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls
new file mode 100644
index 0000000000..290f63d98e
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/nest/_ide/urls/npm/npm/9.9.2/urls
@@ -0,0 +1 @@
+${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz
diff --git a/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..f5a0cfa6d0
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npm
@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ "${1}" == "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/nest/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"
diff --git a/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls
new file mode 100644
index 0000000000..290f63d98e
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/ng/_ide/urls/npm/npm/9.9.2/urls
@@ -0,0 +1 @@
+${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz
diff --git a/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..f5a0cfa6d0
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npm
@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ "${1}" == "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/ng/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"
diff --git a/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls
new file mode 100644
index 0000000000..290f63d98e
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/npm/_ide/urls/npm/npm/9.9.2/urls
@@ -0,0 +1 @@
+${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz
diff --git a/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..f5a0cfa6d0
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npm
@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ "${1}" == "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/npm/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"
diff --git a/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls
new file mode 100644
index 0000000000..290f63d98e
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/task/_ide/urls/npm/npm/9.9.2/urls
@@ -0,0 +1 @@
+${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz
diff --git a/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..f5a0cfa6d0
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npm
@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ "${1}" == "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/task/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"
diff --git a/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls b/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls
new file mode 100644
index 0000000000..290f63d98e
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/yarn/_ide/urls/npm/npm/9.9.2/urls
@@ -0,0 +1 @@
+${testbaseurl}/download/npm/npm/9.9.2/npm-9.9.2.tgz
diff --git a/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm
new file mode 100755
index 0000000000..64d5424857
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npm
@@ -0,0 +1,21 @@
+#!/bin/bash
+if [ "$1" = "--version" ]; then
+ echo "9.9.2"
+ exit
+fi
+if [ "$1" = "list" ] && [ "$2" = "-g" ]; then
+ # Report a version only if the package binary is actually present in the per-project
+ # global prefix (pristine npm reports nothing for a package that is not installed yet).
+ if [ -n "$npm_config_prefix" ] && [ -e "$npm_config_prefix/bin/$3" ]; then
+ echo "-- $3@9.9.2"
+ fi
+ exit 1
+fi
+if [ "$1" = "install" ]; then
+ # Pristine: global packages are installed into the per-project global prefix.
+ if [ -n "$npm_config_prefix" ]; then
+ mkdir -p "$npm_config_prefix/bin"
+ cp target/ide-projects/yarn/repository/yarn/yarn/default/yarn "$npm_config_prefix/bin/yarn"
+ fi
+fi
+echo "npm $*"
diff --git a/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx
new file mode 100755
index 0000000000..0723c2041b
--- /dev/null
+++ b/cli/src/test/resources/ide-projects/yarn/repository/npm/npm/default/bin/npx
@@ -0,0 +1,2 @@
+#!/bin/bash
+echo "npx $*"