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 PATH_PRECEDENCE_TOOLS = List.of("npm"); + private static final List EXTENSION_PRIORITY = List.of(".exe", ".cmd", ".bat", ".msi", ".ps1", ""); /** @@ -205,6 +215,28 @@ private void collectToolPath(Path softwarePath) { } } + /** + * @return the tool bin {@link Path}s in the order in which they must be searched and placed on the PATH. The {@link #PATH_PRECEDENCE_TOOLS} are placed + * first so that their executables deterministically take precedence over the copies bundled with the runtime that also ships them (e.g. the pristine + * {@code npm} over the {@code npm}/{@code npx} bundled with {@code node}); the remaining tools are returned in their map order. + */ + private List getToolPathsInResolutionOrder() { + + List orderedPaths = new ArrayList<>(this.tool2pathMap.size()); + for (String tool : PATH_PRECEDENCE_TOOLS) { + Path toolPath = this.tool2pathMap.get(tool); + if (toolPath != null) { + orderedPaths.add(toolPath); + } + } + for (Map.Entry entry : this.tool2pathMap.entrySet()) { + if (!PATH_PRECEDENCE_TOOLS.contains(entry.getKey())) { + orderedPaths.add(entry.getValue()); + } + } + return orderedPaths; + } + private static String getTool(Path path, Path ideRoot) { if (ideRoot == null) { @@ -290,7 +322,7 @@ public Path findBinary(Path toolPath, Predicate filter) { return binaryPath; } } - for (Path path : this.tool2pathMap.values()) { + for (Path path : getToolPathsInResolutionOrder()) { Path binaryPath = findBinaryInOrder(path, fileName); if (binaryPath != null && filter.test(binaryPath)) { return binaryPath; @@ -352,7 +384,7 @@ public String toString(WindowsPathSyntax pathSyntax) { for (Path path : this.extraPathEntries) { appendPath(path, sb, separator, pathSyntax); } - for (Path path : this.tool2pathMap.values()) { + for (Path path : getToolPathsInResolutionOrder()) { appendPath(path, sb, separator, pathSyntax); } for (Path path : this.paths) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java b/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java index c478896c6c..b039f4c7ec 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java @@ -7,14 +7,8 @@ import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; -import com.devonfw.tools.ide.log.IdeLogLevel; import com.devonfw.tools.ide.nls.NlsBundle; -import com.devonfw.tools.ide.process.ProcessResult; import com.devonfw.tools.ide.tool.LocalToolCommandlet; -import com.devonfw.tools.ide.tool.PackageManagerRequest; -import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.tool.ToolInstallRequest; -import com.devonfw.tools.ide.tool.npm.Npm; /** * {@link ToolCommandlet} for node. @@ -33,32 +27,9 @@ public Node(IdeContext context) { super(context, "node", Set.of(Tag.JAVA_SCRIPT, Tag.RUNTIME)); } - @Override - protected void postInstallOnNewInstallation(ToolInstallRequest request) { - - super.postInstallOnNewInstallation(request); - // this code is slightly dangerous: npm has a dependency to node, while here in node we call npm causing a cyclic dependency - // the problem is that node package already comes with npm so we already have to configure npm properly here - // inside npm we have to guarantee that we will not trigger an installation (again) causing an infinity loop - // we would love to get this clean but node and npm are already flawed forcing us to do such hacks... - Npm npm = this.context.getCommandletManager().getCommandlet(Npm.class); - PackageManagerRequest packageManagerRequest = new PackageManagerRequest("config", this.tool).addArg("config").addArg("set").addArg("prefix") - .addArg(getToolPath().toString()); - ProcessResult result = npm.runPackageManager(packageManagerRequest, true); - if (result.isSuccessful()) { - IdeLogLevel.SUCCESS.log(LOG, "Setting npm config prefix to: {} was successful", getToolPath()); - } - } - @Override public void printHelp(NlsBundle bundle) { LOG.info("For a list of supported options and arguments, use \"node --help\""); } - - @Override - protected boolean isIgnoreSoftwareRepo() { - - return true; - } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java index ab2aa92da9..72ec8f8f77 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java @@ -4,19 +4,46 @@ import java.nio.file.Path; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.devonfw.tools.ide.common.Tag; import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.process.EnvironmentContext; +import com.devonfw.tools.ide.tool.LocalToolCommandlet; import com.devonfw.tools.ide.tool.ToolInstallation; -import com.devonfw.tools.ide.version.VersionIdentifier; /** - * {@link NpmBasedCommandlet} for npm. + * {@link LocalToolCommandlet} for npm. + *

+ * 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 /bin/npm-cli.js}). */ + static final String NPM_CLI_JS = "npm-cli.js"; + + /** The npx CLI entry point inside a flat npm installation ({@code /bin/npx-cli.js}). */ + static final String NPX_CLI_JS = "npx-cli.js"; + + /** The command name of the {@link com.devonfw.tools.ide.tool.node.Node node.js} runtime to launch. */ + static final String NODE = "node"; + + /** File name of the {@link #findBuildDescriptor(Path) build descriptor} of an npm project. */ + private static final String PACKAGE_JSON = "package.json"; + + /** The folder name for the per-project global npm packages inside {@link IdeContext#getIdeHome() IDE_HOME}. */ + public static final String NPM_GLOBAL_FOLDER = ".npm-global"; + /** * The constructor. * @@ -27,34 +54,35 @@ public Npm(IdeContext context) { super(context, "npm", Set.of(Tag.JAVA_SCRIPT, Tag.BUILD)); } - @Override - protected boolean canBeUninstalled() { - return false; - } - - @Override - protected VersionIdentifier computeInstalledPackageVersion() { - if (hasNodeBinary("npm")) { - return VersionIdentifier.of(this.context.newProcess().runAndGetSingleOutput("npm", "--version")); - } - return null; - } - @Override public String getToolHelpArguments() { return "help"; } + /** + * Detects an npm project by its {@code package.json} build descriptor so that the {@code build} commandlet (and the + * {@link com.devonfw.tools.ide.commandlet.BuildCommandlet BuildCommandlet}) can dispatch it to npm. npm is a standalone tool + * (not a child of {@link com.devonfw.tools.ide.tool.node.Node node}) now, so it must declare its build descriptor itself. + * + * @param directory the {@link Path} to the build directory. + * @return the {@code package.json} {@link Path} if it exists, or {@code null} otherwise. + */ @Override - protected boolean isSkipInstallation() { - return true; + public Path findBuildDescriptor(Path directory) { + + Path buildDescriptor = directory.resolve(PACKAGE_JSON); + if (Files.exists(buildDescriptor)) { + return buildDescriptor; + } + return super.findBuildDescriptor(directory); } /** * @return the {@link Path} to the npm user configuration file, creates the folder and configuration file if it was not existing. */ public Path getOrCreateNpmConfigUserConfig() { + Path confPath = this.context.getConfPath().resolve(NPM_HOME_FOLDER); Path npmConfigFile = confPath.resolve(".npmrc"); if (!Files.isDirectory(confPath)) { @@ -66,7 +94,108 @@ public Path getOrCreateNpmConfigUserConfig() { @Override public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { + super.setEnvironment(environmentContext, toolInstallation, additionalInstallation); - environmentContext.withEnvVar("npm_config_prefix", this.context.getSoftwarePath().resolve("node").toString()); + // Global npm packages must not be installed into the shared node installation (issue #352) - they go into a per-project + // directory instead so that projects do not interfere with each other. Outside of a project we do not pin the prefix so + // that the system npm (if the tool is not installed by IDEasy) keeps its own behavior. + Path ideHome = this.context.getIdeHome(); + if (ideHome != null) { + Path npmGlobalPath = ideHome.resolve(NPM_GLOBAL_FOLDER); + environmentContext.withEnvVar("npm_config_prefix", npmGlobalPath.toString()); + environmentContext.withPathEntry(npmGlobalPath.resolve(IdeContext.FOLDER_BIN)); + } + } + + /** + * Repairs the npm launcher shims ({@code npm}/{@code npx}) so that they resolve to this pristine installation instead of the npm that is bundled with + * node. + *

+ * 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 $*"