From e322ac0ca2a48c39a85c4406c7b2c539c4014152 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 10 Mar 2026 13:53:01 +0100 Subject: [PATCH 1/3] fix(upgrade): resolve 23 issues in make upgrade workflow - Fix pip installer (was installing python3 instead of pip via brew) - Fix Python removal detecting wrong binaries (generic python3) - Fix Node.js install reporting "Failed" then succeeding - Fix Node.js removal trying to remove other nvm versions - Add go_install.sh installer for templ and other Go tools - Fix pnpm reconciliation showing "unknown" install method - Fix GAM version parsed as Python version instead of GAM version - Fix semgrep showing for before-version - Suppress Homebrew auto-update noise during upgrades - Filter noisy multi-installation detection (WSL Windows paths, conda) - Clarify "a = all" prompt to "a = all categories" - Clarify "P" skip option wording for version cycles - Add normalize_version_output() for consistent version display - Add final summary with updated/removed/skipped/failed counters - Fix " via unknown" display for uninstalled tools - Add SIGINT trap for partial summary on interruption - Fix terraform unnecessary sudo prompt on non-apt installs - Fix npm updated at wrong path (add nvm use default) - Fix uv cascading tool updates during reconciliation - Fix install_tool.sh uninstall leaking into universal removal --- catalog/gam.json | 2 + catalog/pip.json | 11 ++--- scripts/guide.sh | 64 +++++++++++++++++++++++--- scripts/install_node.sh | 4 ++ scripts/install_pip.sh | 62 +++++++++++++++++++++++++ scripts/install_python.sh | 39 ++++++++++++++-- scripts/install_tool.sh | 4 ++ scripts/install_uv.sh | 5 +-- scripts/installers/go_install.sh | 65 +++++++++++++++++++++++++++ scripts/installers/hashicorp_zip.sh | 14 +++++- scripts/installers/npm_self_update.sh | 5 +++ scripts/installers/uv_tool.sh | 24 ++++++++-- scripts/lib/capability.sh | 32 ++++++++++++- scripts/lib/common.sh | 23 ++++++++++ 14 files changed, 326 insertions(+), 28 deletions(-) create mode 100755 scripts/install_pip.sh create mode 100755 scripts/installers/go_install.sh 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/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..dc2fc02 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -1,16 +1,39 @@ #!/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() { + echo "================================================================================" + echo "Summary (interrupted)" + echo "================================================================================" + 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 +} + # Category filter: CATEGORY=python,go or --category=python CATEGORY_FILTER="${CATEGORY:-}" for arg in "$@"; do @@ -241,6 +264,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,7 +288,11 @@ 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}" + if [ -z "$installed" ]; then + printf " installed: not installed\n" + else + printf " installed: %s via %s\n" "$installed" "${method:-unknown}" + fi printf " target: %s\n" "$(osc8 "$url" "${latest:-}")" check_multi_installs "$catalog_tool" printf " auto-updating...\n" @@ -297,6 +325,7 @@ process_tool() { reload_audit_json # Clean up any already-current marker left by installer rm -f "/tmp/.cli-audit/${catalog_tool}.already-current" + SUMMARY_UPDATED=$((SUMMARY_UPDATED + 1)) return 0 fi @@ -304,7 +333,11 @@ 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}" + if [ -z "$installed" ]; then + printf " installed: not installed\n" + else + printf " installed: %s via %s\n" "$installed" "${method:-unknown}" + fi check_multi_installs "$catalog_tool" @@ -335,7 +368,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 +377,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 +452,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 +469,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 +513,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 +541,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,6 +591,7 @@ 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" fi @@ -570,6 +612,7 @@ process_tool() { ;; *) # User declined (N or empty) + SUMMARY_SKIPPED=$((SUMMARY_SKIPPED + 1)) ;; esac } @@ -863,7 +906,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 +978,14 @@ if [ -n "$DEPRECATED_TOOLS" ]; then fi fi +# Print final summary +echo +echo "================================================================================" +echo "Summary" +echo "================================================================================" +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 "All done. Re-run: make audit" +echo "Re-run: make audit" 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..7c19b98 --- /dev/null +++ b/scripts/install_pip.sh @@ -0,0 +1,62 @@ +#!/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 + python3 -m pip install --upgrade pip 2>&1 || true + + 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..47ffe19 100755 --- a/scripts/install_python.sh +++ b/scripts/install_python.sh @@ -210,11 +210,42 @@ 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 + + # 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) + if have apt-get; then + apt_remove_if_present "python${short_ver}" "python${short_ver}-venv" "python${short_ver}-dev" 2>/dev/null || true + 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..616d818 100755 --- a/scripts/install_tool.sh +++ b/scripts/install_tool.sh @@ -48,6 +48,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/go_install.sh b/scripts/installers/go_install.sh new file mode 100755 index 0000000..deffb63 --- /dev/null +++ b/scripts/installers/go_install.sh @@ -0,0 +1,65 @@ +#!/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 + +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..d6fd76c 100755 --- a/scripts/installers/hashicorp_zip.sh +++ b/scripts/installers/hashicorp_zip.sh @@ -41,8 +41,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_self_update.sh b/scripts/installers/npm_self_update.sh index a5485fb..c19a693 100755 --- a/scripts/installers/npm_self_update.sh +++ b/scripts/installers/npm_self_update.sh @@ -23,6 +23,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/uv_tool.sh b/scripts/installers/uv_tool.sh index a2b61ee..cdf9342 100755 --- a/scripts/installers/uv_tool.sh +++ b/scripts/installers/uv_tool.sh @@ -32,9 +32,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,13 +59,20 @@ 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 diff --git a/scripts/lib/capability.sh b/scripts/lib/capability.sh index 3fbb366..b7ad28b 100755 --- a/scripts/lib/capability.sh +++ b/scripts/lib/capability.sh @@ -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 @@ -118,7 +135,8 @@ detect_all_installations() { # Skip venv paths - these are environments, not installations case "$path" in - */venv/bin/*|*/.venv/bin/*|*/venvs/*/bin/*|*/virtualenvs/*/bin/*) continue ;; + */venv/bin/*|*/.venv/bin/*|*/venvs/*/bin/*|*/virtualenvs/*/bin/*|*/envs/*/bin/*|*/conda/*/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) @@ -174,7 +192,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 +223,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 +} + From 9854e152a383cc1bf68d1bdf917ba8bac714839a Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 10 Mar 2026 15:18:21 +0100 Subject: [PATCH 2/3] fix(upgrade): resolve 31 additional issues from second upgrade run Version output normalization: - Add version_command to isort (--version-number), black, flake8 catalogs - Add version_command to gcloud catalog for clean version extraction - Normalize version output in uv_tool.sh and github_release_binary.sh - Source common.sh in uv_tool.sh and gcloud_installer.sh for normalize fn Capability detection fixes: - Classify $HOME/.nvm/ paths as "nvm" not "npm" in detect/classify - Filter .venvs/, .virtualenvs/, env/, miniconda, anaconda from installs - Add "nvm" case in reconcile.sh remove_installation() npm_global.sh improvements: - Read version_command/version_flag/binary_name from catalog JSON - Retry with --force on EEXIST errors (fixes gemini install) - Filter "npm warn deprecated" noise from output pip installer fix: - Skip pip upgrade when uv manages Python (externally-managed error) Python removal fixes: - Skip apt removal when version matches system Python (python3 dep) - Pre-cache sudo credentials to avoid repeated password prompts Guide UX improvements: - Track auto-update exit codes; count Installed vs Updated vs Failed - Use SUMMARY_INSTALLED counter (was declared but unused) - Show "self-managed" instead of "" for skip_upstream tools - Better partial removal messaging (system binary vs real failure) - Count partial removals as Failed instead of silently ignoring - Unified print_summary() for both interrupt and normal completion Interrupt handling: - Add cleanup trap in github_release_binary.sh for temp files --- catalog/black.json | 3 +- catalog/flake8.json | 3 +- catalog/gcloud.json | 1 + catalog/isort.json | 3 +- scripts/guide.sh | 71 ++++++++++++++------- scripts/install_pip.sh | 9 ++- scripts/install_python.sh | 14 +++- scripts/installers/gcloud_installer.sh | 15 ++++- scripts/installers/github_release_binary.sh | 10 +++ scripts/installers/npm_global.sh | 52 +++++++++------ scripts/installers/uv_tool.sh | 4 ++ scripts/lib/capability.sh | 10 +-- scripts/lib/reconcile.sh | 5 ++ 13 files changed, 147 insertions(+), 53 deletions(-) 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/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/scripts/guide.sh b/scripts/guide.sh index dc2fc02..ee1d61c 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -24,14 +24,17 @@ SUMMARY_FAILED=0 SUMMARY_REMOVED=0 print_summary() { + local label="${1:-interrupted}" echo "================================================================================" - echo "Summary (interrupted)" + echo "Summary${label:+ ($label)}" echo "================================================================================" - printf " Updated: %d\n" "$SUMMARY_UPDATED" - printf " Removed: %d\n" "$SUMMARY_REMOVED" - printf " Skipped: %d\n" "$SUMMARY_SKIPPED" - printf " Failed: %d\n" "$SUMMARY_FAILED" + 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 @@ -293,7 +296,13 @@ process_tool() { else printf " installed: %s via %s\n" "$installed" "${method:-unknown}" fi - printf " target: %s\n" "$(osc8 "$url" "${latest:-}")" + # 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" @@ -306,18 +315,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 @@ -325,7 +335,13 @@ process_tool() { reload_audit_json # Clean up any already-current marker left by installer rm -f "/tmp/.cli-audit/${catalog_tool}.already-current" - SUMMARY_UPDATED=$((SUMMARY_UPDATED + 1)) + 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 @@ -341,7 +357,13 @@ process_tool() { 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" @@ -593,7 +615,16 @@ process_tool() { 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" @@ -980,12 +1011,4 @@ fi # Print final summary echo -echo "================================================================================" -echo "Summary" -echo "================================================================================" -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" +print_summary "" diff --git a/scripts/install_pip.sh b/scripts/install_pip.sh index 7c19b98..947c020 100755 --- a/scripts/install_pip.sh +++ b/scripts/install_pip.sh @@ -39,7 +39,14 @@ install_pip() { fi # Upgrade pip to latest - python3 -m pip install --upgrade pip 2>&1 || true + # 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)" diff --git a/scripts/install_python.sh b/scripts/install_python.sh index 47ffe19..f1e336c 100755 --- a/scripts/install_python.sh +++ b/scripts/install_python.sh @@ -222,14 +222,26 @@ uninstall_py_tools() { 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 - apt_remove_if_present "python${short_ver}" "python${short_ver}-venv" "python${short_ver}-dev" 2>/dev/null || true + 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 diff --git a/scripts/installers/gcloud_installer.sh b/scripts/installers/gcloud_installer.sh index 2a6381c..315b24b 100755 --- a/scripts/installers/gcloud_installer.sh +++ b/scripts/installers/gcloud_installer.sh @@ -3,6 +3,7 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$DIR/lib/common.sh" . "$DIR/lib/install_strategy.sh" TOOL="${1:-gcloud}" @@ -14,6 +15,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 +24,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 +73,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_release_binary.sh b/scripts/installers/github_release_binary.sh index 1a1f1b9..c82c49f 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -7,6 +7,13 @@ 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 @@ -275,6 +282,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/npm_global.sh b/scripts/installers/npm_global.sh index 91e5dc7..a681689 100755 --- a/scripts/installers/npm_global.sh +++ b/scripts/installers/npm_global.sh @@ -26,6 +26,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 +51,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 +99,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/uv_tool.sh b/scripts/installers/uv_tool.sh index cdf9342..335e331 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 @@ -77,6 +78,9 @@ 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 b7ad28b..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/"*) @@ -133,9 +133,11 @@ 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/*|*/envs/*/bin/*|*/conda/*/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 @@ -184,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/}" 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 From e550d5d5bd06c837ee60701550dd3454d55ccf85 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 10 Mar 2026 15:21:58 +0100 Subject: [PATCH 3/3] fix(security): add path traversal validation to all installer scripts - Validate tool name argument rejects "/" and ".." in install_tool.sh (entry point) and all 10 installer scripts (defense in depth) - Extract print_installed_status() helper in guide.sh to reduce duplicated installed/not-installed display logic --- scripts/guide.sh | 23 ++++++++++++--------- scripts/install_tool.sh | 6 ++++++ scripts/installers/dedicated_script.sh | 6 ++++++ scripts/installers/docker_plugin.sh | 6 ++++++ scripts/installers/gcloud_installer.sh | 7 +++++++ scripts/installers/github_clone.sh | 6 ++++++ scripts/installers/github_release_binary.sh | 6 ++++++ scripts/installers/go_install.sh | 6 ++++++ scripts/installers/hashicorp_zip.sh | 6 ++++++ scripts/installers/npm_global.sh | 6 ++++++ scripts/installers/npm_self_update.sh | 6 ++++++ scripts/installers/package_manager.sh | 6 ++++++ scripts/installers/uv_tool.sh | 6 ++++++ 13 files changed, 86 insertions(+), 10 deletions(-) diff --git a/scripts/guide.sh b/scripts/guide.sh index ee1d61c..a647dbb 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -165,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) @@ -291,11 +302,7 @@ 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" - if [ -z "$installed" ]; then - printf " installed: not installed\n" - else - printf " installed: %s via %s\n" "$installed" "${method:-unknown}" - fi + 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)" @@ -349,11 +356,7 @@ process_tool() { printf "\n==> %s %s\n" "$icon" "$display" [ -n "$description" ] && printf " %s\n" "$description" [ -n "$homepage" ] && printf " Homepage: %s\n" "$(osc8 "$homepage" "$homepage")" - if [ -z "$installed" ]; then - printf " installed: not installed\n" - else - printf " installed: %s via %s\n" "$installed" "${method:-unknown}" - fi + print_installed_status "$installed" "$method" check_multi_installs "$catalog_tool" diff --git a/scripts/install_tool.sh b/scripts/install_tool.sh index 616d818..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 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 315b24b..b14f74b 100755 --- a/scripts/installers/gcloud_installer.sh +++ b/scripts/installers/gcloud_installer.sh @@ -7,6 +7,13 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" . "$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 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 c82c49f..07f3e29 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -20,6 +20,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/go_install.sh b/scripts/installers/go_install.sh index deffb63..ec2f4b2 100755 --- a/scripts/installers/go_install.sh +++ b/scripts/installers/go_install.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 diff --git a/scripts/installers/hashicorp_zip.sh b/scripts/installers/hashicorp_zip.sh index d6fd76c..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 diff --git a/scripts/installers/npm_global.sh b/scripts/installers/npm_global.sh index a681689..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 diff --git a/scripts/installers/npm_self_update.sh b/scripts/installers/npm_self_update.sh index c19a693..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 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 335e331..6e4c9c3 100755 --- a/scripts/installers/uv_tool.sh +++ b/scripts/installers/uv_tool.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