Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
e2b1fe8
#2381: Make node and npm pristine, versioned tools (WIP)
krystynaShatkovska Aug 31, 2026
f8e619c
#2381: Fix standalone npm stub fixtures to model pristine .npm-global…
krystynaShatkovska Sep 1, 2026
cb5fdf8
#2381: Restore npm build-descriptor detection and update env log test
krystynaShatkovska Sep 1, 2026
901d4bf
#2381: Add PowerShell wrappers for node and npm to match bash functions
krystynaShatkovska Sep 1, 2026
1808301
Merge branch 'main' into feature/issue-2381-node-npm-bash-isolation-poc
krystynaShatkovska Sep 1, 2026
98d1821
#2381: Mark npm stub fixtures executable to fix CI test failures
krystynaShatkovska Sep 1, 2026
9d2a189
#2381: Add changelog entry for pristine node/npm tools
krystynaShatkovska Sep 1, 2026
34d0bf0
#2381: Repair the npm launcher shims so they resolve to the pristine npm
krystynaShatkovska Sep 2, 2026
797e445
Merge remote-tracking branch 'upstream/main' into feature/issue-2381-…
krystynaShatkovska Sep 21, 2026
4a22dd1
#2381: Address review - generalize SystemPath tool precedence and rep…
krystynaShatkovska Sep 21, 2026
6ebd488
#2381: Fix tests for platform-specific npm shims and isolated test pr…
krystynaShatkovska Sep 21, 2026
7286907
Merge branch 'main' into feature/issue-2381-node-npm-bash-isolation-poc
krystynaShatkovska Sep 21, 2026
c1f3fe7
Merge remote-tracking branch 'origin/feature/issue-2381-node-npm-bash…
krystynaShatkovska Sep 21, 2026
35ca03d
Merge branch 'main' into feature/issue-2381-node-npm-bash-isolation-poc
hohwille Sep 25, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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].

Expand Down
36 changes: 34 additions & 2 deletions cli/src/main/java/com/devonfw/tools/ide/common/SystemPath.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* {@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<String> PATH_PRECEDENCE_TOOLS = List.of("npm");

private static final List<String> EXTENSION_PRIORITY = List.of(".exe", ".cmd", ".bat", ".msi", ".ps1", "");

/**
Expand Down Expand Up @@ -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<Path> getToolPathsInResolutionOrder() {

List<Path> 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<String, Path> entry : this.tool2pathMap.entrySet()) {
if (!PATH_PRECEDENCE_TOOLS.contains(entry.getKey())) {
orderedPaths.add(entry.getValue());
}
}
return orderedPaths;
}
Comment thread
krystynaShatkovska marked this conversation as resolved.

private static String getTool(Path path, Path ideRoot) {

if (ideRoot == null) {
Expand Down Expand Up @@ -290,7 +322,7 @@ public Path findBinary(Path toolPath, Predicate<Path> 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;
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 0 additions & 29 deletions cli/src/main/java/com/devonfw/tools/ide/tool/node/Node.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="https://nodejs.org/">node</a>.
Expand All @@ -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());
}
}
Comment thread
krystynaShatkovska marked this conversation as resolved.

@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;
}
}
167 changes: 148 additions & 19 deletions cli/src/main/java/com/devonfw/tools/ide/tool/npm/Npm.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="https://www.npmjs.com/">npm</a>.
* {@link LocalToolCommandlet} for <a href="https://www.npmjs.org/">npm</a>.
* <p>
* 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 <a href=
* "https://github.com/devonfw/IDEasy/issues/352">issue #352</a> and <a href=
* "https://github.com/devonfw/IDEasy/issues/2381">issue #2381</a>).
*/
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 <tool>/bin/npm-cli.js}). */
static final String NPM_CLI_JS = "npm-cli.js";

/** The npx CLI entry point inside a flat npm installation ({@code <tool>/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.
*
Expand All @@ -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)) {
Expand All @@ -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.
* <p>
* 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}.
* <p>
* 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";
}
}
16 changes: 16 additions & 0 deletions cli/src/main/package/functions
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading