diff --git a/README.md b/README.md index b98d538f6e..98cd67c974 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - [io.js](#iojs) - [System Version of Node](#system-version-of-node) - [Listing Versions](#listing-versions) + - [Pruning old versions](#pruning-old-versions) - [Setting Custom Colors](#setting-custom-colors) - [Persisting custom colors](#persisting-custom-colors) - [Suppressing colorized output](#suppressing-colorized-output) @@ -553,6 +554,21 @@ If you want to see what versions are available to install: nvm ls-remote ``` +### Pruning old versions + +To clean up old, unused Node.js versions, use `nvm prune`: + +```sh +nvm prune +``` + +This will uninstall all installed versions except for the latest one within each major version group (e.g., if you have `v16.1.0` and `v16.2.0`, it will keep `v16.2.0`). + +- **Safety**: The currently active version is **never** removed, even if it is not the latest in its group. +- **Global Modules**: `nvm prune` will warn you if it's about to uninstall a version that contains global npm modules (other than `npm` itself). +- **Preview**: Use `nvm prune --dry-run` to see which versions would be removed without actually uninstalling them. +- **Minor Version Retention**: Use `nvm prune --minor` to keep the latest version of each **minor** release group instead of major. + ### Setting Custom Colors You can set five colors that will be used to display version and alias information. These colors replace the default colors. diff --git a/nvm.sh b/nvm.sh index b2f45ea789..81197a4cf3 100755 --- a/nvm.sh +++ b/nvm.sh @@ -1645,6 +1645,176 @@ nvm_ls_remote() { NVM_LTS="${NVM_LTS-}" nvm_ls_remote_index_tab node std "${PATTERN}" } +nvm_prune() { + local NVM_DRY_RUN + NVM_DRY_RUN=0 + local KEEP_MINOR + KEEP_MINOR=0 + local ARG + for ARG in "$@"; do + case "${ARG}" in + --dry-run) + NVM_DRY_RUN=1 + ;; + --minor) + KEEP_MINOR=1 + ;; + *) + nvm_err "Unknown argument: ${ARG}" + return 127 + ;; + esac + done + + if [ "${NVM_DEBUG-}" = 1 ]; then + nvm_echo "Pruning old installed versions..." + if [ "${NVM_DRY_RUN}" -eq 1 ]; then + nvm_echo "Dry run mode: no versions will be removed." + fi + fi + + local HAS_SORT_V + HAS_SORT_V=0 + if command sort -V /dev/null 2>&1; then + HAS_SORT_V=1 + fi + + local AWK_CMD + AWK_CMD='{ + for(i=1;i<=NF;i++) { + if ($i ~ /^(iojs-)?v[0-9]+\.[0-9]+\.[0-9]+$/) { + sub(/^iojs-/, "", $i) + print $i + } + } + }' + + local VERSIONS + if [ "${HAS_SORT_V}" -eq 1 ]; then + VERSIONS="$(nvm_ls | command awk "${AWK_CMD}" | command sort -V)" + else + # Fallback to numeric sort on fields roughly for POSIX compliance + # v1.2.3 -> field 1 (1.2n ignores 'v'), field 2, field 3 + VERSIONS="$(nvm_ls | command awk "${AWK_CMD}" | command sort -t. -k 1.2,1n -k 2,2n -k 3,3n)" + fi + + if [ -z "${VERSIONS}" ]; then + nvm_echo "No versions installed to prune." + return 0 + fi + + # Ensure the current version is stripped of iojs prefix to match our clean list + local CURRENT_VERSION + CURRENT_VERSION="$(nvm_strip_iojs_prefix "$(nvm_ls_current)")" + if [ "${CURRENT_VERSION}" = "system" ] || [ "${CURRENT_VERSION}" = "none" ]; then + CURRENT_VERSION="" + fi + + local PREV_MAJOR + PREV_MAJOR="" + local GROUP_VERSIONS + GROUP_VERSIONS="" + + # Append a dummy line to flush the last group + VERSIONS="${VERSIONS} +END" + + nvm_is_zsh && setopt local_options shwordsplit + local IFS + IFS=' +' + # shellcheck disable=SC2013 + for VERSION in ${VERSIONS}; do + unset IFS + local MAJOR + MAJOR="" + if [ "${VERSION}" != "END" ] && [ -n "${VERSION}" ]; then + if [ "${KEEP_MINOR}" -eq 1 ]; then + MAJOR="$(nvm_echo "${VERSION}" | command cut -d. -f1,2)" + else + MAJOR="$(nvm_echo "${VERSION}" | command cut -d. -f1)" + fi + fi + + if [ "${MAJOR}" != "${PREV_MAJOR}" ] && [ -n "${PREV_MAJOR}" ]; then + # Process previous group + local LATEST_IN_GROUP + # Get the last word in GROUP_VERSIONS + LATEST_IN_GROUP="" + for V in ${GROUP_VERSIONS}; do + LATEST_IN_GROUP="${V}" + done + + for V in ${GROUP_VERSIONS}; do + if [ "${V}" = "${LATEST_IN_GROUP}" ]; then + # It's the latest, keep it + # nvm_echo "Keeping latest: ${V}" + continue + fi + if [ "${V}" = "${CURRENT_VERSION}" ]; then + if [ "${NVM_DEBUG-}" = 1 ]; then + nvm_echo "Keeping current version: ${V}" + fi + continue + fi + + # Re-resolve if the version is actually iojs + local UNINSTALL_V + UNINSTALL_V="${V}" + if ! nvm_is_version_installed "${UNINSTALL_V}" >/dev/null 2>&1; then + local IOJS_V + IOJS_V="$(nvm_add_iojs_prefix "${V}")" + if nvm_is_version_installed "${IOJS_V}" >/dev/null 2>&1; then + UNINSTALL_V="${IOJS_V}" + fi + fi + + if ! nvm_is_version_installed "${UNINSTALL_V}" >/dev/null 2>&1; then + continue + fi + + # Check for global npm modules explicitly + local NODE_MODULES_DIR + NODE_MODULES_DIR="$(nvm_version_path "${UNINSTALL_V}")/lib/node_modules" + local HAS_GLOBALS + HAS_GLOBALS=0 + if [ -d "${NODE_MODULES_DIR}" ]; then + local NUM_GLOBALS + NUM_GLOBALS="$(command ls -1 "${NODE_MODULES_DIR}" 2>/dev/null | nvm_grep -v '^npm$' | command wc -l | command awk '{print $1}')" + if [ "${NUM_GLOBALS}" -gt 0 ]; then + HAS_GLOBALS="${NUM_GLOBALS}" + fi + fi + + local MSG_SUFFIX + MSG_SUFFIX="" + if [ "${HAS_GLOBALS}" -gt 0 ]; then + MSG_SUFFIX=" (Warning: replacing ${HAS_GLOBALS} global module(s))" + fi + + # Prune V + if [ "${NVM_DRY_RUN}" -eq 1 ]; then + nvm_echo "[Dry Run] Uninstalling ${UNINSTALL_V}...${MSG_SUFFIX}" + else + nvm_echo "Uninstalling ${UNINSTALL_V}...${MSG_SUFFIX}" + nvm uninstall "${UNINSTALL_V}" >/dev/null + fi + done + + GROUP_VERSIONS="" + fi + + if [ "${VERSION}" = "END" ]; then + break + fi + + if [ -n "${VERSION}" ]; then + PREV_MAJOR="${MAJOR}" + GROUP_VERSIONS="${GROUP_VERSIONS} ${VERSION}" + fi + done +} + nvm_ls_remote_iojs() { NVM_LTS="${NVM_LTS-}" nvm_ls_remote_index_tab iojs std "${1-}" } @@ -3217,6 +3387,9 @@ nvm() { nvm_echo ' nvm uninstall Uninstall a version' nvm_echo ' nvm uninstall --lts Uninstall using automatic LTS (long-term support) alias `lts/*`, if available.' nvm_echo ' nvm uninstall --lts= Uninstall using automatic alias for provided LTS line, if available.' + nvm_echo ' nvm prune Uninstall all versions except the latest one for each major version.' + nvm_echo ' --dry-run Preview deletions without executing them.' + nvm_echo ' --minor Uninstall all versions except the latest one for each minor version.' nvm_echo ' nvm use [] Modify PATH to use . Uses .nvmrc if available and version is omitted.' nvm_echo ' The following optional arguments, if provided, must appear directly after `nvm use`:' nvm_echo ' --silent Silences stdout/stderr output' @@ -3914,6 +4087,9 @@ nvm() { nvm unalias "$(command basename "${ALIAS}")" done ;; + "prune") + nvm_prune "$@" + ;; "deactivate") local NVM_SILENT while [ $# -ne 0 ]; do @@ -4648,6 +4824,7 @@ nvm() { nvm_get_artifact_compression nvm_install_binary_extract nvm_extract_tarball \ nvm_process_nvmrc nvm_nvmrc_invalid_msg \ nvm_write_nvmrc \ + nvm_prune \ >/dev/null 2>&1 unset NVM_NODEJS_ORG_MIRROR NVM_IOJS_ORG_MIRROR NVM_DIR \ NVM_CD_FLAGS NVM_BIN NVM_INC NVM_MAKE_JOBS \ diff --git a/test/fast/Running 'nvm prune' should remove old versions b/test/fast/Running 'nvm prune' should remove old versions new file mode 100755 index 0000000000..8542f04910 --- /dev/null +++ b/test/fast/Running 'nvm prune' should remove old versions @@ -0,0 +1,97 @@ +#!/bin/sh + +# Test: Running 'nvm prune' should remove old versions + +die () { echo "$@" ; exit 1; } + +# Mocking nvm_ls output logic relies on nvm_ls calling directory listing. +# Instead of mocking filesystem which is complex, we will override nvm_ls and nvm_ls_current. + +. ../../nvm.sh + +# Mock nvm_ls to return specific versions as if they were installed +# The implementation of nvm_prune calls: nvm_ls --no-colors --no-alias +# We must mock nvm_ls to respond to that or just return the list. +# Note: nvm_prune strips output except vX.Y.Z. +nvm_ls() { + echo " v16.20.2" + echo " v18.20.5" + echo " v18.20.7" + echo " v20.17.0" + echo "-> v22.22.0" + echo " v24.13.0" +} + +# Mock nvm_ls_current +nvm_ls_current() { + echo "v22.22.0" +} + +# Mock nvm_is_version_installed +nvm_is_version_installed() { + return 0 +} + +# Mock nvm function to intercept uninstall calls +# nvm_prune calls `nvm uninstall`. +UNINSTALLED_VERSIONS="" +nvm() { + if [ "$1" = "uninstall" ]; then + UNINSTALLED_VERSIONS="${UNINSTALLED_VERSIONS} $2" + return 0 + fi + echo "nvm called with unexpected args: $*" +} + +# Run prune +nvm_prune + +# Expected: +# v16.20.2 (Keep: Only one v16) +# v18.20.5 (Delete: v18.20.7 is newer) -> WAIT. v18.20.7 is newer. So keep 18.20.7. Delete 18.20.5. +# v18.20.7 (Keep: Latest v18) +# v20.17.0 (Keep: Only one v20) +# v22.22.0 (Keep: Current) +# v24.13.0 (Keep: Only one v24) + +# Wait, my logic for nvm_prune was to keep latest per major. +# v18.20.5 and v18.20.7 are Major 18. +# Latest is v18.20.7. +# So v18.20.5 should be pruned. + +# Check UNINSTALLED_VERSIONS +EXPECTED=" v18.20.5" +if [ "${UNINSTALLED_VERSIONS}" != "${EXPECTED}" ]; then + die "Expected uninstalled: '${EXPECTED}', got: '${UNINSTALLED_VERSIONS}'" +fi + +# New Test Case: Current version is NOT the latest. +# v18.1.0 (Current) +# v18.2.0 (Latest) +# v18.0.0 (Old) + +nvm_ls() { + echo " v18.0.0" + echo "-> v18.1.0" + echo " v18.2.0" +} + +nvm_ls_current() { + echo "v18.1.0" +} + +UNINSTALLED_VERSIONS="" +nvm_prune + +# Expected: +# v18.0.0 (Prune) +# v18.1.0 (Keep: Current) +# v18.2.0 (Keep: Latest) + +EXPECTED=" v18.0.0" + +if [ "${UNINSTALLED_VERSIONS}" != "${EXPECTED}" ]; then + die "TestCase 2 Failed. Expected uninstalled: '${EXPECTED}', got: '${UNINSTALLED_VERSIONS}'" +fi + +echo "All tests passed"