diff --git a/catalog/black.json b/catalog/black.json index 6e37ec8..b972c82 100644 --- a/catalog/black.json +++ b/catalog/black.json @@ -4,5 +4,6 @@ "install_method": "uv_tool", "description": "The uncompromising Python code formatter", "homepage": "https://github.com/psf/black", - "package_name": "black" + "package_name": "black", + "version_command": "black --version 2>/dev/null | head -1" } diff --git a/catalog/flake8.json b/catalog/flake8.json index 12216ed..39edcd4 100644 --- a/catalog/flake8.json +++ b/catalog/flake8.json @@ -4,5 +4,6 @@ "install_method": "uv_tool", "description": "Python style guide enforcement tool combining PyFlakes, pycodestyle, and McCabe complexity checker", "homepage": "https://github.com/PyCQA/flake8", - "package_name": "flake8" + "package_name": "flake8", + "version_command": "flake8 --version 2>/dev/null | head -1" } diff --git a/catalog/gam.json b/catalog/gam.json index c9221bf..a9553fc 100644 --- a/catalog/gam.json +++ b/catalog/gam.json @@ -7,6 +7,8 @@ "github_repo": "GAM-team/GAM", "binary_name": "gam", "version_flag": "version", + "version_command": "gam version 2>/dev/null | head -1 | grep -oE 'GAM [0-9]+\\.[0-9]+\\.[0-9]+' | awk '{print $2}'", + "version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)", "package_name": "gam7", "auto_update": true } diff --git a/catalog/gcloud.json b/catalog/gcloud.json index a4679f2..5c6aae1 100644 --- a/catalog/gcloud.json +++ b/catalog/gcloud.json @@ -6,5 +6,6 @@ "homepage": "https://cloud.google.com/sdk/gcloud", "binary_name": "gcloud", "skip_upstream": true, + "version_command": "gcloud version 2>/dev/null | head -1 | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+'", "notes": "Installed via Google Cloud SDK installer; self-updates with 'gcloud components update'" } diff --git a/catalog/isort.json b/catalog/isort.json index 7cc99fe..a813edb 100644 --- a/catalog/isort.json +++ b/catalog/isort.json @@ -4,5 +4,6 @@ "install_method": "uv_tool", "description": "A Python utility to sort imports alphabetically and automatically separate them into sections", "homepage": "https://github.com/PyCQA/isort", - "package_name": "isort" + "package_name": "isort", + "version_command": "isort --version-number 2>/dev/null" } diff --git a/catalog/pip.json b/catalog/pip.json index 2c244d8..6976a35 100644 --- a/catalog/pip.json +++ b/catalog/pip.json @@ -1,16 +1,11 @@ { "name": "pip", "category": "python", - "install_method": "package_manager", + "install_method": "dedicated_script", "description": "Python package installer", "homepage": "https://pip.pypa.io/", "package_name": "pip", "binary_name": "pip", - "packages": { - "apt": "python3-pip", - "brew": "python3", - "dnf": "python3-pip", - "pacman": "python-pip" - }, - "notes": "pip typically comes with Python 3. Use python3 -m pip if pip command is not available." + "script": "install_pip.sh", + "notes": "pip is bundled with Python 3. Use python3 -m pip if pip command is not available." } diff --git a/scripts/guide.sh b/scripts/guide.sh index c60f03c..a647dbb 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -1,16 +1,42 @@ #!/usr/bin/env bash set -euo pipefail trap '' PIPE +# Graceful interrupt handling +INTERRUPTED=0 +trap 'INTERRUPTED=1; echo; echo "⚠️ Interrupted. Partial summary:"; print_summary; exit 130' INT DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$DIR/.." && pwd)" VERBOSE="${VERBOSE:-0}" +# Suppress Homebrew auto-update during upgrade runs to reduce noise +export HOMEBREW_NO_AUTO_UPDATE=1 OFFLINE="${OFFLINE:-0}" CLI="${PYTHON:-python3}" # Ignore pins: IGNORE_PINS=1 to show all tools regardless of pin status IGNORE_PINS="${IGNORE_PINS:-0}" +# Summary counters +SUMMARY_UPDATED=0 +SUMMARY_INSTALLED=0 +SUMMARY_SKIPPED=0 +SUMMARY_FAILED=0 +SUMMARY_REMOVED=0 + +print_summary() { + local label="${1:-interrupted}" + echo "================================================================================" + echo "Summary${label:+ ($label)}" + echo "================================================================================" + printf " Installed: %d\n" "$SUMMARY_INSTALLED" + printf " Updated: %d\n" "$SUMMARY_UPDATED" + printf " Removed: %d\n" "$SUMMARY_REMOVED" + printf " Skipped: %d\n" "$SUMMARY_SKIPPED" + printf " Failed: %d\n" "$SUMMARY_FAILED" + echo + echo "Re-run: make audit" +} + # Category filter: CATEGORY=python,go or --category=python CATEGORY_FILTER="${CATEGORY:-}" for arg in "$@"; do @@ -139,6 +165,17 @@ osc8() { [ -n "$url" ] && printf '\e]8;;%s\e\\%s\e]8;;\e\\' "$url" "$text" || printf '%s' "$text" } +# Print installed status line (reusable for auto-update and interactive prompts) +print_installed_status() { + local installed="$1" + local method="$2" + if [ -z "$installed" ]; then + printf " installed: not installed\n" + else + printf " installed: %s via %s\n" "$installed" "${method:-unknown}" + fi +} + # Check for multiple installations and print warning if found # Args: catalog_tool_name # Returns: 0 always (informational only) @@ -241,6 +278,7 @@ process_tool() { printf " installed: %s via %s\n" "$installed" "$method" printf " target: %s (same)\n" "$(osc8 "$url" "$latest")" check_multi_installs "$catalog_tool" + SUMMARY_SKIPPED=$((SUMMARY_SKIPPED + 1)) printf " up-to-date; skipping.\n" return 0 fi @@ -264,8 +302,14 @@ process_tool() { # BUT: multi-version tools always prompt (more significant operation) if [ "$auto_update" = "true" ] && [ -z "$is_multi_version" ]; then printf "\n==> %s %s [auto-update]\n" "$icon" "$display" - printf " installed: %s via %s\n" "${installed:-}" "${method:-unknown}" - printf " target: %s\n" "$(osc8 "$url" "${latest:-}")" + print_installed_status "$installed" "$method" + # Show target; for self-managed tools (skip_upstream) show "self-managed" instead of + local target_display="${latest:-}" + local skip_upstream="$(catalog_get_property "$catalog_tool" skip_upstream)" + if [ "$target_display" = "" ] && [ "$skip_upstream" = "true" ]; then + target_display="self-managed" + fi + printf " target: %s\n" "$(osc8 "$url" "$target_display")" check_multi_installs "$catalog_tool" printf " auto-updating...\n" @@ -278,18 +322,19 @@ process_tool() { fi # Execute the install with version-specific environment variables + local auto_update_success=0 if [ "$catalog_tool" = "python" ] || [ -n "$is_multi_version" ] && [ "$catalog_tool" = "python" ]; then - UV_PYTHON_SPEC="$latest" "$ROOT"/scripts/$install_cmd || true + UV_PYTHON_SPEC="$latest" "$ROOT"/scripts/$install_cmd && auto_update_success=1 || true elif [ "$catalog_tool" = "ruby" ]; then - RUBY_VERSION="$latest" "$ROOT"/scripts/$install_cmd || true + RUBY_VERSION="$latest" "$ROOT"/scripts/$install_cmd && auto_update_success=1 || true elif [ "$catalog_tool" = "php" ] && [ -n "$version_cycle" ]; then - PHP_VERSION="$version_cycle" "$ROOT"/scripts/$install_cmd || true + PHP_VERSION="$version_cycle" "$ROOT"/scripts/$install_cmd && auto_update_success=1 || true elif [ "$catalog_tool" = "node" ] && [ -n "$version_cycle" ]; then - NODE_VERSION="$version_cycle" "$ROOT"/scripts/$install_cmd || true + NODE_VERSION="$version_cycle" "$ROOT"/scripts/$install_cmd && auto_update_success=1 || true elif [ "$catalog_tool" = "go" ] && [ -n "$version_cycle" ]; then - GO_VERSION="$version_cycle" "$ROOT"/scripts/$install_cmd || true + GO_VERSION="$version_cycle" "$ROOT"/scripts/$install_cmd && auto_update_success=1 || true else - "$ROOT"/scripts/$install_cmd || true + "$ROOT"/scripts/$install_cmd && auto_update_success=1 || true fi # Re-audit with fresh collection for this specific tool @@ -297,6 +342,13 @@ process_tool() { reload_audit_json # Clean up any already-current marker left by installer rm -f "/tmp/.cli-audit/${catalog_tool}.already-current" + if [ "$auto_update_success" = "0" ]; then + SUMMARY_FAILED=$((SUMMARY_FAILED + 1)) + elif [ -z "$installed" ]; then + SUMMARY_INSTALLED=$((SUMMARY_INSTALLED + 1)) + else + SUMMARY_UPDATED=$((SUMMARY_UPDATED + 1)) + fi return 0 fi @@ -304,11 +356,17 @@ process_tool() { printf "\n==> %s %s\n" "$icon" "$display" [ -n "$description" ] && printf " %s\n" "$description" [ -n "$homepage" ] && printf " Homepage: %s\n" "$(osc8 "$homepage" "$homepage")" - printf " installed: %s via %s\n" "${installed:-}" "${method:-unknown}" + print_installed_status "$installed" "$method" check_multi_installs "$catalog_tool" - printf " target: %s\n" "$(osc8 "$url" "${latest:-}")" + # Show target; for self-managed tools (skip_upstream) show "self-managed" instead of + local target_display_p="${latest:-}" + local skip_upstream_p="$(catalog_get_property "$catalog_tool" skip_upstream)" + if [ "$target_display_p" = "" ] && [ "$skip_upstream_p" = "true" ]; then + target_display_p="self-managed" + fi + printf " target: %s\n" "$(osc8 "$url" "$target_display_p")" # Build install command from catalog metadata (use catalog_tool for script name) local install_cmd="install_tool.sh $catalog_tool" @@ -335,7 +393,7 @@ process_tool() { fi printf " r = Remove/uninstall this tool\n" if [ -n "$is_multi_version" ]; then - printf " P = Skip ALL %s cycles (never install any %s)\n" "$catalog_tool" "$catalog_tool" + printf " P = Skip ALL outdated %s cycles\n" "$catalog_tool" fi else printf " y = Install now\n" @@ -344,7 +402,7 @@ process_tool() { printf " s = Skip only %s (ask again when newer patch available)\n" "$latest" if [ -n "$is_multi_version" ]; then printf " p = Never install %s (skip entire %s.x cycle)\n" "$display" "$version_cycle" - printf " P = Skip ALL %s cycles (never install any %s)\n" "$catalog_tool" "$catalog_tool" + printf " P = Skip ALL outdated %s cycles\n" "$catalog_tool" else printf " p = Never install (permanently skip this tool)\n" fi @@ -419,6 +477,7 @@ process_tool() { if [ "$upgrade_success" = "0" ]; then # Install script failed printf "\n ⚠️ Upgrade failed (install script error)\n" + SUMMARY_FAILED=$((SUMMARY_FAILED + 1)) prompt_pin_version "$tool" "$installed" elif [ -n "$binary_already_current" ]; then # Binary hash matches target release - upgrade succeeded despite version string @@ -435,6 +494,7 @@ process_tool() { fi else # Upgrade succeeded - remove any existing pin to avoid stale pins + SUMMARY_UPDATED=$((SUMMARY_UPDATED + 1)) local existing_pin="$(pins_get "$tool")" if [ -n "$existing_pin" ] && [ "$existing_pin" != "never" ]; then "$ROOT"/scripts/unpin_version.sh "$tool" || true @@ -478,18 +538,23 @@ process_tool() { if [ "$upgrade_success_a" = "0" ]; then printf "\n ⚠️ Upgrade failed (install script error)\n" printf " Auto-update is still enabled - will try again next time.\n" + SUMMARY_FAILED=$((SUMMARY_FAILED + 1)) elif [ -n "$binary_already_current_a" ]; then printf " ✓ Auto-update enabled. Binary already matches target release.\n" + SUMMARY_UPDATED=$((SUMMARY_UPDATED + 1)) elif [ "$new_installed_a" = "$installed" ] && [ "$new_installed_a" != "$latest" ]; then # Version didn't change - but check for prefix match (e.g., 3.13 vs 3.13.11) if [[ "$latest" == "$new_installed_a"* ]] || [[ "$new_installed_a" == "$latest"* ]]; then printf " ✓ Auto-update enabled. This tool will update automatically in future.\n" + SUMMARY_UPDATED=$((SUMMARY_UPDATED + 1)) else printf "\n ⚠️ Upgrade did not succeed (version unchanged)\n" printf " Auto-update is still enabled - will try again next time.\n" + SUMMARY_FAILED=$((SUMMARY_FAILED + 1)) fi else printf " ✓ Auto-update enabled. This tool will update automatically in future.\n" + SUMMARY_UPDATED=$((SUMMARY_UPDATED + 1)) # Remove any existing pin local existing_pin_a="$(pins_get "$tool")" if [ -n "$existing_pin_a" ]; then @@ -501,6 +566,7 @@ process_tool() { # Skip this specific patch version only printf " Skipping only %s (will prompt again when newer patch available)\n" "$latest" "$ROOT"/scripts/pin_version.sh "$tool" "$latest" || true + SUMMARY_SKIPPED=$((SUMMARY_SKIPPED + 1)) ;; [p]) if [ -n "$installed" ]; then @@ -550,8 +616,18 @@ process_tool() { local still_installed="$(json_field "$tool" installed)" if [ -z "$still_installed" ]; then printf " ✓ %s has been removed\n" "$tool" + SUMMARY_REMOVED=$((SUMMARY_REMOVED + 1)) else - printf " ⚠️ %s may not have been fully removed (still detected: %s)\n" "$tool" "$still_installed" + # Check if remaining installation is a system/apt binary that we can't remove + local remaining_method="$(json_field "$tool" installed_method)" + if [ "$remaining_method" = "apt" ] || [ "$remaining_method" = "system" ]; then + printf " ✓ User-managed %s removed (system %s still present at %s — managed by OS)\n" \ + "$tool" "$still_installed" "$remaining_method" + SUMMARY_REMOVED=$((SUMMARY_REMOVED + 1)) + else + printf " ⚠️ %s may not have been fully removed (still detected: %s via %s)\n" "$tool" "$still_installed" "${remaining_method:-unknown}" + SUMMARY_FAILED=$((SUMMARY_FAILED + 1)) + fi fi else printf " Tool is not installed, nothing to remove\n" @@ -570,6 +646,7 @@ process_tool() { ;; *) # User declined (N or empty) + SUMMARY_SKIPPED=$((SUMMARY_SKIPPED + 1)) ;; esac } @@ -863,7 +940,7 @@ for category in $(printf '%s\n' "${!CATEGORY_TOOLS[@]}" | while read c; do echo # Category-level prompt (skip if auto-yes mode) if [ "${AUTO_YES_ALL:-}" != "1" ]; then printf " Tools: %s\n" "$(echo $tools | tr ' ' ', ' | sed 's/^, //')" - printf " Process this category? [Y/n/a=all/s=skip-all] " + printf " Process this category? [Y/n/a=all categories/s=skip-all] " cat_ans="" if [ -t 0 ]; then @@ -935,5 +1012,6 @@ if [ -n "$DEPRECATED_TOOLS" ]; then fi fi +# Print final summary echo -echo "All done. Re-run: make audit" +print_summary "" diff --git a/scripts/install_node.sh b/scripts/install_node.sh index e58db20..97625cb 100755 --- a/scripts/install_node.sh +++ b/scripts/install_node.sh @@ -38,6 +38,8 @@ get_specific_node_version() { install_node() { ensure_nvm nvm install "$NODE_CHANNEL" + # Re-source nvm to ensure the new version is active in this shell + ensure_nvm_loaded # Only set default if this is NOT a multi-version install # (multi-version = specific major version like 24, 25) @@ -66,6 +68,8 @@ install_node() { update_node() { ensure_nvm nvm install "$NODE_CHANNEL" + # Re-source nvm to ensure the new version is active in this shell + ensure_nvm_loaded # Only set default and update global packages if NOT a multi-version install if [ -z "${NODE_VERSION:-}" ]; then diff --git a/scripts/install_pip.sh b/scripts/install_pip.sh new file mode 100755 index 0000000..947c020 --- /dev/null +++ b/scripts/install_pip.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# pip installer - ensures pip is available via Python's ensurepip +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$DIR/lib/common.sh" +. "$DIR/lib/install_strategy.sh" + +ACTION="${1:-install}" + +get_pip_version() { + if command -v pip3 >/dev/null 2>&1; then + pip3 --version 2>/dev/null | awk '{print $2}' || true + elif command -v pip >/dev/null 2>&1; then + pip --version 2>/dev/null | awk '{print $2}' || true + elif command -v python3 >/dev/null 2>&1; then + python3 -m pip --version 2>/dev/null | awk '{print $2}' || true + fi +} + +install_pip() { + local before after path + + before="$(get_pip_version)" + + # Ensure Python is available + if ! command -v python3 >/dev/null 2>&1; then + echo "[pip] Error: python3 not found. Install Python first." >&2 + exit 1 + fi + + # Use ensurepip to bootstrap pip if not present + if ! python3 -m pip --version >/dev/null 2>&1; then + echo "[pip] Bootstrapping pip via ensurepip..." >&2 + python3 -m ensurepip --upgrade 2>&1 || { + echo "[pip] ensurepip failed, trying apt..." >&2 + apt_install_if_missing python3-pip || true + } + fi + + # Upgrade pip to latest + # Use uv if available (avoids externally-managed-environment errors) + if command -v uv >/dev/null 2>&1; then + # uv manages Python installations; pip upgrade is best left to uv's Python + echo "[pip] pip is managed by the Python distribution (uv/brew). Skipping standalone upgrade." >&2 + else + # Only attempt pip upgrade if not in an externally-managed environment + python3 -m pip install --upgrade pip 2>&1 | grep -v "externally-managed-environment" || true + fi + + after="$(get_pip_version)" + path="$(command -v pip3 2>/dev/null || command -v pip 2>/dev/null || true)" + + printf "[%s] before: %s\n" "pip" "${before:-}" + printf "[%s] after: %s\n" "pip" "${after:-}" + if [ -n "$path" ]; then printf "[%s] path: %s\n" "pip" "$path"; fi + + refresh_snapshot "pip" +} + +uninstall_pip() { + echo "[pip] pip is bundled with Python and cannot be uninstalled separately" >&2 +} + +case "$ACTION" in + install|update|reconcile) install_pip ;; + uninstall) uninstall_pip ;; + *) echo "Usage: $0 {install|update|uninstall|reconcile}" ; exit 2 ;; +esac diff --git a/scripts/install_python.sh b/scripts/install_python.sh index ded4d5d..f1e336c 100755 --- a/scripts/install_python.sh +++ b/scripts/install_python.sh @@ -210,11 +210,54 @@ update_py_stack() { } uninstall_py_tools() { - local tools=(black isort flake8 bandit httpie pre-commit poetry semgrep) - if command -v uv >/dev/null 2>&1; then - for p in "${tools[@]}"; do uv tool uninstall "$p" >/dev/null 2>&1 || true; done + local PY_SPEC="${UV_PYTHON_SPEC:-}" + + if [ -n "$PY_SPEC" ]; then + # Version-specific removal (e.g., UV_PYTHON_SPEC=3.10) + local short_ver="${PY_SPEC}" + # Extract major.minor from full version like 3.10.19 + case "$PY_SPEC" in + *.*.*) short_ver="${PY_SPEC%.*}" ;; + esac + + echo "[python] Removing Python $short_ver..." >&2 + + # Pre-cache sudo credentials to avoid repeated password prompts + if have sudo && have apt-get; then + sudo -v 2>/dev/null || true + fi + + # Remove via uv if available + if command -v uv >/dev/null 2>&1; then + uv python uninstall "$short_ver" 2>/dev/null || true + fi + + # Remove version-specific apt packages (e.g., python3.10) + # BUT skip if this version is the system default python3 (dependency of python3 metapackage) + if have apt-get; then + local system_py_ver="" + system_py_ver="$(/usr/bin/python3 --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1 || true)" + if [ "$short_ver" = "$system_py_ver" ]; then + echo "[python] Skipping apt removal: python${short_ver} is the system Python (dependency of python3)" >&2 + else + apt_remove_if_present "python${short_ver}" "python${short_ver}-venv" "python${short_ver}-dev" 2>/dev/null || true + fi + fi + + # Remove version-specific brew formula if it exists + if have brew; then + brew uninstall "python@${short_ver}" 2>/dev/null || true + fi + + echo "[python] Python $short_ver removal complete" >&2 else - for p in "${tools[@]}"; do pipx uninstall "$p" >/dev/null 2>&1 || true; done + # Full uninstall: remove all CLI tools + local tools=(black isort flake8 bandit httpie pre-commit poetry semgrep) + if command -v uv >/dev/null 2>&1; then + for p in "${tools[@]}"; do uv tool uninstall "$p" >/dev/null 2>&1 || true; done + else + for p in "${tools[@]}"; do pipx uninstall "$p" >/dev/null 2>&1 || true; done + fi fi } diff --git a/scripts/install_tool.sh b/scripts/install_tool.sh index f24a390..271e6ef 100755 --- a/scripts/install_tool.sh +++ b/scripts/install_tool.sh @@ -17,6 +17,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "[$TOOL] Error: Invalid tool name" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" # Check if tool has catalog entry @@ -48,6 +54,10 @@ if [ "$ACTION" = "uninstall" ]; then if [ -n "$script_name" ] && [ -f "$DIR/$script_name" ]; then "$DIR/$script_name" uninstall || true fi + # Dedicated scripts handle their own cleanup completely + # Don't try to detect and remove additional installations + # (especially important for multi-version tools like node, python, go) + exit 0 fi binary_name="$(jq -r '.binary_name // ""' "$CATALOG_FILE" 2>/dev/null || echo "$TOOL")" diff --git a/scripts/install_uv.sh b/scripts/install_uv.sh index a5e5d37..6544005 100755 --- a/scripts/install_uv.sh +++ b/scripts/install_uv.sh @@ -62,7 +62,8 @@ self_update_uv() { upgrade_uv_tools() { command -v uv >/dev/null 2>&1 || return 0 echo "Checking uv-managed tools..." - uv tool upgrade --all || true + # Only show tool name and version changes, not dependency details + uv tool upgrade --all 2>&1 | grep -E '^(Updated |Modified |Installed |No updates)' || true } reconcile_uv() { @@ -75,8 +76,6 @@ reconcile_uv() { fi # Try self-update to latest stable self_update_uv || true - # Upgrade all uv-managed tools - upgrade_uv_tools || true # Verify final state echo "uv path: $(command -v uv 2>/dev/null || echo '')" uv --version 2>/dev/null || true diff --git a/scripts/installers/dedicated_script.sh b/scripts/installers/dedicated_script.sh index b74b999..745f8e1 100755 --- a/scripts/installers/dedicated_script.sh +++ b/scripts/installers/dedicated_script.sh @@ -11,6 +11,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 diff --git a/scripts/installers/docker_plugin.sh b/scripts/installers/docker_plugin.sh index 611e91a..ff8a57f 100755 --- a/scripts/installers/docker_plugin.sh +++ b/scripts/installers/docker_plugin.sh @@ -13,6 +13,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 diff --git a/scripts/installers/gcloud_installer.sh b/scripts/installers/gcloud_installer.sh index 2a6381c..b14f74b 100755 --- a/scripts/installers/gcloud_installer.sh +++ b/scripts/installers/gcloud_installer.sh @@ -3,9 +3,17 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$DIR/lib/common.sh" . "$DIR/lib/install_strategy.sh" TOOL="${1:-gcloud}" + +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then @@ -14,6 +22,7 @@ if [ ! -f "$CATALOG_FILE" ]; then fi BINARY_NAME="$(jq -r '.binary_name' "$CATALOG_FILE")" +VERSION_COMMAND="$(jq -r '.version_command // empty' "$CATALOG_FILE")" GCLOUD_SDK="$HOME/google-cloud-sdk" GCLOUD_BIN="$GCLOUD_SDK/bin" @@ -22,8 +31,17 @@ if [ -d "$GCLOUD_BIN" ]; then export PATH="$GCLOUD_BIN:$PATH" fi +# Version detection helper +get_gcloud_version() { + if [ -n "$VERSION_COMMAND" ]; then + timeout 5 bash -c "$VERSION_COMMAND" 2>/dev/null || true + elif command -v "$BINARY_NAME" >/dev/null 2>&1; then + "$BINARY_NAME" version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true + fi +} + # Get current version -before="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && "$BINARY_NAME" version 2>/dev/null | head -1 || true)" +before="$(get_gcloud_version)" if command -v "$BINARY_NAME" >/dev/null 2>&1; then # Already installed - use built-in update (quiet) @@ -62,7 +80,7 @@ for cmd in gcloud gsutil bq; do done # Report -after="$(command -v "$BINARY_NAME" >/dev/null 2>&1 && "$BINARY_NAME" version 2>/dev/null | head -1 || true)" +after="$(get_gcloud_version)" path="$(command -v "$BINARY_NAME" 2>/dev/null || true)" printf "[%s] before: %s\n" "$TOOL" "${before:-}" printf "[%s] after: %s\n" "$TOOL" "${after:-}" diff --git a/scripts/installers/github_clone.sh b/scripts/installers/github_clone.sh index 3b3e609..a3f08bb 100755 --- a/scripts/installers/github_clone.sh +++ b/scripts/installers/github_clone.sh @@ -11,6 +11,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + ACTION="${2:-install}" CATALOG_FILE="$DIR/../catalog/$TOOL.json" diff --git a/scripts/installers/github_release_binary.sh b/scripts/installers/github_release_binary.sh index 1a1f1b9..07f3e29 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -7,12 +7,25 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" . "$DIR/lib/common.sh" . "$DIR/lib/install_strategy.sh" +# Cleanup temp files on interrupt +_grb_cleanup() { + rm -f "/tmp/${BINARY_NAME:-tool}.$$" 2>/dev/null || true + rm -rf "/tmp/${BINARY_NAME:-tool}-extract.$$" 2>/dev/null || true +} +trap '_grb_cleanup; exit 130' INT TERM + TOOL="${1:-}" if [ -z "$TOOL" ]; then echo "Usage: $0 TOOL_NAME" >&2 exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 @@ -275,6 +288,9 @@ if command -v "$BINARY_NAME" >/dev/null 2>&1; then timeout 2 "$BINARY_NAME" version /dev/null | head -1 || true)" fi fi +# Normalize verbose version output +before="$(normalize_version_output "${before:-}")" +after="$(normalize_version_output "${after:-}")" path="$(command -v "$BINARY_NAME" 2>/dev/null || true)" printf "[%s] before: %s\n" "$TOOL" "${before:-}" printf "[%s] after: %s\n" "$TOOL" "${after:-}" diff --git a/scripts/installers/go_install.sh b/scripts/installers/go_install.sh new file mode 100755 index 0000000..ec2f4b2 --- /dev/null +++ b/scripts/installers/go_install.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Generic installer for Go tools via 'go install' +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$DIR/lib/common.sh" +. "$DIR/lib/install_strategy.sh" + +TOOL="${1:-}" +if [ -z "$TOOL" ]; then + echo "Usage: $0 TOOL_NAME" >&2 + exit 1 +fi + +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + +CATALOG_FILE="$DIR/../catalog/$TOOL.json" +if [ ! -f "$CATALOG_FILE" ]; then + echo "Error: Catalog file not found: $CATALOG_FILE" >&2 + exit 1 +fi + +# Check if Go is available +if ! command -v go >/dev/null 2>&1; then + echo "[$TOOL] Error: go not found. Install Go first." >&2 + exit 1 +fi + +# Parse catalog +BINARY_NAME="$(jq -r '.binary_name // .name' "$CATALOG_FILE")" +GO_PACKAGE="$(jq -r '.go_package // empty' "$CATALOG_FILE")" +VERSION_COMMAND="$(jq -r '.version_command // empty' "$CATALOG_FILE")" +VERSION_REGEX="$(jq -r '.version_regex // empty' "$CATALOG_FILE")" + +if [ -z "$GO_PACKAGE" ]; then + echo "[$TOOL] Error: No go_package specified in catalog" >&2 + exit 1 +fi + +# Get current version +get_version() { + if [ -n "$VERSION_COMMAND" ]; then + timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true + elif command -v "$BINARY_NAME" >/dev/null 2>&1; then + timeout 2 "$BINARY_NAME" --version /dev/null | head -1 || \ + timeout 2 "$BINARY_NAME" version /dev/null | head -1 || true + fi +} + +before="$(get_version)" + +# Install/update via go install +echo "[$TOOL] Installing via go install: ${GO_PACKAGE}@latest" >&2 +go install "${GO_PACKAGE}@latest" || { + echo "[$TOOL] Error: go install failed" >&2 + exit 1 +} + +# Report +after="$(get_version)" +path="$(command -v "$BINARY_NAME" 2>/dev/null || true)" +printf "[%s] before: %s\n" "$TOOL" "${before:-}" +printf "[%s] after: %s\n" "$TOOL" "${after:-}" +if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi + +# Refresh snapshot after successful installation +refresh_snapshot "$TOOL" diff --git a/scripts/installers/hashicorp_zip.sh b/scripts/installers/hashicorp_zip.sh index 6f218b4..2b48cb2 100755 --- a/scripts/installers/hashicorp_zip.sh +++ b/scripts/installers/hashicorp_zip.sh @@ -12,6 +12,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 @@ -41,8 +47,18 @@ BIN_DIR="$(get_install_dir "$BINARY_NAME")" get_install_cmd "$BIN_DIR" mkdir -p "$BIN_DIR" 2>/dev/null || true -# Remove distro package first if it exists -apt_remove_if_present "$BINARY_NAME" || true +# Only remove apt package if current installation is from apt +# (avoid unnecessary sudo prompts when tool isn't apt-managed) +if command -v "$BINARY_NAME" >/dev/null 2>&1; then + current_path="$(command -v "$BINARY_NAME")" + case "$current_path" in + /usr/bin/*|/usr/sbin/*) + if command -v dpkg >/dev/null 2>&1 && dpkg -S "$current_path" >/dev/null 2>&1; then + apt_remove_if_present "$BINARY_NAME" || true + fi + ;; + esac +fi # Get latest version from GitHub releases LATEST_TAG="" diff --git a/scripts/installers/npm_global.sh b/scripts/installers/npm_global.sh index 91e5dc7..35db14c 100755 --- a/scripts/installers/npm_global.sh +++ b/scripts/installers/npm_global.sh @@ -18,6 +18,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 @@ -26,6 +32,10 @@ fi # Parse catalog PACKAGE_NAME="$(jq -r '.package_name // .name' "$CATALOG_FILE")" +BINARY_NAME="$(jq -r '.binary_name // empty' "$CATALOG_FILE")" +BINARY_NAME="${BINARY_NAME:-$TOOL}" +VERSION_COMMAND="$(jq -r '.version_command // empty' "$CATALOG_FILE")" +VERSION_FLAG="$(jq -r '.version_flag // empty' "$CATALOG_FILE")" # Detect available package manager (pnpm > npm > yarn) # Only use pnpm if it's properly configured with a global bin directory @@ -47,32 +57,47 @@ if [ -z "$PKG_MANAGER" ]; then exit 1 fi +# Version detection helper (uses catalog version_command/version_flag if available) +get_npm_tool_version() { + if [ -n "$VERSION_COMMAND" ]; then + timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true + return + fi + local bin="$1" + if command -v "$bin" >/dev/null 2>&1; then + if [ -n "$VERSION_FLAG" ]; then + timeout 2 "$bin" $VERSION_FLAG /dev/null | head -1 || true + else + timeout 2 "$bin" --version /dev/null | head -1 || true + fi + fi +} + # Get current version -before="" -if command -v "$TOOL" >/dev/null 2>&1; then - before="$("$TOOL" --version 2>/dev/null || true)" -fi +before="$(get_npm_tool_version "$BINARY_NAME")" # Install or upgrade globally echo "[$TOOL] Installing package globally via $PKG_MANAGER: $PACKAGE_NAME" >&2 case "$PKG_MANAGER" in pnpm) - # Use @latest to ensure we get the newest version - pnpm add -g "${PACKAGE_NAME}@latest" || { + pnpm add -g "${PACKAGE_NAME}@latest" 2>&1 | grep -v "^npm warn deprecated" || { echo "[$TOOL] Error: pnpm install failed" >&2 exit 1 } ;; npm) - # Use @latest to ensure we get the newest version (bypasses npm cache issues) - npm install -g "${PACKAGE_NAME}@latest" || { - echo "[$TOOL] Error: npm install failed" >&2 - exit 1 - } + # Try normal install first; retry with --force on EEXIST errors + if ! npm install -g "${PACKAGE_NAME}@latest" 2>&1 | grep -v "^npm warn deprecated"; then + if npm install -g --force "${PACKAGE_NAME}@latest" 2>&1 | grep -v "^npm warn deprecated"; then + : # Force install succeeded + else + echo "[$TOOL] Error: npm install failed" >&2 + exit 1 + fi + fi ;; yarn) - # Use @latest to ensure we get the newest version - yarn global add "${PACKAGE_NAME}@latest" || { + yarn global add "${PACKAGE_NAME}@latest" 2>&1 | grep -v "^npm warn deprecated" || { echo "[$TOOL] Error: yarn install failed" >&2 exit 1 } @@ -80,12 +105,9 @@ case "$PKG_MANAGER" in esac # Report -after="" -if command -v "$TOOL" >/dev/null 2>&1; then - after="$("$TOOL" --version 2>/dev/null || true)" -fi +after="$(get_npm_tool_version "$BINARY_NAME")" -path="$(command -v "$TOOL" 2>/dev/null || true)" +path="$(command -v "$BINARY_NAME" 2>/dev/null || true)" printf "[%s] before: %s\n" "$TOOL" "${before:-}" printf "[%s] after: %s\n" "$TOOL" "${after:-}" if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi diff --git a/scripts/installers/npm_self_update.sh b/scripts/installers/npm_self_update.sh index a5485fb..05fd733 100755 --- a/scripts/installers/npm_self_update.sh +++ b/scripts/installers/npm_self_update.sh @@ -12,6 +12,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 @@ -23,6 +29,11 @@ BINARY_NAME="npm" # Load nvm if available (npm is bundled with nvm-managed Node.js) ensure_nvm_loaded +# Ensure we're using the nvm-managed node (not brew's node) +if command -v nvm >/dev/null 2>&1; then + nvm use default >/dev/null 2>&1 || true +fi + # Get current version before="$(timeout 2 npm --version /dev/null || echo '')" diff --git a/scripts/installers/package_manager.sh b/scripts/installers/package_manager.sh index 34255fb..26ec4a5 100755 --- a/scripts/installers/package_manager.sh +++ b/scripts/installers/package_manager.sh @@ -13,6 +13,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 diff --git a/scripts/installers/uv_tool.sh b/scripts/installers/uv_tool.sh index a2b61ee..6e4c9c3 100755 --- a/scripts/installers/uv_tool.sh +++ b/scripts/installers/uv_tool.sh @@ -3,6 +3,7 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$DIR/lib/common.sh" TOOL="${1:-}" if [ -z "$TOOL" ]; then @@ -10,6 +11,12 @@ if [ -z "$TOOL" ]; then exit 1 fi +# Validate tool name to prevent path traversal +if [[ "$TOOL" == *"/"* ]] || [[ "$TOOL" == *".."* ]]; then + echo "Error: Invalid tool name: $TOOL" >&2 + exit 1 +fi + CATALOG_FILE="$DIR/../catalog/$TOOL.json" if [ ! -f "$CATALOG_FILE" ]; then echo "Error: Catalog file not found: $CATALOG_FILE" >&2 @@ -32,9 +39,20 @@ fi # Get current version # Some tools need special handling for version detection VERSION_FLAG="$(jq -r '.version_flag // empty' "$CATALOG_FILE")" +VERSION_COMMAND="$(jq -r '.version_command // empty' "$CATALOG_FILE")" + get_uv_tool_version() { local tool="$1" bin="$2" flag="${3:---version}" - # Try the binary with its version flag first + # Use catalog-specified version command if available (most reliable) + if [ -n "$VERSION_COMMAND" ]; then + local ver + ver="$(timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true)" + if [ -n "$ver" ]; then + echo "$ver" + return + fi + fi + # Try the binary with its version flag if command -v "$bin" >/dev/null 2>&1; then local ver ver="$(timeout 2 "$bin" $flag /dev/null || true)" @@ -48,17 +66,27 @@ get_uv_tool_version() { } before="$(get_uv_tool_version "$TOOL" "$BINARY_NAME" "${VERSION_FLAG:---version}")" +# Fallback: if binary version detection failed, try uv tool list directly +if [ -z "$before" ] || [ "$before" = "" ]; then + before="$(uv tool list 2>/dev/null | grep -E "^${PACKAGE_NAME} " | head -1 | sed 's/^[^ ]* //' || true)" +fi # Install or upgrade with optional Python version pinning +# Use --upgrade (not --force) to avoid unnecessary reinstalls if [ -n "$PYTHON_VERSION" ]; then echo "[$TOOL] Installing with Python $PYTHON_VERSION..." - uv tool install --force --upgrade --python "$PYTHON_VERSION" "$PACKAGE_NAME" || true + uv tool install --upgrade --python "$PYTHON_VERSION" "$PACKAGE_NAME" || \ + uv tool install --force --upgrade --python "$PYTHON_VERSION" "$PACKAGE_NAME" || true else - uv tool install --force --upgrade "$PACKAGE_NAME" || true + uv tool install --upgrade "$PACKAGE_NAME" || \ + uv tool install --force --upgrade "$PACKAGE_NAME" || true fi # Report after="$(get_uv_tool_version "$TOOL" "$BINARY_NAME" "${VERSION_FLAG:---version}")" +# Normalize multi-line or verbose version output to a clean version string +before="$(normalize_version_output "${before:-}")" +after="$(normalize_version_output "${after:-}")" path="$(command -v "$BINARY_NAME" 2>/dev/null || true)" printf "[%s] before: %s\n" "$TOOL" "${before:-}" printf "[%s] after: %s\n" "$TOOL" "${after:-}" diff --git a/scripts/lib/capability.sh b/scripts/lib/capability.sh index 3fbb366..fc4348c 100755 --- a/scripts/lib/capability.sh +++ b/scripts/lib/capability.sh @@ -49,7 +49,7 @@ detect_install_method() { return 0 ;; "$HOME/.nvm/"*) - echo "npm" + echo "nvm" return 0 ;; "/usr/local/bin/"*) @@ -61,6 +61,23 @@ detect_install_method() { fi return 0 ;; + "/home/linuxbrew/.linuxbrew/"*|"/opt/homebrew/"*) + # Check if it's an npm global installed under brew's node (symlink to lib/node_modules/...) + local resolved_brew + resolved_brew="$(readlink -f "$binary_path" 2>/dev/null || true)" + if [ -n "$resolved_brew" ] && [[ "$resolved_brew" == */node_modules/* ]]; then + echo "npm" + elif command -v brew >/dev/null 2>&1 && brew list --formula 2>/dev/null | grep -q "^${tool}\$"; then + echo "brew" + else + echo "unknown" + fi + return 0 + ;; + "$HOME/.local/share/pnpm/"*) + echo "npm" + return 0 + ;; "/usr/bin/"*|"/bin/"*) # Check if it's a corepack shim local resolved_bp @@ -116,9 +133,12 @@ detect_all_installations() { [ -z "$path" ] && continue [ ! -x "$path" ] && continue - # Skip venv paths - these are environments, not installations + # Skip venv/virtualenv paths - these are environments, not installations case "$path" in - */venv/bin/*|*/.venv/bin/*|*/venvs/*/bin/*|*/virtualenvs/*/bin/*) continue ;; + */venv/bin/*|*/.venv/bin/*|*/venvs/*/bin/*|*/.venvs/*/bin/*|*/virtualenvs/*/bin/*|*/.virtualenvs/*/bin/*) continue ;; + */envs/*/bin/*|*/conda/*/bin/*|*/miniconda*/bin/*|*/anaconda*/bin/*) continue ;; + */env/bin/*) continue ;; + /mnt/[a-z]/*) continue ;; # Skip Windows paths on WSL esac # Resolve symlinks for deduplication (e.g., /bin/X and /usr/bin/X are the same on Ubuntu) @@ -166,7 +186,7 @@ classify_install_path() { # Extract node version for context local node_version="${path#$HOME/.nvm/versions/node/}" node_version="${node_version%%/*}" - echo "npm($node_version)" + echo "nvm($node_version)" ;; "$HOME/.local/pipx/venvs/"*) local pkg="${path#$HOME/.local/pipx/venvs/}" @@ -174,7 +194,14 @@ classify_install_path() { echo "pipx($pkg)" ;; "/home/linuxbrew/.linuxbrew/bin/"*|"/opt/homebrew/bin/"*) - echo "brew" + # Check if it's an npm global installed under brew's node (symlink to lib/node_modules/...) + local resolved_brew + resolved_brew="$(readlink -f "$path" 2>/dev/null || true)" + if [ -n "$resolved_brew" ] && [[ "$resolved_brew" == */node_modules/* ]]; then + echo "npm" + else + echo "brew" + fi ;; "/usr/local/bin/"*) if command -v brew >/dev/null 2>&1 && brew list --formula 2>/dev/null | grep -q "^${tool}\$"; then @@ -198,6 +225,9 @@ classify_install_path() { "/snap/bin/"*) echo "snap" ;; + "$HOME/.local/share/pnpm/"*) + echo "npm" + ;; *) echo "unknown" ;; diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 35a8635..f30f7ef 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -119,4 +119,27 @@ is_wsl() { grep -qi microsoft /proc/version 2>/dev/null } +# Normalize version output to a concise single-line format +# Extracts the first version-like string (X.Y.Z or X.Y) from potentially verbose output +# Usage: normalize_version_output "golangci-lint has version 2.11.3 built with go1.26.1..." +# → "2.11.3" +normalize_version_output() { + local raw="$1" + [ -z "$raw" ] && return + # Take only first line + raw="$(echo "$raw" | head -1)" + # Try to extract a version number (X.Y.Z or X.Y) + local ver + ver="$(echo "$raw" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + if [ -z "$ver" ]; then + ver="$(echo "$raw" | grep -oE '[0-9]+\.[0-9]+' | head -1)" + fi + # If we got a version, print it; otherwise print the raw first line (trimmed) + if [ -n "$ver" ]; then + echo "$ver" + else + echo "$raw" + fi +} + diff --git a/scripts/lib/reconcile.sh b/scripts/lib/reconcile.sh index a4c2e08..91dfd6c 100755 --- a/scripts/lib/reconcile.sh +++ b/scripts/lib/reconcile.sh @@ -65,6 +65,11 @@ remove_installation() { npm uninstall -g "$tool" 2>/dev/null || true fi ;; + nvm) + # nvm-managed binaries: these are node versions, not directly removable via nvm here + # Skip removal — nvm versions are managed by install_node.sh + echo "[$tool] Skipping nvm-managed binary (use install_node.sh to manage)" >&2 + ;; gem) if command -v gem >/dev/null 2>&1; then echo "[$tool] Uninstalling gem: $tool" >&2