Skip to content
Open
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
18 changes: 17 additions & 1 deletion src/node/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@
"default": "/usr/local/share/nvm",
"description": "The path where NVM will be installed."
},
"npmVersion": {
"type": "string",
"proposals": [
"lts",
"latest",
"10.9.0",
"10.8.0",
"10.7.0",
"9.9.3",
"8.19.4",
"latest",
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The npmVersion proposals list contains latest twice, which is confusing and suggests a copy/paste mistake. Remove the duplicate entry to keep the UI suggestions clean.

Suggested change
"latest",

Copilot uses AI. Check for mistakes.
"none"
],
"default": "10.9.0",
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting the default npmVersion to a concrete version (10.9.0) changes the feature’s default behavior for all consumers (it will now always attempt to upgrade npm during build). To avoid a breaking/behavior-changing default, consider defaulting to none (keep Node’s bundled npm) and let users opt into pinning/upgrading explicitly.

Suggested change
"default": "10.9.0",
"default": "none",

Copilot uses AI. Check for mistakes.
"description": "Select or enter a specific NPM version to install globally. Use 'latest' for the latest version, 'none' to skip npm version update, or specify a version like '10.9.0'."
},
"pnpmVersion": {
"type": "string",
"proposals": [
Expand Down Expand Up @@ -78,4 +94,4 @@
"installsAfter": [
"ghcr.io/devcontainers/features/common-utils"
]
}
}
99 changes: 99 additions & 0 deletions src/node/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# Maintainer: The Dev Container spec maintainers

export NODE_VERSION="${VERSION:-"lts"}"
export NPM_VERSION="${NPMVERSION:-"lts"}"
export PNPM_VERSION="${PNPMVERSION:-"latest"}"
export NVM_VERSION="${NVMVERSION:-"latest"}"
export NVM_DIR="${NVMINSTALLPATH:-"/usr/local/share/nvm"}"
Expand Down Expand Up @@ -381,6 +382,104 @@ if [ ! -z "${ADDITIONAL_VERSIONS}" ]; then
IFS=$OLDIFS
fi

# Install or update npm to specific version
if [ ! -z "${NPM_VERSION}" ] && [ "${NPM_VERSION}" = "none" ]; then
echo "Ignoring NPM version update"
else
if bash -c ". '${NVM_DIR}/nvm.sh' && type npm >/dev/null 2>&1"; then
(
. "${NVM_DIR}/nvm.sh"
[ ! -z "$http_proxy" ] && npm set proxy="$http_proxy"
[ ! -z "$https_proxy" ] && npm set https-proxy="$https_proxy"
[ ! -z "$no_proxy" ] && npm set noproxy="$no_proxy"
echo "Installing npm version ${NPM_VERSION}..."

CURRENT_NPM_VERSION=$(npm --version 2>/dev/null || echo 'unknown')
echo "Current npm version: $CURRENT_NPM_VERSION"

# Clear npm cache and extract version numbers
npm cache clean --force 2>/dev/null || true
CURRENT_MAJOR=$(echo "$CURRENT_NPM_VERSION" | cut -d. -f1 || echo "0")
NODE_MAJOR=$(node --version 2>/dev/null | cut -d. -f1 | tr -d 'v' || echo "0")

# Dynamically check npm's Node.js requirements and auto-fallback if incompatible
ORIGINAL_NPM_VERSION="$NPM_VERSION"
if [ "$NPM_VERSION" != "none" ]; then
echo "Checking npm compatibility requirements..."
NPM_NODE_REQUIREMENT=$(npm view npm@${NPM_VERSION} engines.node 2>/dev/null || echo "")

if [ -n "$NPM_NODE_REQUIREMENT" ]; then
echo "npm $NPM_VERSION requires Node.js: $NPM_NODE_REQUIREMENT"

# Extract minimum required Node version from requirement string
MIN_NODE=$(echo "$NPM_NODE_REQUIREMENT" | grep -oE '[0-9]+' | head -1 || echo "0")

if [ "$MIN_NODE" -gt "0" ] && [ "$NODE_MAJOR" -lt "$MIN_NODE" ]; then
echo "⚠️ WARNING: npm $NPM_VERSION requires Node.js $MIN_NODE+, you have $NODE_MAJOR.x"

# Find compatible npm version dynamically using same logic
echo "🔍 Finding compatible npm version for Node.js $NODE_MAJOR.x..."

# Try npm major versions in descending order to find highest compatible version
for npm_major in 10 9 8 7 6; do
echo "Checking npm $npm_major compatibility..."
FALLBACK_NODE_REQUIREMENT=$(npm view "npm@${npm_major}" engines.node 2>/dev/null || echo "")

if [ -n "$FALLBACK_NODE_REQUIREMENT" ]; then
MIN_NODE=$(echo "$FALLBACK_NODE_REQUIREMENT" | grep -oE '[0-9]+' | head -1 || echo "0")

if [ "$MIN_NODE" -le "$NODE_MAJOR" ]; then
# Get latest patch version for this compatible major version
NPM_VERSION=$(npm view "npm@${npm_major}" version 2>/dev/null || echo "")
if [ -n "$NPM_VERSION" ]; then
echo "✓ Found compatible npm $NPM_VERSION (requires Node.js $MIN_NODE+)"
echo "🔄 Auto-fallback: Installing compatible npm $NPM_VERSION instead"
break
fi
fi
fi
done

# If no compatible version found, skip npm installation
if [ "$NPM_VERSION" = "$ORIGINAL_NPM_VERSION" ]; then
echo "❌ Could not find compatible npm version, keeping current npm"
NPM_VERSION="none"
fi
elif [ "$MIN_NODE" -gt "0" ]; then
echo "✓ Node.js $NODE_MAJOR.x meets npm $NPM_VERSION requirement"
fi
else
echo "Could not determine Node.js requirements for npm $NPM_VERSION, proceeding anyway..."
fi
fi

# Use special upgrade method for npm 10.x to latest (only if not falling back)
if [ "$ORIGINAL_NPM_VERSION" = "latest" ] && [ "$NPM_VERSION" = "latest" ] && [ "$CURRENT_MAJOR" = "10" ]; then
echo "Using npmjs.org install script for npm upgrade"
curl -fsSL https://www.npmjs.com/install.sh | sh 2>/dev/null || true
fi

Comment on lines +456 to +461
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The curl -fsSL https://www.npmjs.com/install.sh | sh path is both redundant (the script still runs npm install -g npm@latest afterwards) and it suppresses all errors via || true, which can leave npm in a partially-modified state without any signal. Consider removing this special-case, or at least failing/branching based on the script’s exit code.

Suggested change
# Use special upgrade method for npm 10.x to latest (only if not falling back)
if [ "$ORIGINAL_NPM_VERSION" = "latest" ] && [ "$NPM_VERSION" = "latest" ] && [ "$CURRENT_MAJOR" = "10" ]; then
echo "Using npmjs.org install script for npm upgrade"
curl -fsSL https://www.npmjs.com/install.sh | sh 2>/dev/null || true
fi

Copilot uses AI. Check for mistakes.
# Try npm installation with retries
for i in {1..3}; do
echo "Attempt $i: Running npm install -g npm@$NPM_VERSION"
if npm install -g npm@$NPM_VERSION --force --no-audit --no-fund 2>&1; then
NEW_VERSION=$(npm --version 2>/dev/null || echo 'unknown')
echo "Successfully installed npm@${NPM_VERSION}, new version: $NEW_VERSION"
break
else
echo "Attempt $i failed, retrying..."
sleep 2
if [ $i -eq 3 ]; then
echo "Failed to install npm@${NPM_VERSION} after 3 attempts. Keeping current npm version $(npm --version 2>/dev/null || echo 'unknown')."
fi
fi
done
Comment on lines +455 to +476
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inside the compatibility fallback, NPM_VERSION can be set to none (line 446), but the script still proceeds into the install loop and runs npm install -g npm@none three times. Add a guard after the compatibility logic to skip installation when NPM_VERSION becomes none (or empty) to avoid unnecessary failures/retries and noisy logs.

Suggested change
# Use special upgrade method for npm 10.x to latest (only if not falling back)
if [ "$ORIGINAL_NPM_VERSION" = "latest" ] && [ "$NPM_VERSION" = "latest" ] && [ "$CURRENT_MAJOR" = "10" ]; then
echo "Using npmjs.org install script for npm upgrade"
curl -fsSL https://www.npmjs.com/install.sh | sh 2>/dev/null || true
fi
# Try npm installation with retries
for i in {1..3}; do
echo "Attempt $i: Running npm install -g npm@$NPM_VERSION"
if npm install -g npm@$NPM_VERSION --force --no-audit --no-fund 2>&1; then
NEW_VERSION=$(npm --version 2>/dev/null || echo 'unknown')
echo "Successfully installed npm@${NPM_VERSION}, new version: $NEW_VERSION"
break
else
echo "Attempt $i failed, retrying..."
sleep 2
if [ $i -eq 3 ]; then
echo "Failed to install npm@${NPM_VERSION} after 3 attempts. Keeping current npm version $(npm --version 2>/dev/null || echo 'unknown')."
fi
fi
done
if [ -z "$NPM_VERSION" ] || [ "$NPM_VERSION" = "none" ]; then
echo "Skipping npm installation because NPM_VERSION is '${NPM_VERSION:-empty}'."
else
# Use special upgrade method for npm 10.x to latest (only if not falling back)
if [ "$ORIGINAL_NPM_VERSION" = "latest" ] && [ "$NPM_VERSION" = "latest" ] && [ "$CURRENT_MAJOR" = "10" ]; then
echo "Using npmjs.org install script for npm upgrade"
curl -fsSL https://www.npmjs.com/install.sh | sh 2>/dev/null || true
fi
# Try npm installation with retries
for i in {1..3}; do
echo "Attempt $i: Running npm install -g npm@$NPM_VERSION"
if npm install -g npm@$NPM_VERSION --force --no-audit --no-fund 2>&1; then
NEW_VERSION=$(npm --version 2>/dev/null || echo 'unknown')
echo "Successfully installed npm@${NPM_VERSION}, new version: $NEW_VERSION"
break
else
echo "Attempt $i failed, retrying..."
sleep 2
if [ $i -eq 3 ]; then
echo "Failed to install npm@${NPM_VERSION} after 3 attempts. Keeping current npm version $(npm --version 2>/dev/null || echo 'unknown')."
fi
fi
done
fi

Copilot uses AI. Check for mistakes.
)
else
echo "Skip installing/updating npm because npm is not available"
fi
fi

# Install pnpm
if [ ! -z "${PNPM_VERSION}" ] && [ "${PNPM_VERSION}" = "none" ]; then
echo "Ignoring installation of PNPM"
Expand Down
16 changes: 16 additions & 0 deletions test/node/install_npm_latest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/bash

set -e

# Optional: Import test library
source dev-container-features-test-lib

# When npmVersion="latest", npm should be upgraded from Node.js bundled version
# Node.js 22 comes with npm 10.x, so latest should be 11+
check "npm_version_upgraded" bash -c "npm -v | cut -d. -f1 | awk '\$1 >= 11 { exit 0 } { exit 1 }'"

# Also verify pnpm works as configured
check "pnpm_version" bash -c "pnpm -v | grep 8.8.0"

# Report result
reportResults
30 changes: 30 additions & 0 deletions test/node/install_npm_latest_incompatible.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/bash

set -e

# Optional: Import test library
source dev-container-features-test-lib

# Test: npm "latest" with Node.js 16.x (incompatible scenario)
# Should show compatibility warning and auto-fallback to compatible version (npm 9.x)

# Verify we have Node.js 16.x as expected
check "node_version_16" bash -c "node -v | grep '^v16\.'"

# Check npm is functional after installation attempt
check "npm_works" bash -c "npm --version"

# Verify npm version fell back to compatible version for Node 16.x (should be npm 8.x)
# check "npm_fallback_version" bash -c "
Comment on lines +8 to +18
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scenario is intended to validate the “Node 16 + npmVersion=latest” incompatibility fallback, but the actual version assertion is commented out, so the test only checks that npm --version runs. Either re-enable a reliable assertion for the expected fallback major (and fix the 9.x vs 8.x expectation mismatch in comments), or adjust the scenario/test to match the behavior being verified.

Copilot uses AI. Check for mistakes.
# NPM_MAJOR=\$(npm --version | cut -d. -f1)
# if [ \$NPM_MAJOR -eq 8 ]; then
# echo 'npm auto-fell back to version 8.x (compatible with Node 16.x)'
# exit 0
# else
# echo 'npm version \$NPM_MAJOR.x - fallback may not have worked correctly'
# exit 1
# fi
# "

# Report result
reportResults
12 changes: 12 additions & 0 deletions test/node/install_npm_none.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash

set -e

# Optional: Import test library
source dev-container-features-test-lib

# When npmVersion is "none", npm should not be updated from node's bundled version
check "npm_not_updated" bash -c "npm --version"

Comment on lines +9 to +10
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

npmVersion: "none" is described as “do not update npm from Node’s bundled version”, but the test currently only checks that npm --version runs (which will pass even if npm was upgraded). Add an assertion that distinguishes bundled vs upgraded npm (e.g., compare against an expected major for the selected Node version, or verify no global npm reinstall occurred) so this scenario actually validates the skip logic.

Suggested change
check "npm_not_updated" bash -c "npm --version"
check "npm_not_updated" bash -c '
npm --version >/dev/null
NODE_MAJOR=$(node -p "process.versions.node.split(\".\")[0]")
NPM_MAJOR=$(npm --version | cut -d. -f1)
case "$NODE_MAJOR" in
16) EXPECTED_NPM_MAJOR=8 ;;
18|20|22) EXPECTED_NPM_MAJOR=10 ;;
*)
echo "Unsupported Node major for bundled npm assertion: $NODE_MAJOR"
exit 1
;;
esac
[ "$NPM_MAJOR" = "$EXPECTED_NPM_MAJOR" ]
'

Copilot uses AI. Check for mistakes.
# Report result
reportResults
12 changes: 12 additions & 0 deletions test/node/install_specific_npm_version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash

set -e

# Optional: Import test library
source dev-container-features-test-lib

# Verify npm is installed with specific version 10.8.0
check "npm_specific_version" bash -c "npm -v | grep '^10.8.0'"

# Report result
reportResults
47 changes: 42 additions & 5 deletions test/node/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@
"version": "lts"
}
}
},
},
"install_node_debian_bookworm": {
"image": "debian:12",
"features": {
"node": {
"version": "lts"
}
}
},
},
"nvm_test_fallback": {
"image": "debian:11",
"features": {
"node": {
"version": "lts"
}
}
},
},
"install_additional_node": {
"image": "debian:11",
"features": {
Expand Down Expand Up @@ -98,7 +98,7 @@
"features": {
"node": {
"version": "22",
"pnpmVersion":"8.8.0"
"pnpmVersion": "8.8.0"
}
}
},
Expand Down Expand Up @@ -207,5 +207,42 @@
"version": "lts"
}
}
},
"install_specific_npm_version": {
"image": "debian:12",
"features": {
"node": {
"version": "lts",
"npmVersion": "10.8.0"
}
}
},
"install_npm_none": {
"image": "mcr.microsoft.com/devcontainers/base",
"features": {
"node": {
"version": "lts",
"npmVersion": "none"
}
}
},
"install_npm_latest": {
"image": "debian:12",
"features": {
"node": {
"version": "22",
"npmVersion": "latest",
"pnpmVersion": "8.8.0"
}
}
},
"install_npm_latest_incompatible": {
"image": "debian:12",
"features": {
"node": {
"version": "16",
"npmVersion": "latest"
}
}
}
}
}
Loading