From d4c0f6789128c2ee0a86dbf8d7055f763f31f323 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 25 Feb 2026 15:20:37 +0100 Subject: [PATCH 1/4] fix(scripts): add uninstall handlers and sudo-aware removal Add missing uninstall action to install_go.sh, install_composer.sh, install_docker.sh, and install_parallel.sh. All 14 dedicated scripts now consistently handle install/update/uninstall actions. Fix remove_installation() in reconcile.sh to check directory writability before removal, using sudo for root-owned paths like /usr/local/bin. Closes #36, closes #37 Signed-off-by: Sebastian Mendel --- scripts/install_composer.sh | 181 ++++++++++++++--------- scripts/install_docker.sh | 71 +++++---- scripts/install_go.sh | 263 ++++++++++++++++++++------------- scripts/install_parallel.sh | 34 ++++- scripts/lib/reconcile.sh | 8 +- tests/test_update_fixes.py | 284 ++++++++++++++++++++++++++++++++++++ 6 files changed, 642 insertions(+), 199 deletions(-) diff --git a/scripts/install_composer.sh b/scripts/install_composer.sh index aab2d03..28662c0 100755 --- a/scripts/install_composer.sh +++ b/scripts/install_composer.sh @@ -5,82 +5,123 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ACTION="${1:-install}" INSTALL_DIR="${COMPOSER_INSTALL_DIR:-/usr/local/bin}" COMPOSER_URL="https://getcomposer.org/download/latest-stable/composer.phar" -# Check for PHP dependency -if ! command -v php >/dev/null 2>&1; then - echo "[composer] Error: PHP is required but not installed." >&2 - echo "[composer] Install PHP first using one of:" >&2 - echo " - sudo apt-get install php php-cli (Debian/Ubuntu)" >&2 - echo " - brew install php (macOS)" >&2 - echo " - ./install_tool.sh php (via this toolset)" >&2 - - # Try to install PHP automatically if we can - if [ -f "$DIR/installers/package_manager.sh" ]; then - echo "[composer] Attempting to install PHP automatically..." >&2 - if "$DIR/installers/package_manager.sh" php; then - echo "[composer] PHP installed successfully, continuing with Composer..." >&2 +install_composer() { + # Check for PHP dependency + if ! command -v php >/dev/null 2>&1; then + echo "[composer] Error: PHP is required but not installed." >&2 + echo "[composer] Install PHP first using one of:" >&2 + echo " - sudo apt-get install php php-cli (Debian/Ubuntu)" >&2 + echo " - brew install php (macOS)" >&2 + echo " - ./install_tool.sh php (via this toolset)" >&2 + + # Try to install PHP automatically if we can + if [ -f "$DIR/installers/package_manager.sh" ]; then + echo "[composer] Attempting to install PHP automatically..." >&2 + if "$DIR/installers/package_manager.sh" php; then + echo "[composer] PHP installed successfully, continuing with Composer..." >&2 + else + echo "[composer] Failed to install PHP automatically. Please install manually." >&2 + exit 1 + fi else - echo "[composer] Failed to install PHP automatically. Please install manually." >&2 exit 1 fi + fi + + echo "[composer] Downloading latest stable composer.phar..." + + # Download to temp file + TMP_FILE="$(mktemp)" + trap "rm -f '$TMP_FILE'" EXIT + + if ! curl -fsSL "$COMPOSER_URL" -o "$TMP_FILE"; then + echo "[composer] Error: Failed to download from $COMPOSER_URL" >&2 + exit 1 + fi + + # Verify it's a valid phar by checking version + echo "[composer] Verifying downloaded phar..." + if ! php "$TMP_FILE" --version >/dev/null 2>&1; then + echo "[composer] Error: Downloaded file is not a valid composer.phar" >&2 + exit 1 + fi + + # Get version + COMPOSER_VERSION="$(php "$TMP_FILE" --version 2>/dev/null | head -1 || echo 'unknown')" + echo "[composer] Downloaded: $COMPOSER_VERSION" + + # Get current version + CURRENT_VERSION="$(command -v composer >/dev/null 2>&1 && composer --version 2>/dev/null | head -1 || echo '')" + echo "[composer] Current: $CURRENT_VERSION" + + # Install composer.phar + echo "[composer] Installing to $INSTALL_DIR/composer..." + + if [ -w "$INSTALL_DIR" ]; then + # User has write access + cp "$TMP_FILE" "$INSTALL_DIR/composer" + chmod 755 "$INSTALL_DIR/composer" + elif command -v sudo >/dev/null 2>&1; then + # Need sudo + echo "[composer] Requires sudo to install to $INSTALL_DIR" + sudo cp "$TMP_FILE" "$INSTALL_DIR/composer" + sudo chmod 755 "$INSTALL_DIR/composer" else + echo "[composer] Error: Cannot write to $INSTALL_DIR and sudo not available" >&2 + echo "[composer] Try: COMPOSER_INSTALL_DIR=~/.local/bin $0" >&2 exit 1 fi -fi - -echo "[composer] Downloading latest stable composer.phar..." - -# Download to temp file -TMP_FILE="$(mktemp)" -trap "rm -f '$TMP_FILE'" EXIT - -if ! curl -fsSL "$COMPOSER_URL" -o "$TMP_FILE"; then - echo "[composer] Error: Failed to download from $COMPOSER_URL" >&2 - exit 1 -fi - -# Verify it's a valid phar by checking version -echo "[composer] Verifying downloaded phar..." -if ! php "$TMP_FILE" --version >/dev/null 2>&1; then - echo "[composer] Error: Downloaded file is not a valid composer.phar" >&2 - exit 1 -fi - -# Get version -COMPOSER_VERSION="$(php "$TMP_FILE" --version 2>/dev/null | head -1 || echo 'unknown')" -echo "[composer] Downloaded: $COMPOSER_VERSION" - -# Get current version -CURRENT_VERSION="$(command -v composer >/dev/null 2>&1 && composer --version 2>/dev/null | head -1 || echo '')" -echo "[composer] Current: $CURRENT_VERSION" - -# Install composer.phar -echo "[composer] Installing to $INSTALL_DIR/composer..." - -if [ -w "$INSTALL_DIR" ]; then - # User has write access - cp "$TMP_FILE" "$INSTALL_DIR/composer" - chmod 755 "$INSTALL_DIR/composer" -elif command -v sudo >/dev/null 2>&1; then - # Need sudo - echo "[composer] Requires sudo to install to $INSTALL_DIR" - sudo cp "$TMP_FILE" "$INSTALL_DIR/composer" - sudo chmod 755 "$INSTALL_DIR/composer" -else - echo "[composer] Error: Cannot write to $INSTALL_DIR and sudo not available" >&2 - echo "[composer] Try: COMPOSER_INSTALL_DIR=~/.local/bin $0" >&2 - exit 1 -fi - -# Verify installation -NEW_VERSION="$(composer --version 2>/dev/null | head -1 || echo '')" -echo "[composer] Installed: $NEW_VERSION" - -if [ "$NEW_VERSION" = "" ]; then - echo "[composer] Error: Installation verification failed" >&2 - exit 1 -fi - -echo "[composer] ✓ Installation successful" + + # Verify installation + NEW_VERSION="$(composer --version 2>/dev/null | head -1 || echo '')" + echo "[composer] Installed: $NEW_VERSION" + + if [ "$NEW_VERSION" = "" ]; then + echo "[composer] Error: Installation verification failed" >&2 + exit 1 + fi + + echo "[composer] Installation successful" +} + +uninstall_composer() { + local composer_bin + composer_bin="$(command -v composer 2>/dev/null || echo "")" + + if [ -z "$composer_bin" ]; then + echo "[composer] Not installed, nothing to remove" >&2 + return 0 + fi + + echo "[composer] Removing composer binary: $composer_bin" >&2 + local bin_dir + bin_dir="$(dirname "$composer_bin")" + if [ -w "$bin_dir" ]; then + rm -f "$composer_bin" + elif command -v sudo >/dev/null 2>&1; then + sudo rm -f "$composer_bin" + else + echo "[composer] Error: Cannot remove $composer_bin (no write access and sudo not available)" >&2 + return 1 + fi + + # Remove composer cache/config directories + for dir in "$HOME/.composer" "$HOME/.config/composer"; do + if [ -d "$dir" ]; then + echo "[composer] Removing $dir" >&2 + rm -rf "$dir" + fi + done + + echo "[composer] Uninstall complete" >&2 +} + +case "$ACTION" in + install|update) install_composer ;; + uninstall) uninstall_composer ;; + *) echo "Usage: $0 {install|update|uninstall}" ; exit 2 ;; +esac diff --git a/scripts/install_docker.sh b/scripts/install_docker.sh index 145256a..eee9f96 100755 --- a/scripts/install_docker.sh +++ b/scripts/install_docker.sh @@ -4,35 +4,52 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" . "$DIR/lib/common.sh" +ACTION="${1:-install}" + before() { command -v docker >/dev/null 2>&1 && docker --version || true; } after() { command -v docker >/dev/null 2>&1 && docker --version || true; } -# Reconcile to official script/apt on Debian/Ubuntu systems -if have apt-get; then - echo "[docker] current: $(before)" - # Remove legacy docker.io if present - apt_purge_if_present docker.io docker-doc docker-compose podman-docker containerd runc || true - - if is_wsl; then - echo "[docker] WSL detected - installing Docker Engine (skipping 20s wait)..." - echo "[docker] Note: Docker Desktop for Windows is also available." - echo "[docker] See: https://docs.docker.com/desktop/wsl/" - # Download script and remove the sleep to skip 20-second wait - curl -fsSL https://get.docker.com -o /tmp/get-docker.sh - sed -i 's/sleep 20/sleep 0/' /tmp/get-docker.sh - sudo sh /tmp/get-docker.sh - rm -f /tmp/get-docker.sh - # Start service manually (no systemd in WSL) - sudo service docker start || echo "[docker] Run 'sudo service docker start' to start Docker" - else - curl -fsSL https://get.docker.com | sh +install_docker() { + # Reconcile to official script/apt on Debian/Ubuntu systems + if have apt-get; then + echo "[docker] current: $(before)" + # Remove legacy docker.io if present + apt_purge_if_present docker.io docker-doc docker-compose podman-docker containerd runc || true + + if is_wsl; then + echo "[docker] WSL detected - installing Docker Engine (skipping 20s wait)..." + echo "[docker] Note: Docker Desktop for Windows is also available." + echo "[docker] See: https://docs.docker.com/desktop/wsl/" + # Download script and remove the sleep to skip 20-second wait + curl -fsSL https://get.docker.com -o /tmp/get-docker.sh + sed -i 's/sleep 20/sleep 0/' /tmp/get-docker.sh + sudo sh /tmp/get-docker.sh + rm -f /tmp/get-docker.sh + # Start service manually (no systemd in WSL) + sudo service docker start || echo "[docker] Run 'sudo service docker start' to start Docker" + else + curl -fsSL https://get.docker.com | sh + fi + + sudo usermod -aG docker "$USER" || true + echo "[docker] updated: $(after)" + return 0 fi - sudo usermod -aG docker "$USER" || true - echo "[docker] updated: $(after)" - exit 0 -fi - -echo "Please install Docker following https://docs.docker.com/engine/install/" - - + echo "Please install Docker following https://docs.docker.com/engine/install/" +} + +uninstall_docker() { + echo "[docker] Docker uninstall is a system-level operation." >&2 + echo "[docker] To remove Docker Engine, run:" >&2 + echo "[docker] sudo apt-get remove docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin" >&2 + echo "[docker] sudo apt-get purge docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin" >&2 + echo "[docker] sudo rm -rf /var/lib/docker /var/lib/containerd" >&2 + echo "[docker] See: https://docs.docker.com/engine/install/ubuntu/#uninstall-docker-engine" >&2 +} + +case "$ACTION" in + install|update) install_docker ;; + uninstall) uninstall_docker ;; + *) echo "Usage: $0 {install|update|uninstall}" ; exit 2 ;; +esac diff --git a/scripts/install_go.sh b/scripts/install_go.sh index cc60a47..62c349d 100755 --- a/scripts/install_go.sh +++ b/scripts/install_go.sh @@ -7,6 +7,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" have() { command -v "$1" >/dev/null 2>&1; } TOOL="go" +ACTION="${1:-install}" # Support version-specific installation via GO_VERSION env var # e.g., GO_VERSION=1.24 installs go1.24 alongside existing go @@ -29,121 +30,183 @@ get_go_version() { fi } -before="$(get_go_version "$BINARY")" +install_go() { + local before="$(get_go_version "$BINARY")" -# Version-specific Go installation (e.g., go1.24 alongside go1.25) -if [ -n "$TARGET_CYCLE" ]; then - if ! have go; then - echo "Error: Base Go installation required for multi-version support" >&2 - echo "Install base Go first, then specific versions" >&2 - exit 1 - fi - - # golang.org/dl uses full version names (go1.24.12, not go1.24) - # Look up the latest patch version for this major.minor cycle - FULL_VERSION="" - if [[ "$TARGET_CYCLE" =~ ^[0-9]+\.[0-9]+$ ]]; then - # It's just major.minor, need to find latest patch - echo "Looking up latest Go ${TARGET_CYCLE}.x version..." - FULL_VERSION=$(curl -s "https://go.dev/dl/?mode=json" 2>/dev/null | \ - grep -oE "go${TARGET_CYCLE}\.[0-9]+" | head -1 | sed 's/go//' || true) - elif [[ "$TARGET_CYCLE" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - # It's already a full version - FULL_VERSION="$TARGET_CYCLE" - fi + # Version-specific Go installation (e.g., go1.24 alongside go1.25) + if [ -n "$TARGET_CYCLE" ]; then + if ! have go; then + echo "Error: Base Go installation required for multi-version support" >&2 + echo "Install base Go first, then specific versions" >&2 + exit 1 + fi - if [ -z "$FULL_VERSION" ]; then - echo "Error: Could not determine full version for Go ${TARGET_CYCLE}" >&2 - echo "Available versions: https://go.dev/dl/" >&2 - exit 1 - fi + # golang.org/dl uses full version names (go1.24.12, not go1.24) + # Look up the latest patch version for this major.minor cycle + FULL_VERSION="" + if [[ "$TARGET_CYCLE" =~ ^[0-9]+\.[0-9]+$ ]]; then + # It's just major.minor, need to find latest patch + echo "Looking up latest Go ${TARGET_CYCLE}.x version..." + FULL_VERSION=$(curl -s "https://go.dev/dl/?mode=json" 2>/dev/null | \ + grep -oE "go${TARGET_CYCLE}\.[0-9]+" | head -1 | sed 's/go//' || true) + elif [[ "$TARGET_CYCLE" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # It's already a full version + FULL_VERSION="$TARGET_CYCLE" + fi - # The binary will be named go1.24.12 (full version) - FULL_BINARY="go${FULL_VERSION}" - - echo "Installing ${FULL_BINARY} via go install golang.org/dl/${FULL_BINARY}@latest..." - if go install "golang.org/dl/${FULL_BINARY}@latest"; then - # The go1.XX.YY command needs to download its SDK on first run - if have "$FULL_BINARY"; then - echo "Downloading Go ${FULL_VERSION} SDK..." - "$FULL_BINARY" download || true - - # Create symlink from go1.24 -> go1.24.12 for convenience - GOBIN="$(go env GOPATH)/bin" - if [ -x "$GOBIN/$FULL_BINARY" ] && [ ! -e "$GOBIN/$BINARY" ]; then - ln -sf "$FULL_BINARY" "$GOBIN/$BINARY" 2>/dev/null || true - echo "Created symlink: $BINARY -> $FULL_BINARY" - fi + if [ -z "$FULL_VERSION" ]; then + echo "Error: Could not determine full version for Go ${TARGET_CYCLE}" >&2 + echo "Available versions: https://go.dev/dl/" >&2 + exit 1 fi - else - echo "Failed to install ${FULL_BINARY}" >&2 - fi -# Standard single-version Go installation -elif have brew; then - # Use homebrew for installation/upgrade - if have go; then brew upgrade go || brew install go || true; else brew install go || true; fi -else - # Manual installation from official Go downloads - # Determine OS and architecture - OS="$(uname -s | tr '[:upper:]' '[:lower:]')" - ARCH="$(uname -m)" - case "$ARCH" in - x86_64|amd64) GOARCH="amd64" ;; - aarch64|arm64) GOARCH="arm64" ;; - armv6*) GOARCH="armv6l" ;; - *) GOARCH="amd64" ;; - esac - - # Get latest version from Go download page - TMP="$(mktemp -d)" - VERSION_URL="https://go.dev/VERSION?m=text" - if curl -fsSL "$VERSION_URL" -o "$TMP/version.txt" 2>/dev/null; then - # VERSION file contains lines like "go1.25.2" - take first line - TARGET_VERSION="$(head -n1 "$TMP/version.txt" | tr -d '\n\r')" - if [ -n "$TARGET_VERSION" ]; then - # Download archive - ARCHIVE="${TARGET_VERSION}.${OS}-${GOARCH}.tar.gz" - DOWNLOAD_URL="https://go.dev/dl/${ARCHIVE}" - - echo "Downloading ${ARCHIVE}..." - if curl -fsSL "$DOWNLOAD_URL" -o "$TMP/${ARCHIVE}"; then - # Remove existing installation - if [ -d "/usr/local/go" ]; then - echo "Removing existing /usr/local/go..." - sudo rm -rf /usr/local/go + # The binary will be named go1.24.12 (full version) + FULL_BINARY="go${FULL_VERSION}" + + echo "Installing ${FULL_BINARY} via go install golang.org/dl/${FULL_BINARY}@latest..." + if go install "golang.org/dl/${FULL_BINARY}@latest"; then + # The go1.XX.YY command needs to download its SDK on first run + if have "$FULL_BINARY"; then + echo "Downloading Go ${FULL_VERSION} SDK..." + "$FULL_BINARY" download || true + + # Create symlink from go1.24 -> go1.24.12 for convenience + GOBIN="$(go env GOPATH)/bin" + if [ -x "$GOBIN/$FULL_BINARY" ] && [ ! -e "$GOBIN/$BINARY" ]; then + ln -sf "$FULL_BINARY" "$GOBIN/$BINARY" 2>/dev/null || true + echo "Created symlink: $BINARY -> $FULL_BINARY" fi + fi + else + echo "Failed to install ${FULL_BINARY}" >&2 + fi - # Extract new version - echo "Extracting to /usr/local/go..." - sudo tar -C /usr/local -xzf "$TMP/${ARCHIVE}" - - # Ensure /usr/local/go/bin is in PATH - if [ ! -f "/usr/local/bin/go" ]; then - sudo ln -sf /usr/local/go/bin/go /usr/local/bin/go 2>/dev/null || true - sudo ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt 2>/dev/null || true + # Standard single-version Go installation + elif have brew; then + # Use homebrew for installation/upgrade + if have go; then brew upgrade go || brew install go || true; else brew install go || true; fi + else + # Manual installation from official Go downloads + # Determine OS and architecture + OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m)" + case "$ARCH" in + x86_64|amd64) GOARCH="amd64" ;; + aarch64|arm64) GOARCH="arm64" ;; + armv6*) GOARCH="armv6l" ;; + *) GOARCH="amd64" ;; + esac + + # Get latest version from Go download page + TMP="$(mktemp -d)" + VERSION_URL="https://go.dev/VERSION?m=text" + if curl -fsSL "$VERSION_URL" -o "$TMP/version.txt" 2>/dev/null; then + # VERSION file contains lines like "go1.25.2" - take first line + TARGET_VERSION="$(head -n1 "$TMP/version.txt" | tr -d '\n\r')" + if [ -n "$TARGET_VERSION" ]; then + # Download archive + ARCHIVE="${TARGET_VERSION}.${OS}-${GOARCH}.tar.gz" + DOWNLOAD_URL="https://go.dev/dl/${ARCHIVE}" + + echo "Downloading ${ARCHIVE}..." + if curl -fsSL "$DOWNLOAD_URL" -o "$TMP/${ARCHIVE}"; then + # Remove existing installation + if [ -d "/usr/local/go" ]; then + echo "Removing existing /usr/local/go..." + sudo rm -rf /usr/local/go + fi + + # Extract new version + echo "Extracting to /usr/local/go..." + sudo tar -C /usr/local -xzf "$TMP/${ARCHIVE}" + + # Ensure /usr/local/go/bin is in PATH + if [ ! -f "/usr/local/bin/go" ]; then + sudo ln -sf /usr/local/go/bin/go /usr/local/bin/go 2>/dev/null || true + sudo ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt 2>/dev/null || true + fi + else + echo "Failed to download ${DOWNLOAD_URL}" + echo "Please install Go manually from https://go.dev/dl/" fi else - echo "Failed to download ${DOWNLOAD_URL}" - echo "Please install Go manually from https://go.dev/dl/" + echo "Failed to determine latest Go version" + echo "Please install Go from https://go.dev/dl/" fi else - echo "Failed to determine latest Go version" + echo "Failed to fetch Go version information" echo "Please install Go from https://go.dev/dl/" fi - else - echo "Failed to fetch Go version information" - echo "Please install Go from https://go.dev/dl/" + rm -rf "$TMP" 2>/dev/null || true fi - rm -rf "$TMP" 2>/dev/null || true -fi -after="$(get_go_version "$BINARY")" -path="$(command -v "$BINARY" 2>/dev/null || true)" -printf "[%s] before: %s\n" "$DISPLAY_NAME" "${before:-}" -printf "[%s] after: %s\n" "$DISPLAY_NAME" "${after:-}" -if [ -n "$path" ]; then printf "[%s] path: %s\n" "$DISPLAY_NAME" "$path"; fi + local after="$(get_go_version "$BINARY")" + local path="$(command -v "$BINARY" 2>/dev/null || true)" + printf "[%s] before: %s\n" "$DISPLAY_NAME" "${before:-}" + printf "[%s] after: %s\n" "$DISPLAY_NAME" "${after:-}" + if [ -n "$path" ]; then printf "[%s] path: %s\n" "$DISPLAY_NAME" "$path"; fi + + refresh_snapshot "go" +} + +uninstall_go() { + echo "[go] Uninstalling Go..." >&2 -refresh_snapshot "go" + if [ -n "$TARGET_CYCLE" ]; then + # Multi-version: only remove the specific version binary and SDK + local gobin="${GOPATH:-$HOME/go}/bin" + if [ -f "$gobin/$BINARY" ]; then + echo "[go] Removing $gobin/$BINARY" >&2 + rm -f "$gobin/$BINARY" + fi + # Remove SDK for this version + local sdk_dir="$HOME/sdk/go${TARGET_CYCLE}" + if [ -d "$sdk_dir" ]; then + echo "[go] Removing SDK: $sdk_dir" >&2 + rm -rf "$sdk_dir" + fi + else + # Full uninstall: remove Go installation and GOPATH binaries + # Remove /usr/local/go (standard manual install location) + if [ -d "/usr/local/go" ]; then + echo "[go] Removing /usr/local/go..." >&2 + sudo rm -rf /usr/local/go + fi + # Remove symlinks in /usr/local/bin + for link in /usr/local/bin/go /usr/local/bin/gofmt; do + if [ -L "$link" ]; then + sudo rm -f "$link" 2>/dev/null || true + fi + done + # Remove user Go directories + if [ -d "$HOME/go" ]; then + echo "[go] Removing $HOME/go..." >&2 + rm -rf "$HOME/go" + fi + if [ -d "$HOME/.local/go" ]; then + rm -rf "$HOME/.local/go" + fi + # Remove brew-installed go + if have brew && brew list go >/dev/null 2>&1; then + echo "[go] Removing brew-installed go..." >&2 + brew uninstall go || true + fi + # Remove PATH entries from shell configs + for rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile" "$HOME/.bash_profile"; do + if [ -f "$rc" ]; then + sed -i '/\/usr\/local\/go\/bin/d' "$rc" 2>/dev/null || true + sed -i '/GOPATH/d' "$rc" 2>/dev/null || true + fi + done + fi + echo "[go] Uninstall complete" >&2 + refresh_snapshot "go" +} +case "$ACTION" in + install) install_go ;; + update) install_go ;; + uninstall) uninstall_go ;; + *) echo "Usage: $0 {install|update|uninstall}" ; exit 2 ;; +esac diff --git a/scripts/install_parallel.sh b/scripts/install_parallel.sh index b1171ab..11cefe3 100755 --- a/scripts/install_parallel.sh +++ b/scripts/install_parallel.sh @@ -87,13 +87,45 @@ install_parallel() { return 0 } +uninstall_parallel() { + echo "[$TOOL] Uninstalling parallel..." >&2 + + # Remove all parallel-related binaries from install dir + for script in parallel parcat parsort parset niceload sql sem env_parallel; do + if [ -f "$INSTALL_DIR/$script" ]; then + echo "[$TOOL] Removing $INSTALL_DIR/$script" >&2 + rm -f "$INSTALL_DIR/$script" + fi + done + + # Also check command -v in case it was installed elsewhere + local parallel_bin + parallel_bin="$(command -v parallel 2>/dev/null || echo "")" + if [ -n "$parallel_bin" ] && [ -f "$parallel_bin" ]; then + local bin_dir + bin_dir="$(dirname "$parallel_bin")" + echo "[$TOOL] Removing $parallel_bin" >&2 + if [ -w "$bin_dir" ]; then + rm -f "$parallel_bin" + else + echo "[$TOOL] Cannot remove $parallel_bin (no write access), try: sudo rm -f $parallel_bin" >&2 + fi + fi + + echo "[$TOOL] Uninstall complete" >&2 + refresh_snapshot "$TOOL" +} + # Main case "${1:-install}" in install|update) install_parallel "${2:-}" ;; + uninstall) + uninstall_parallel + ;; *) - echo "Usage: $0 [install|update] [version]" >&2 + echo "Usage: $0 [install|update|uninstall] [version]" >&2 exit 1 ;; esac diff --git a/scripts/lib/reconcile.sh b/scripts/lib/reconcile.sh index ea3e3fc..c9f158c 100755 --- a/scripts/lib/reconcile.sh +++ b/scripts/lib/reconcile.sh @@ -94,8 +94,14 @@ remove_installation() { local binary_path binary_path="$(command -v "$binary" 2>/dev/null || echo "")" if [ -n "$binary_path" ] && [ -f "$binary_path" ]; then + local bin_dir + bin_dir="$(dirname "$binary_path")" echo "[$tool] Removing binary: $binary_path" >&2 - rm -f "$binary_path" || true + if [ -w "$bin_dir" ]; then + rm -f "$binary_path" + else + sudo rm -f "$binary_path" + fi fi ;; corepack) diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index fe3650b..617c160 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -620,3 +620,287 @@ def test_fd_candidates_field_preserved_in_catalog_entry(self): assert entry.candidates is not None assert "fdfind" in entry.candidates assert "fd" in entry.candidates + + +# =========================================================================== +# 13. Dedicated scripts: uninstall handler contract (Issue #36) +# =========================================================================== + +# All 14 dedicated scripts from the catalog +DEDICATED_SCRIPTS = [ + "claude", "composer", "docker", "gem", "go", "node", "parallel", + "python", "ruby", "rust", "tmux", "tree", "uv", "yarn", +] + + +@skip_on_windows +class TestDedicatedScriptUninstallHandler: + """Tests that all 14 dedicated scripts handle the uninstall action (Issue #36).""" + + @pytest.mark.parametrize("catalog_name", DEDICATED_SCRIPTS) + def test_dedicated_script_handles_uninstall_action(self, catalog_name): + """Each dedicated script must have an 'uninstall)' case branch.""" + catalog_path = CATALOG_DIR / f"{catalog_name}.json" + if not catalog_path.exists(): + pytest.skip(f"{catalog_name}.json not found") + with open(catalog_path) as f: + data = json.load(f) + script_name = data.get("script", "") + assert script_name, f"{catalog_name}: missing 'script' field" + script_path = SCRIPTS_DIR / script_name + assert script_path.exists(), f"{script_name} not found" + content = script_path.read_text() + assert "uninstall)" in content, ( + f"{script_name} must have an 'uninstall)' case to handle " + f"'install_tool.sh {catalog_name} uninstall'" + ) + + @pytest.mark.parametrize("catalog_name", DEDICATED_SCRIPTS) + def test_dedicated_script_parses_action_arg(self, catalog_name): + """Each dedicated script must parse $1 as an action via case statement.""" + catalog_path = CATALOG_DIR / f"{catalog_name}.json" + if not catalog_path.exists(): + pytest.skip(f"{catalog_name}.json not found") + with open(catalog_path) as f: + data = json.load(f) + script_name = data.get("script", "") + script_path = SCRIPTS_DIR / script_name + content = script_path.read_text() + # Script must either set ACTION="${1:-...}" or use case "${1:-...}" + has_action_var = 'ACTION="${1:-' in content or "ACTION=\"${1:-" in content + has_inline_case = 'case "${1:-' in content or "case \"${1:-" in content + assert has_action_var or has_inline_case, ( + f"{script_name} must parse $1 as action " + f"(use ACTION=\"${{1:-install}}\" or case \"${{1:-install}}\")" + ) + + def test_install_go_uninstall_does_not_download(self): + """install_go.sh uninstall must NOT trigger any downloads (curl/wget).""" + script_path = SCRIPTS_DIR / "install_go.sh" + # Run with uninstall action in a sandboxed environment + # Override PATH to remove real go/curl/wget and use stubs that fail loudly + with tempfile.TemporaryDirectory() as tmpdir: + # Create stub binaries that record invocations + marker = Path(tmpdir) / "download_called" + for cmd in ("curl", "wget"): + stub = Path(tmpdir) / cmd + stub.write_text( + f"#!/bin/sh\ntouch '{marker}'\n" + f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" + f"exit 1\n" + ) + stub.chmod(0o755) + + # Create a fake go binary so the script thinks go is installed + fake_go = Path(tmpdir) / "go" + fake_go.write_text("#!/bin/sh\necho 'go version go1.25.0 linux/amd64'\n") + fake_go.chmod(0o755) + + # Run the script with uninstall action + env = os.environ.copy() + # Put our stubs first in PATH, but keep bash/coreutils accessible + env["PATH"] = f"{tmpdir}:/usr/bin:/bin" + env["HOME"] = tmpdir + result = subprocess.run( + ["bash", str(script_path), "uninstall"], + capture_output=True, text=True, timeout=10, + env=env, + ) + assert not marker.exists(), ( + f"install_go.sh uninstall triggered a download! " + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + def test_install_composer_uninstall_does_not_download(self): + """install_composer.sh uninstall must NOT trigger any downloads.""" + script_path = SCRIPTS_DIR / "install_composer.sh" + with tempfile.TemporaryDirectory() as tmpdir: + marker = Path(tmpdir) / "download_called" + for cmd in ("curl", "wget"): + stub = Path(tmpdir) / cmd + stub.write_text( + f"#!/bin/sh\ntouch '{marker}'\n" + f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" + f"exit 1\n" + ) + stub.chmod(0o755) + + # Create a fake composer binary + fake_composer = Path(tmpdir) / "composer" + fake_composer.write_text("#!/bin/sh\necho 'Composer version 2.8.0'\n") + fake_composer.chmod(0o755) + + env = os.environ.copy() + env["PATH"] = f"{tmpdir}:/usr/bin:/bin" + env["HOME"] = tmpdir + result = subprocess.run( + ["bash", str(script_path), "uninstall"], + capture_output=True, text=True, timeout=10, + env=env, + ) + assert not marker.exists(), ( + f"install_composer.sh uninstall triggered a download! " + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + def test_install_docker_uninstall_does_not_download(self): + """install_docker.sh uninstall must NOT trigger any downloads.""" + script_path = SCRIPTS_DIR / "install_docker.sh" + with tempfile.TemporaryDirectory() as tmpdir: + marker = Path(tmpdir) / "download_called" + for cmd in ("curl", "wget"): + stub = Path(tmpdir) / cmd + stub.write_text( + f"#!/bin/sh\ntouch '{marker}'\n" + f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" + f"exit 1\n" + ) + stub.chmod(0o755) + + env = os.environ.copy() + env["PATH"] = f"{tmpdir}:/usr/bin:/bin" + env["HOME"] = tmpdir + result = subprocess.run( + ["bash", str(script_path), "uninstall"], + capture_output=True, text=True, timeout=10, + env=env, + ) + assert not marker.exists(), ( + f"install_docker.sh uninstall triggered a download! " + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + def test_install_parallel_uninstall_does_not_download(self): + """install_parallel.sh uninstall must NOT trigger any downloads.""" + script_path = SCRIPTS_DIR / "install_parallel.sh" + with tempfile.TemporaryDirectory() as tmpdir: + marker = Path(tmpdir) / "download_called" + for cmd in ("curl", "wget"): + stub = Path(tmpdir) / cmd + stub.write_text( + f"#!/bin/sh\ntouch '{marker}'\n" + f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" + f"exit 1\n" + ) + stub.chmod(0o755) + + # Create a fake parallel binary + fake_parallel = Path(tmpdir) / "parallel" + fake_parallel.write_text("#!/bin/sh\necho 'GNU parallel 20250122'\n") + fake_parallel.chmod(0o755) + + env = os.environ.copy() + env["PATH"] = f"{tmpdir}:/usr/bin:/bin" + env["HOME"] = tmpdir + result = subprocess.run( + ["bash", str(script_path), "uninstall"], + capture_output=True, text=True, timeout=10, + env=env, + ) + assert not marker.exists(), ( + f"install_parallel.sh uninstall triggered a download! " + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +# =========================================================================== +# 14. reconcile.sh: sudo-aware removal (Issue #37) +# =========================================================================== + +@skip_on_windows +class TestReconcileSudoAwareRemoval: + """Tests for sudo-aware binary removal in reconcile.sh (Issue #37).""" + + def _source_and_run(self, bash_code: str) -> subprocess.CompletedProcess: + """Source reconcile.sh and run bash code.""" + full_code = f""" +set -euo pipefail +source "{SCRIPTS_DIR}/lib/reconcile.sh" +{bash_code} +""" + return subprocess.run( + ["bash", "-c", full_code], + capture_output=True, text=True, timeout=10, + ) + + def test_remove_installation_checks_writability(self): + """reconcile.sh should check directory writability before removal.""" + content = (SCRIPTS_DIR / "lib" / "reconcile.sh").read_text() + # The github_release_binary|manual case should check writability + assert "-w " in content or "-w \"" in content, ( + "reconcile.sh remove_installation() must check writability " + "with [ -w ] before removing binaries" + ) + + def test_remove_installation_no_sudo_for_writable_dir(self): + """remove_installation should NOT use sudo for writable directories.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a fake binary in a writable directory + fake_bin = Path(tmpdir) / "fake_tool" + fake_bin.write_text("#!/bin/sh\necho fake") + fake_bin.chmod(0o755) + + # Create a sudo stub that records calls + sudo_marker = Path(tmpdir) / "sudo_called" + sudo_stub = Path(tmpdir) / "sudo" + sudo_stub.write_text( + f"#!/bin/sh\ntouch '{sudo_marker}'\nexec \"$@\"\n" + ) + sudo_stub.chmod(0o755) + + result = self._source_and_run(f""" +export PATH="{tmpdir}:$PATH" +remove_installation "fake_tool" "manual" "fake_tool" +""") + assert result.returncode == 0, f"removal failed: {result.stderr}" + assert not fake_bin.exists(), "Binary should have been removed" + assert not sudo_marker.exists(), ( + "sudo should NOT be used for writable directories" + ) + + def test_remove_installation_uses_sudo_for_nonwritable_dir(self): + """remove_installation should use sudo for non-writable directories.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a subdirectory that we'll make non-writable + restricted_dir = Path(tmpdir) / "restricted" + restricted_dir.mkdir() + + # Create a fake binary + fake_bin = restricted_dir / "fake_tool" + fake_bin.write_text("#!/bin/sh\necho fake") + fake_bin.chmod(0o755) + + # Create a sudo stub that records calls + # Note: the stub can't actually remove the file (no real elevated + # permissions), so exit code may be non-zero. We only check that + # sudo was invoked. + sudo_marker = Path(tmpdir) / "sudo_called" + sudo_stub = Path(tmpdir) / "sudo" + sudo_stub.write_text( + f"#!/bin/sh\ntouch '{sudo_marker}'\nexec \"$@\"\n" + ) + sudo_stub.chmod(0o755) + + # Make the directory non-writable + restricted_dir.chmod(0o555) + + try: + self._source_and_run(f""" +export PATH="{restricted_dir}:{tmpdir}:$PATH" +remove_installation "fake_tool" "github_release_binary" "fake_tool" || true +""") + assert sudo_marker.exists(), ( + "sudo SHOULD be used for non-writable directories like /usr/local/bin" + ) + finally: + # Restore permissions for cleanup + restricted_dir.chmod(0o755) + + def test_remove_installation_github_release_writability_check(self): + """github_release_binary case must check bin_dir writability.""" + content = (SCRIPTS_DIR / "lib" / "reconcile.sh").read_text() + # Find the github_release_binary|manual case and verify it has writability logic + # The pattern should be: check -w on the directory, then use sudo if not writable + assert "sudo rm" in content, ( + "reconcile.sh must use 'sudo rm' as fallback for non-writable dirs" + ) From 6981113fb7dc039149ffa39c4bc9e4ee769fdc96 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 25 Feb 2026 15:28:18 +0100 Subject: [PATCH 2/4] fix: address review feedback for uninstall handlers - Add sudo availability check in reconcile.sh remove_installation - Narrow sed patterns in install_go.sh to only match export lines - Add sudo check before rm -rf in install_go.sh uninstall - Add test for graceful error when sudo is unavailable Signed-off-by: Sebastian Mendel --- scripts/install_go.sh | 18 ++++++++++++++---- scripts/lib/reconcile.sh | 5 ++++- tests/test_update_fixes.py | 26 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/scripts/install_go.sh b/scripts/install_go.sh index 62c349d..15f2928 100755 --- a/scripts/install_go.sh +++ b/scripts/install_go.sh @@ -170,12 +170,22 @@ uninstall_go() { # Remove /usr/local/go (standard manual install location) if [ -d "/usr/local/go" ]; then echo "[go] Removing /usr/local/go..." >&2 - sudo rm -rf /usr/local/go + if [ -w "/usr/local" ]; then + rm -rf /usr/local/go + elif command -v sudo >/dev/null 2>&1; then + sudo rm -rf /usr/local/go + else + echo "[go] Error: Cannot remove /usr/local/go (no write access and sudo not available)" >&2 + fi fi # Remove symlinks in /usr/local/bin for link in /usr/local/bin/go /usr/local/bin/gofmt; do if [ -L "$link" ]; then - sudo rm -f "$link" 2>/dev/null || true + if [ -w "/usr/local/bin" ]; then + rm -f "$link" 2>/dev/null || true + elif command -v sudo >/dev/null 2>&1; then + sudo rm -f "$link" 2>/dev/null || true + fi fi done # Remove user Go directories @@ -194,8 +204,8 @@ uninstall_go() { # Remove PATH entries from shell configs for rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile" "$HOME/.bash_profile"; do if [ -f "$rc" ]; then - sed -i '/\/usr\/local\/go\/bin/d' "$rc" 2>/dev/null || true - sed -i '/GOPATH/d' "$rc" 2>/dev/null || true + sed -i '/^[[:space:]]*export.*\/usr\/local\/go\/bin/d' "$rc" 2>/dev/null || true + sed -i '/^export GOPATH=/d' "$rc" 2>/dev/null || true fi done fi diff --git a/scripts/lib/reconcile.sh b/scripts/lib/reconcile.sh index c9f158c..a4c2e08 100755 --- a/scripts/lib/reconcile.sh +++ b/scripts/lib/reconcile.sh @@ -99,8 +99,11 @@ remove_installation() { echo "[$tool] Removing binary: $binary_path" >&2 if [ -w "$bin_dir" ]; then rm -f "$binary_path" - else + elif command -v sudo >/dev/null 2>&1; then sudo rm -f "$binary_path" + else + echo "[$tool] Error: Cannot remove $binary_path (no write access and sudo not available)" >&2 + return 1 fi fi ;; diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 617c160..a420fda 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -896,6 +896,32 @@ def test_remove_installation_uses_sudo_for_nonwritable_dir(self): # Restore permissions for cleanup restricted_dir.chmod(0o755) + def test_remove_installation_error_when_no_sudo_and_nonwritable(self): + """remove_installation should error gracefully when dir is not writable and sudo is unavailable.""" + with tempfile.TemporaryDirectory() as tmpdir: + fake_bin = Path(tmpdir) / "fake_tool" + fake_bin.write_text("#!/bin/sh\necho fake") + fake_bin.chmod(0o755) + os.chmod(tmpdir, 0o555) + try: + result = subprocess.run( + ["bash", "-c", f""" + source scripts/lib/reconcile.sh 2>/dev/null || source scripts/lib/common.sh + source scripts/lib/reconcile.sh + export PATH="{tmpdir}:$PATH" + # Hide sudo + sudo() {{ return 127; }} + export -f sudo + remove_installation "fake_tool" "manual" "fake_tool" 2>&1 + """], + capture_output=True, text=True, timeout=10, + cwd=str(Path(__file__).parent.parent), + ) + combined = result.stdout + result.stderr + assert "no write access" in combined.lower() or "sudo not available" in combined.lower() or result.returncode != 0 + finally: + os.chmod(tmpdir, 0o755) + def test_remove_installation_github_release_writability_check(self): """github_release_binary case must check bin_dir writability.""" content = (SCRIPTS_DIR / "lib" / "reconcile.sh").read_text() From c0223cedc297533c5738212d5ff8cc1cc94df801 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 25 Feb 2026 15:36:00 +0100 Subject: [PATCH 3/4] fix(tests): use 0o700 permissions to satisfy CodeQL Change test file permissions from 0o755 (world-executable) to 0o700 (owner-only) in all tests added by this PR. The bash subprocess runs as the same user, so group/other permissions are unnecessary. This resolves a CodeQL "overly permissive file permissions" false positive. Signed-off-by: Sebastian Mendel --- tests/test_update_fixes.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index a420fda..cb3bf9e 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -689,12 +689,12 @@ def test_install_go_uninstall_does_not_download(self): f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" f"exit 1\n" ) - stub.chmod(0o755) + stub.chmod(0o700) # Create a fake go binary so the script thinks go is installed fake_go = Path(tmpdir) / "go" fake_go.write_text("#!/bin/sh\necho 'go version go1.25.0 linux/amd64'\n") - fake_go.chmod(0o755) + fake_go.chmod(0o700) # Run the script with uninstall action env = os.environ.copy() @@ -723,12 +723,12 @@ def test_install_composer_uninstall_does_not_download(self): f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" f"exit 1\n" ) - stub.chmod(0o755) + stub.chmod(0o700) # Create a fake composer binary fake_composer = Path(tmpdir) / "composer" fake_composer.write_text("#!/bin/sh\necho 'Composer version 2.8.0'\n") - fake_composer.chmod(0o755) + fake_composer.chmod(0o700) env = os.environ.copy() env["PATH"] = f"{tmpdir}:/usr/bin:/bin" @@ -755,7 +755,7 @@ def test_install_docker_uninstall_does_not_download(self): f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" f"exit 1\n" ) - stub.chmod(0o755) + stub.chmod(0o700) env = os.environ.copy() env["PATH"] = f"{tmpdir}:/usr/bin:/bin" @@ -782,12 +782,12 @@ def test_install_parallel_uninstall_does_not_download(self): f"echo 'ERROR: {cmd} should not be called during uninstall' >&2\n" f"exit 1\n" ) - stub.chmod(0o755) + stub.chmod(0o700) # Create a fake parallel binary fake_parallel = Path(tmpdir) / "parallel" fake_parallel.write_text("#!/bin/sh\necho 'GNU parallel 20250122'\n") - fake_parallel.chmod(0o755) + fake_parallel.chmod(0o700) env = os.environ.copy() env["PATH"] = f"{tmpdir}:/usr/bin:/bin" @@ -838,7 +838,7 @@ def test_remove_installation_no_sudo_for_writable_dir(self): # Create a fake binary in a writable directory fake_bin = Path(tmpdir) / "fake_tool" fake_bin.write_text("#!/bin/sh\necho fake") - fake_bin.chmod(0o755) + fake_bin.chmod(0o700) # Create a sudo stub that records calls sudo_marker = Path(tmpdir) / "sudo_called" @@ -846,7 +846,7 @@ def test_remove_installation_no_sudo_for_writable_dir(self): sudo_stub.write_text( f"#!/bin/sh\ntouch '{sudo_marker}'\nexec \"$@\"\n" ) - sudo_stub.chmod(0o755) + sudo_stub.chmod(0o700) result = self._source_and_run(f""" export PATH="{tmpdir}:$PATH" @@ -868,7 +868,7 @@ def test_remove_installation_uses_sudo_for_nonwritable_dir(self): # Create a fake binary fake_bin = restricted_dir / "fake_tool" fake_bin.write_text("#!/bin/sh\necho fake") - fake_bin.chmod(0o755) + fake_bin.chmod(0o700) # Create a sudo stub that records calls # Note: the stub can't actually remove the file (no real elevated @@ -879,7 +879,7 @@ def test_remove_installation_uses_sudo_for_nonwritable_dir(self): sudo_stub.write_text( f"#!/bin/sh\ntouch '{sudo_marker}'\nexec \"$@\"\n" ) - sudo_stub.chmod(0o755) + sudo_stub.chmod(0o700) # Make the directory non-writable restricted_dir.chmod(0o555) @@ -894,14 +894,14 @@ def test_remove_installation_uses_sudo_for_nonwritable_dir(self): ) finally: # Restore permissions for cleanup - restricted_dir.chmod(0o755) + restricted_dir.chmod(0o700) def test_remove_installation_error_when_no_sudo_and_nonwritable(self): """remove_installation should error gracefully when dir is not writable and sudo is unavailable.""" with tempfile.TemporaryDirectory() as tmpdir: fake_bin = Path(tmpdir) / "fake_tool" fake_bin.write_text("#!/bin/sh\necho fake") - fake_bin.chmod(0o755) + fake_bin.chmod(0o700) os.chmod(tmpdir, 0o555) try: result = subprocess.run( @@ -920,7 +920,7 @@ def test_remove_installation_error_when_no_sudo_and_nonwritable(self): combined = result.stdout + result.stderr assert "no write access" in combined.lower() or "sudo not available" in combined.lower() or result.returncode != 0 finally: - os.chmod(tmpdir, 0o755) + os.chmod(tmpdir, 0o700) def test_remove_installation_github_release_writability_check(self): """github_release_binary case must check bin_dir writability.""" From b24bc6b23163060688def31075d682201a5fddb8 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Wed, 25 Feb 2026 15:42:29 +0100 Subject: [PATCH 4/4] fix(tests): use 0o500 for non-writable dirs to satisfy CodeQL Signed-off-by: Sebastian Mendel --- tests/test_update_fixes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index cb3bf9e..fbddb76 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -882,7 +882,7 @@ def test_remove_installation_uses_sudo_for_nonwritable_dir(self): sudo_stub.chmod(0o700) # Make the directory non-writable - restricted_dir.chmod(0o555) + restricted_dir.chmod(0o500) try: self._source_and_run(f""" @@ -902,7 +902,7 @@ def test_remove_installation_error_when_no_sudo_and_nonwritable(self): fake_bin = Path(tmpdir) / "fake_tool" fake_bin.write_text("#!/bin/sh\necho fake") fake_bin.chmod(0o700) - os.chmod(tmpdir, 0o555) + os.chmod(tmpdir, 0o500) try: result = subprocess.run( ["bash", "-c", f"""