From 080c93d450bbfccaed1f1510bdaae9e42539f06f Mon Sep 17 00:00:00 2001 From: yangsec888 Date: Tue, 24 Mar 2026 18:01:43 -0400 Subject: [PATCH] feat: add OpenClaw removal scripts with tests Add cross-platform removal scripts (remove-openclaw.sh, remove-openclaw.ps1) that uninstall OpenClaw by stopping services, killing processes, removing packages, binaries, app bundles, Docker artifacts, and state directories. Includes dry-run mode, data preservation option, and MDM exit codes. Also adds bats and Pester test suites, updates CI to run removal scripts and tests, and updates README with removal docs. --- .github/workflows/test-scripts.yml | 25 +- README.md | 86 ++++++- remove-openclaw.ps1 | 331 ++++++++++++++++++++++++++ remove-openclaw.sh | 359 +++++++++++++++++++++++++++++ tests/remove-openclaw.Tests.ps1 | 353 ++++++++++++++++++++++++++++ tests/remove-openclaw.bats | 311 +++++++++++++++++++++++++ 6 files changed, 1458 insertions(+), 7 deletions(-) create mode 100644 remove-openclaw.ps1 create mode 100755 remove-openclaw.sh create mode 100644 tests/remove-openclaw.Tests.ps1 create mode 100644 tests/remove-openclaw.bats diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml index 20f65e5..0d830a3 100644 --- a/.github/workflows/test-scripts.yml +++ b/.github/workflows/test-scripts.yml @@ -1,4 +1,4 @@ -name: Test Detection Scripts +name: Test Scripts on: push: @@ -14,15 +14,38 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - name: Run detection script run: | chmod +x detect-openclaw.sh ./detect-openclaw.sh + - name: Run removal script + run: | + chmod +x remove-openclaw.sh + ./remove-openclaw.sh + + - name: Install bats-core + run: | + git clone --depth 1 https://github.com/bats-core/bats-core.git /tmp/bats-core + sudo /tmp/bats-core/install.sh /usr/local + + - name: Run removal tests + run: bats tests/remove-openclaw.bats + test-windows: runs-on: windows-latest steps: - uses: actions/checkout@v4 + - name: Run detection script run: .\detect-openclaw.ps1 shell: powershell + + - name: Run removal script + run: .\remove-openclaw.ps1 + shell: powershell + + - name: Run removal tests + run: Invoke-Pester -Path tests\remove-openclaw.Tests.ps1 -Output Detailed -CI + shell: powershell diff --git a/README.md b/README.md index e3bbc08..0696cf6 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Also check out: --- -# OpenClaw Detection Scripts - TL;DR +# OpenClaw Detection & Removal Scripts - TL;DR -Detection scripts for MDM deployment to identify OpenClaw installations on managed devices. +Detection scripts for MDM deployment to identify OpenClaw installations on managed devices, plus removal scripts to uninstall them. ## What It Detects @@ -47,39 +47,88 @@ Detection scripts for MDM deployment to identify OpenClaw installations on manag ## Usage -### macOS/Linux +### Detection + +#### macOS/Linux ```bash curl -sL https://raw.githubusercontent.com/knostic/openclaw-detect/refs/heads/main/detect-openclaw.sh | bash ``` -### Windows (PowerShell) +#### Windows (PowerShell) ```powershell iwr -useb https://raw.githubusercontent.com/knostic/openclaw-detect/refs/heads/main/detect-openclaw.ps1 | iex ``` -### Without curl +#### Without curl Copy [`detect-openclaw.sh`](detect-openclaw.sh) (macOS/Linux) or [`detect-openclaw.ps1`](detect-openclaw.ps1) (Windows) and run directly. +### Removal + +#### macOS/Linux + +```bash +curl -sL https://raw.githubusercontent.com/knostic/openclaw-detect/refs/heads/main/remove-openclaw.sh | sudo bash +``` + +#### Windows (PowerShell, as Administrator) + +```powershell +iwr -useb https://raw.githubusercontent.com/knostic/openclaw-detect/refs/heads/main/remove-openclaw.ps1 | iex +``` + +#### Without curl + +Copy [`remove-openclaw.sh`](remove-openclaw.sh) (macOS/Linux) or [`remove-openclaw.ps1`](remove-openclaw.ps1) (Windows) and run directly. + ### Run as root/admin -Running with elevated privileges scans all user directories: +Running with elevated privileges scans and acts on all user directories: ```bash +# Detection curl -sL https://raw.githubusercontent.com/knostic/openclaw-detect/refs/heads/main/detect-openclaw.sh | sudo bash + +# Removal +curl -sL https://raw.githubusercontent.com/knostic/openclaw-detect/refs/heads/main/remove-openclaw.sh | sudo bash ``` +## What It Removes + +The removal scripts perform a phased cleanup in this order: + +1. **Stop services** — launchd (macOS), systemd (Linux), scheduled tasks (Windows) +2. **Kill gateway processes** — on default and configured ports +3. **Docker** — stop/remove containers and images matching `openclaw` +4. **Package managers** — brew, npm, volta (macOS/Linux); scoop, npm, winget (Windows) +5. **CLI binaries** — global and per-user install locations +6. **macOS app bundle** — `/Applications/OpenClaw.app` +7. **State directories** — `~/.openclaw` (or profile variant) unless `OPENCLAW_KEEP_DATA=1` +8. **WSL** (Windows only) — openclaw binary inside WSL + +### Removal Exit Codes + +| Exit Code | Meaning | MDM Status | +|-----------|---------|------------| +| 0 | All removed (or nothing to remove) | Success (clean) | +| 1 | Partial removal (some items failed) | Error (investigate) | +| 2 | Script error | Error (investigate) | + ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `OPENCLAW_PROFILE` | (none) | Profile name for multi-instance setups | | `OPENCLAW_GATEWAY_PORT` | 18789 | Gateway port to check | +| `OPENCLAW_KEEP_DATA` | 0 | Set to `1` to preserve state directories during removal | +| `OPENCLAW_DRY_RUN` | 0 | Set to `1` to log removal actions without performing them | ## Example Output +### Detection + ``` summary: installed-and-running platform: darwin @@ -94,6 +143,31 @@ docker-container: not-found docker-image: not-found ``` +### Removal + +``` +result: all-removed +platform: darwin +removed: launchd service gui/501/bot.molt.gateway +removed: kill gateway process pid=12345 on port 18789 +removed: brew uninstall openclaw +removed: binary /usr/local/bin/openclaw +removed: macOS app bundle /Applications/OpenClaw.app +removed: state-dir /Users/alice/.openclaw +``` + +### Removal (dry run) + +``` +result: nothing-to-remove +platform: darwin +mode: dry-run +dry-run: launchd service gui/501/bot.molt.gateway +dry-run: brew uninstall openclaw +dry-run: binary /usr/local/bin/openclaw +dry-run: state-dir /Users/alice/.openclaw +``` + --- ## MDM Integration diff --git a/remove-openclaw.ps1 b/remove-openclaw.ps1 new file mode 100644 index 0000000..1e71f0a --- /dev/null +++ b/remove-openclaw.ps1 @@ -0,0 +1,331 @@ +# OpenClaw Removal Script for MDM deployment (Windows) +# Exit codes: 0=all-removed/nothing-to-remove, 1=partial, 2=error + +$ErrorActionPreference = "Stop" + +$script:Profile = $env:OPENCLAW_PROFILE +$Port = if ($env:OPENCLAW_GATEWAY_PORT) { [int]$env:OPENCLAW_GATEWAY_PORT } else { 18789 } +$script:KeepData = if ($env:OPENCLAW_KEEP_DATA) { $env:OPENCLAW_KEEP_DATA } else { "0" } +$script:DryRun = if ($env:OPENCLAW_DRY_RUN) { $env:OPENCLAW_DRY_RUN } else { "0" } + +if ($script:Profile -and $script:Profile -notmatch '^[A-Za-z0-9_-]{1,64}$') { + Write-Output "result: error" + Write-Output "error-detail: invalid OPENCLAW_PROFILE value" + exit 2 +} + +$script:RemovedCount = 0 +$script:SkippedCount = 0 +$script:ErrorCount = 0 +$script:Output = [System.Collections.ArrayList]::new() + +function Show-Banner { + $banner = @" + + _ ___ _ ___ ___ _____ ___ ___ + | |/ / \| |/ _ \/ __|_ _|_ _/ __| + | ' <| . | (_) \__ \ | | | | (__ + |_|\_\_|\_|\___/|___/ |_| |___\___| + + Open source from Knostic - https://knostic.ai + OpenClaw Removal Script + +"@ + Write-Output $banner +} + +Show-Banner + +function Out { + param([string]$Line) + [void]$script:Output.Add($Line) +} + +function Invoke-OrDry { + param([string]$Description, [scriptblock]$Action) + if ($script:DryRun -eq "1") { + Out "dry-run: $Description" + $script:SkippedCount++ + return $true + } + try { + & $Action 2>$null + Out "removed: $Description" + $script:RemovedCount++ + return $true + } catch { + Out "error: $Description" + $script:ErrorCount++ + return $false + } +} + +function Test-IsAdmin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-UsersToCheck { + if (Test-IsAdmin) { + Get-ChildItem "C:\Users" -Directory | + Where-Object { $_.Name -notin @('Public', 'Default', 'Default User', 'All Users') } | + ForEach-Object { $_.Name } + } else { + $env:USERNAME + } +} + +function Get-HomeDir { + param([string]$User) + return "C:\Users\$User" +} + +function Get-StateDir { + param([string]$HomeDir) + if ($script:Profile) { + return Join-Path $HomeDir ".openclaw-$($script:Profile)" + } + return Join-Path $HomeDir ".openclaw" +} + +function Get-ConfiguredPort { + param([string]$ConfigFile) + if (Test-Path $ConfigFile) { + try { + $content = Get-Content $ConfigFile -Raw + if ($content -match '"port"\s*:\s*(\d+)') { + return [int]$matches[1] + } + } catch {} + } + return $null +} + +# -- Scheduled task removal ----------------------------------------------- + +function Remove-OpenClawScheduledTask { + $taskName = if ($script:Profile) { "OpenClaw Gateway $($script:Profile)" } else { "OpenClaw Gateway" } + try { + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($task) { + Invoke-OrDry "scheduled task '$taskName'" { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false + } + } + } catch {} +} + +# -- Kill gateway process on port ----------------------------------------- + +function Stop-GatewayOnPort { + param([int]$PortNum) + try { + $connections = Get-NetTCPConnection -LocalPort $PortNum -ErrorAction SilentlyContinue + foreach ($conn in $connections) { + if ($conn.OwningProcess -and $conn.OwningProcess -ne 0) { + Invoke-OrDry "kill gateway pid=$($conn.OwningProcess) on port $PortNum" { + Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue + } + } + } + } catch {} +} + +# -- Docker removal ------------------------------------------------------- + +function Remove-DockerContainers { + try { + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { return } + $ids = docker ps --filter "name=openclaw" -q 2>$null + foreach ($cid in $ids) { + if ($cid) { + Invoke-OrDry "docker container $cid" { + docker stop $cid 2>$null + docker rm $cid 2>$null + } + } + } + } catch {} +} + +function Remove-DockerImages { + try { + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { return } + $ids = docker images --filter "reference=*openclaw*" -q 2>$null + foreach ($iid in $ids) { + if ($iid) { + Invoke-OrDry "docker image $iid" { + docker rmi $iid 2>$null + } + } + } + } catch {} +} + +# -- Package manager uninstall -------------------------------------------- + +function Uninstall-ViaPackageManagers { + try { + if ((Get-Command scoop -ErrorAction SilentlyContinue) -and (scoop list 2>$null | Select-String "openclaw")) { + Invoke-OrDry "scoop uninstall openclaw" { scoop uninstall openclaw 2>$null } + } + } catch {} + try { + if ((Get-Command npm -ErrorAction SilentlyContinue) -and (npm ls -g openclaw --depth=0 2>$null | Select-String "openclaw")) { + Invoke-OrDry "npm uninstall -g openclaw" { npm uninstall -g openclaw 2>$null } + } + } catch {} + try { + if (Get-Command winget -ErrorAction SilentlyContinue) { + $found = winget list openclaw 2>$null | Select-String "openclaw" + if ($found) { + Invoke-OrDry "winget uninstall openclaw" { winget uninstall openclaw --silent 2>$null } + } + } + } catch {} +} + +# -- Binary removal ------------------------------------------------------- + +function Remove-CliBinary { + param([string]$Path) + if (Test-Path $Path) { + Invoke-OrDry "binary $Path" { Remove-Item -Path $Path -Force } + } +} + +function Remove-CliBinariesGlobal { + $locations = @( + "C:\Program Files\openclaw\openclaw.exe", + "C:\Program Files (x86)\openclaw\openclaw.exe" + ) + foreach ($loc in $locations) { + Remove-CliBinary $loc + } +} + +function Remove-CliBinariesUser { + param([string]$HomeDir) + $locations = @( + (Join-Path $HomeDir "AppData\Local\Programs\openclaw\openclaw.exe"), + (Join-Path $HomeDir "AppData\Roaming\npm\openclaw.cmd"), + (Join-Path $HomeDir "AppData\Local\pnpm\openclaw.cmd"), + (Join-Path $HomeDir ".volta\bin\openclaw.exe"), + (Join-Path $HomeDir "scoop\shims\openclaw.exe") + ) + foreach ($loc in $locations) { + Remove-CliBinary $loc + } +} + +# -- State directory removal ---------------------------------------------- + +function Remove-StateDir { + param([string]$StateDir) + if (-not (Test-Path $StateDir -PathType Container)) { return } + if ($script:KeepData -eq "1") { + Out "skipped-state-dir: $StateDir" + $script:SkippedCount++ + return + } + Invoke-OrDry "state-dir $StateDir" { Remove-Item -Path $StateDir -Recurse -Force } +} + +# -- WSL removal ---------------------------------------------------------- + +function Remove-WslOpenclaw { + try { + if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { return } + $wslPath = (wsl -e which openclaw 2>$null) + if ($wslPath) { + $wslPath = $wslPath.Trim() + Invoke-OrDry "WSL openclaw at $wslPath" { + wsl -e rm -f -- $wslPath 2>$null + } + } + } catch {} +} + +# -- Main ----------------------------------------------------------------- + +function Main { + Out "platform: windows" + + if ($script:DryRun -eq "1") { Out "mode: dry-run" } + if ($script:KeepData -eq "1") { Out "keep-data: true" } + + $users = @(Get-UsersToCheck) + $portsToCheck = @($Port) + + foreach ($user in $users) { + $homeDir = Get-HomeDir $user + $stateDir = Get-StateDir $homeDir + $configFile = Join-Path $stateDir "openclaw.json" + $configPort = Get-ConfiguredPort $configFile + if ($configPort) { $portsToCheck += $configPort } + } + + # Phase 1: Scheduled tasks + Remove-OpenClawScheduledTask + + # Phase 2: Kill gateway processes + $uniquePorts = $portsToCheck | Sort-Object -Unique + foreach ($p in $uniquePorts) { + Stop-GatewayOnPort $p + } + + # Phase 3: Docker + Remove-DockerContainers + Remove-DockerImages + + # Phase 4: Package managers + Uninstall-ViaPackageManagers + + # Phase 5: Binaries + Remove-CliBinariesGlobal + foreach ($user in $users) { + $homeDir = Get-HomeDir $user + Remove-CliBinariesUser $homeDir + } + + # Phase 6: State directories + foreach ($user in $users) { + $homeDir = Get-HomeDir $user + $stateDir = Get-StateDir $homeDir + Remove-StateDir $stateDir + } + + # Phase 7: WSL + Remove-WslOpenclaw + + # Also remove CLI found via PATH at unexpected locations + try { + $pathCli = (Get-Command openclaw -ErrorAction SilentlyContinue).Source + if ($pathCli) { Remove-CliBinary $pathCli } + } catch {} + + # -- Result ------------------------------------------------------------- + $total = $script:RemovedCount + $script:SkippedCount + $script:ErrorCount + if ($script:ErrorCount -gt 0) { + Write-Output "result: partial" + $script:Output | ForEach-Object { Write-Output $_ } + exit 1 + } elseif ($total -eq 0) { + Write-Output "result: nothing-to-remove" + $script:Output | ForEach-Object { Write-Output $_ } + exit 0 + } else { + Write-Output "result: all-removed" + $script:Output | ForEach-Object { Write-Output $_ } + exit 0 + } +} + +try { + Main +} catch { + Write-Output "result: error" + Write-Output "error-detail: $_" + exit 2 +} diff --git a/remove-openclaw.sh b/remove-openclaw.sh new file mode 100755 index 0000000..22c4ab0 --- /dev/null +++ b/remove-openclaw.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +# openclaw removal script for mdm deployment +# exit codes: 0=all-removed/nothing-to-remove, 1=partial, 2=error + +set -uo pipefail + +PROFILE="${OPENCLAW_PROFILE:-}" +PORT="${OPENCLAW_GATEWAY_PORT:-18789}" +KEEP_DATA="${OPENCLAW_KEEP_DATA:-0}" +DRY_RUN="${OPENCLAW_DRY_RUN:-0}" + +if [[ -n "$PROFILE" && ! "$PROFILE" =~ ^[A-Za-z0-9_-]{1,64}$ ]]; then + echo "result: error" + echo "error-detail: invalid OPENCLAW_PROFILE value" + exit 2 +fi + +print_banner() { + echo '' + echo ' _ ___ _ ___ ___ _____ ___ ___' + echo ' | |/ / \| |/ _ \/ __|_ _|_ _/ __|' + echo ' | <| . | (_) \__ \ | | | | (__ ' + echo ' |_|\_\_|\_|\___/|___/ |_| |___\___|' + echo '' + echo ' Open source from Knostic - https://knostic.ai' + echo ' OpenClaw Removal Script' + echo '' +} + +print_banner + +declare -i REMOVED_COUNT=0 +declare -i SKIPPED_COUNT=0 +declare -i ERROR_COUNT=0 +output="" + +out() { output+="$1"$'\n'; } + +do_or_dry() { + local description="$1" + shift + if [[ "$DRY_RUN" == "1" ]]; then + out "dry-run: $description" + ((SKIPPED_COUNT++)) + return 0 + fi + if "$@" 2>/dev/null; then + out "removed: $description" + ((REMOVED_COUNT++)) + return 0 + else + out "error: $description" + ((ERROR_COUNT++)) + return 1 + fi +} + +detect_platform() { + case "$(uname -s)" in + Darwin) echo "darwin" ;; + Linux) echo "linux" ;; + *) echo "unknown" ;; + esac +} + +get_users_to_check() { + local platform="$1" + if [[ $EUID -eq 0 ]]; then + case "$platform" in + darwin) + for dir in /Users/*; do + [[ -d "$dir" && "$(basename "$dir")" != "Shared" ]] && basename "$dir" + done + ;; + linux) + for dir in /home/*; do + [[ -d "$dir" ]] && basename "$dir" + done + ;; + esac + else + whoami + fi +} + +get_home_dir() { + local user="$1" platform="$2" + case "$platform" in + darwin) echo "/Users/$user" ;; + linux) echo "/home/$user" ;; + esac +} + +get_uid_for_user() { + id -u "$1" 2>/dev/null || echo "" +} + +get_state_dir() { + local home="$1" + if [[ -n "$PROFILE" ]]; then + echo "${home}/.openclaw-${PROFILE}" + else + echo "${home}/.openclaw" + fi +} + +get_configured_port() { + local config_file="$1" + if [[ -f "$config_file" ]]; then + # extract port from json without jq (mdm environments may not have it) + grep -o '"port"[[:space:]]*:[[:space:]]*[0-9]*' "$config_file" 2>/dev/null | head -1 | grep -o '[0-9]*$' || true + fi +} + +# -- Service removal ------------------------------------------------------ + +remove_launchd_service() { + local uid="$1" label + if [[ -n "$PROFILE" ]]; then + label="bot.molt.${PROFILE}" + else + label="bot.molt.gateway" + fi + if launchctl print "gui/${uid}/${label}" &>/dev/null; then + do_or_dry "launchd service gui/${uid}/${label}" launchctl bootout "gui/${uid}/${label}" + fi +} + +remove_systemd_service() { + local uid="$1" service runtime_dir + if [[ -n "$PROFILE" ]]; then + service="openclaw-gateway-${PROFILE}.service" + else + service="openclaw-gateway.service" + fi + runtime_dir="/run/user/${uid}" + if [[ -d "$runtime_dir" ]]; then + if XDG_RUNTIME_DIR="$runtime_dir" systemctl --user is-active "$service" &>/dev/null 2>&1; then + do_or_dry "systemd service ${service} stop (uid=${uid})" \ + env XDG_RUNTIME_DIR="$runtime_dir" systemctl --user stop "$service" + do_or_dry "systemd service ${service} disable (uid=${uid})" \ + env XDG_RUNTIME_DIR="$runtime_dir" systemctl --user disable "$service" + fi + fi +} + +# -- Kill gateway process on port ----------------------------------------- + +kill_gateway_port() { + local port="$1" + local pids + pids=$(lsof -ti :"$port" 2>/dev/null) || true + if [[ -z "$pids" ]]; then + pids=$(fuser "$port/tcp" 2>/dev/null | tr -s ' ') || true + fi + if [[ -n "$pids" ]]; then + for pid in $pids; do + do_or_dry "kill gateway process pid=$pid on port $port" kill "$pid" + done + fi +} + +# -- Docker removal ------------------------------------------------------- + +remove_docker_containers() { + command -v docker &>/dev/null || return 0 + local ids + ids=$(docker ps --filter "name=openclaw" -q 2>/dev/null) || true + if [[ -n "$ids" ]]; then + for cid in $ids; do + do_or_dry "docker container $cid" bash -c "docker stop '$cid' && docker rm '$cid'" + done + fi +} + +remove_docker_images() { + command -v docker &>/dev/null || return 0 + local ids + ids=$(docker images --filter "reference=*openclaw*" -q 2>/dev/null) || true + if [[ -n "$ids" ]]; then + for iid in $ids; do + do_or_dry "docker image $iid" docker rmi "$iid" + done + fi +} + +# -- Package manager uninstall -------------------------------------------- + +uninstall_via_package_managers() { + if command -v brew &>/dev/null && brew list openclaw &>/dev/null 2>&1; then + do_or_dry "brew uninstall openclaw" brew uninstall openclaw + fi + if command -v npm &>/dev/null && npm ls -g openclaw --depth=0 &>/dev/null 2>&1; then + do_or_dry "npm uninstall -g openclaw" npm uninstall -g openclaw + fi + if command -v volta &>/dev/null && volta list openclaw 2>/dev/null | grep -q openclaw; then + do_or_dry "volta uninstall openclaw" volta uninstall openclaw + fi +} + +# -- Binary removal ------------------------------------------------------- + +remove_binary() { + local path="$1" + if [[ -f "$path" || -L "$path" ]]; then + do_or_dry "binary $path" rm -f "$path" + fi +} + +remove_cli_binaries_global() { + local locations=( + "/usr/local/bin/openclaw" + "/opt/homebrew/bin/openclaw" + "/usr/bin/openclaw" + ) + for loc in "${locations[@]}"; do + remove_binary "$loc" + done +} + +remove_cli_binaries_user() { + local home="$1" + local locations=( + "${home}/.volta/bin/openclaw" + "${home}/.local/bin/openclaw" + "${home}/.nvm/current/bin/openclaw" + "${home}/bin/openclaw" + ) + for loc in "${locations[@]}"; do + remove_binary "$loc" + done +} + +# -- App bundle removal (macOS) ------------------------------------------- + +remove_mac_app() { + if [[ -d "/Applications/OpenClaw.app" ]]; then + do_or_dry "macOS app bundle /Applications/OpenClaw.app" rm -rf "/Applications/OpenClaw.app" + fi +} + +# -- State directory removal ---------------------------------------------- + +remove_state_dir() { + local state_dir="$1" + if [[ ! -d "$state_dir" ]]; then + return 0 + fi + if [[ "$KEEP_DATA" == "1" ]]; then + out "skipped-state-dir: $state_dir" + ((SKIPPED_COUNT++)) + return 0 + fi + do_or_dry "state-dir $state_dir" rm -rf "$state_dir" +} + +# -- Main ----------------------------------------------------------------- + +main() { + local platform + platform=$(detect_platform) + out "platform: $platform" + + if [[ "$platform" == "unknown" ]]; then + echo "result: error" + out "error-detail: unsupported platform" + printf "%s" "$output" + exit 2 + fi + + if [[ "$DRY_RUN" == "1" ]]; then + out "mode: dry-run" + fi + if [[ "$KEEP_DATA" == "1" ]]; then + out "keep-data: true" + fi + + local users + users=$(get_users_to_check "$platform") + + local ports_to_check="$PORT" + local uid="" home_dir="" state_dir="" configured_port="" + + # Phase 1: Stop services per user + for user in $users; do + uid=$(get_uid_for_user "$user") + if [[ -n "$uid" ]]; then + case "$platform" in + darwin) remove_launchd_service "$uid" ;; + linux) remove_systemd_service "$uid" ;; + esac + fi + + home_dir=$(get_home_dir "$user" "$platform") + state_dir=$(get_state_dir "$home_dir") + configured_port=$(get_configured_port "${state_dir}/openclaw.json") + if [[ -n "$configured_port" ]]; then + ports_to_check="$ports_to_check $configured_port" + fi + done + + # Phase 2: Kill gateway processes on all discovered ports + local unique_ports + unique_ports=$(echo "$ports_to_check" | tr ' ' '\n' | sort -u | tr '\n' ' ') + for port in $unique_ports; do + kill_gateway_port "$port" + done + + # Phase 3: Docker + remove_docker_containers + remove_docker_images + + # Phase 4: Package managers + uninstall_via_package_managers + + # Phase 5: Binary removal + remove_cli_binaries_global + for user in $users; do + home_dir=$(get_home_dir "$user" "$platform") + remove_cli_binaries_user "$home_dir" + done + + # Phase 6: macOS app bundle + if [[ "$platform" == "darwin" ]]; then + remove_mac_app + fi + + # Phase 7: State directories + for user in $users; do + home_dir=$(get_home_dir "$user" "$platform") + state_dir=$(get_state_dir "$home_dir") + remove_state_dir "$state_dir" + done + + # Also remove CLI found via PATH at an unexpected location + local path_cli + path_cli=$(command -v openclaw 2>/dev/null) || true + if [[ -n "$path_cli" ]]; then + remove_binary "$path_cli" + fi + + # -- Determine result --------------------------------------------------- + local total=$((REMOVED_COUNT + SKIPPED_COUNT + ERROR_COUNT)) + if [[ $ERROR_COUNT -gt 0 ]]; then + echo "result: partial" + printf "%s" "$output" + exit 1 + elif [[ $total -eq 0 ]]; then + echo "result: nothing-to-remove" + printf "%s" "$output" + exit 0 + else + echo "result: all-removed" + printf "%s" "$output" + exit 0 + fi +} + +main diff --git a/tests/remove-openclaw.Tests.ps1 b/tests/remove-openclaw.Tests.ps1 new file mode 100644 index 0000000..9acf89a --- /dev/null +++ b/tests/remove-openclaw.Tests.ps1 @@ -0,0 +1,353 @@ +# Pester tests for remove-openclaw.ps1 +# +# Uses OPENCLAW_PROFILE=pestertest for isolation so planted artifacts +# live under ~\.openclaw-pestertest\ instead of the default location. + +$Script = Join-Path $PSScriptRoot "..\remove-openclaw.ps1" +$DetectScript = Join-Path $PSScriptRoot "..\detect-openclaw.ps1" +$Profile = "pestertest" +$HomeDir = $env:USERPROFILE +$StateDir = Join-Path $HomeDir ".openclaw-$Profile" +$LocalPrograms = Join-Path $HomeDir "AppData\Local\Programs\openclaw" +$FakeBinary = Join-Path $LocalPrograms "openclaw.exe" + +# -- helpers -------------------------------------------------------------- + +function Plant-Artifacts { + New-Item -ItemType Directory -Path $StateDir -Force | Out-Null + '{"port": 18789, "version": "0.99.0-fake"}' | Set-Content (Join-Path $StateDir "openclaw.json") + 'fake gateway' | Set-Content (Join-Path $StateDir "gateway") + + New-Item -ItemType Directory -Path $LocalPrograms -Force | Out-Null + 'fake openclaw binary' | Set-Content $FakeBinary +} + +function Remove-PlantedArtifacts { + if (Test-Path $StateDir) { Remove-Item $StateDir -Recurse -Force -ErrorAction SilentlyContinue } + if (Test-Path $FakeBinary) { Remove-Item $FakeBinary -Force -ErrorAction SilentlyContinue } +} + +function Test-RealOpenClaw { + $realState = Join-Path $HomeDir ".openclaw" + if (Test-Path $realState) { return $true } + if (Get-Command openclaw -ErrorAction SilentlyContinue) { return $true } + return $false +} + +function Invoke-RemovalScript { + param([hashtable]$EnvOverrides = @{}) + + $savedEnv = @{} + foreach ($key in $EnvOverrides.Keys) { + $savedEnv[$key] = [Environment]::GetEnvironmentVariable($key, "Process") + [Environment]::SetEnvironmentVariable($key, $EnvOverrides[$key], "Process") + } + + try { + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $Script 2>&1 + $exitCode = $LASTEXITCODE + return @{ Output = ($output | Out-String); ExitCode = $exitCode } + } finally { + foreach ($key in $savedEnv.Keys) { + [Environment]::SetEnvironmentVariable($key, $savedEnv[$key], "Process") + } + } +} + +function Invoke-DetectScript { + param([hashtable]$EnvOverrides = @{}) + + $savedEnv = @{} + foreach ($key in $EnvOverrides.Keys) { + $savedEnv[$key] = [Environment]::GetEnvironmentVariable($key, "Process") + [Environment]::SetEnvironmentVariable($key, $EnvOverrides[$key], "Process") + } + + try { + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File $DetectScript 2>&1 + $exitCode = $LASTEXITCODE + return @{ Output = ($output | Out-String); ExitCode = $exitCode } + } finally { + foreach ($key in $savedEnv.Keys) { + [Environment]::SetEnvironmentVariable($key, $savedEnv[$key], "Process") + } + } +} + +# -- tests ---------------------------------------------------------------- + +Describe "remove-openclaw.ps1" { + + BeforeEach { + Remove-PlantedArtifacts + } + + AfterEach { + Remove-PlantedArtifacts + } + + # ===================================================================== + # Clean machine tests + # ===================================================================== + + Context "Clean machine" { + It "exits 0" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.ExitCode | Should -Be 0 + } + + It "reports nothing-to-remove" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.Output | Should -Match "result: nothing-to-remove" + } + + It "reports platform as windows" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.Output | Should -Match "platform: windows" + } + + It "shows banner" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.Output | Should -Match "Knostic" + $r.Output | Should -Match "Removal Script" + } + } + + # ===================================================================== + # Profile validation + # ===================================================================== + + Context "Profile validation" { + It "rejects path traversal" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = "..\etc\passwd" } + $r.ExitCode | Should -Be 2 + $r.Output | Should -Match "result: error" + $r.Output | Should -Match "invalid OPENCLAW_PROFILE" + } + + It "rejects spaces" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = "bad profile" } + $r.ExitCode | Should -Be 2 + } + + It "rejects shell metacharacters" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = "test;rm -rf /" } + $r.ExitCode | Should -Be 2 + } + + It "accepts alphanumeric with hyphens and underscores" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = "my-test_profile123" } + $r.ExitCode | Should -Be 0 + } + + It "accepts empty profile (default)" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = "" } + $r.ExitCode | Should -Be 0 + } + } + + # ===================================================================== + # Planted artifact removal + # ===================================================================== + + Context "Planted artifacts" { + BeforeEach { + if (Test-RealOpenClaw) { Set-ItResult -Skipped -Because "Real OpenClaw present" } + Plant-Artifacts + } + + It "removes state directory" { + Test-Path $StateDir | Should -Be $true + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.ExitCode | Should -Be 0 + Test-Path $StateDir | Should -Be $false + } + + It "removes binary" { + Test-Path $FakeBinary | Should -Be $true + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.ExitCode | Should -Be 0 + Test-Path $FakeBinary | Should -Be $false + } + + It "reports all-removed" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.Output | Should -Match "result: all-removed" + } + + It "output contains removed lines" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r.Output | Should -Match "removed:" + } + } + + # ===================================================================== + # Dry-run mode + # ===================================================================== + + Context "Dry-run mode" { + BeforeEach { + if (Test-RealOpenClaw) { Set-ItResult -Skipped -Because "Real OpenClaw present" } + Plant-Artifacts + } + + It "preserves state directory" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_DRY_RUN = "1" } + $r.ExitCode | Should -Be 0 + Test-Path $StateDir | Should -Be $true + } + + It "preserves binary" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_DRY_RUN = "1" } + $r.ExitCode | Should -Be 0 + Test-Path $FakeBinary | Should -Be $true + } + + It "reports dry-run mode" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_DRY_RUN = "1" } + $r.Output | Should -Match "mode: dry-run" + } + + It "logs dry-run actions" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_DRY_RUN = "1" } + $r.Output | Should -Match "dry-run:" + } + } + + # ===================================================================== + # Keep-data mode + # ===================================================================== + + Context "Keep-data mode" { + BeforeEach { + if (Test-RealOpenClaw) { Set-ItResult -Skipped -Because "Real OpenClaw present" } + Plant-Artifacts + } + + It "preserves state directory" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_KEEP_DATA = "1" } + $r.ExitCode | Should -Be 0 + Test-Path $StateDir | Should -Be $true + } + + It "removes binary" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_KEEP_DATA = "1" } + $r.ExitCode | Should -Be 0 + Test-Path $FakeBinary | Should -Be $false + } + + It "reports keep-data in output" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_KEEP_DATA = "1" } + $r.Output | Should -Match "keep-data: true" + } + + It "reports skipped state dir" { + $r = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile; OPENCLAW_KEEP_DATA = "1" } + $r.Output | Should -Match "skipped-state-dir:" + } + } + + # ===================================================================== + # Idempotency + # ===================================================================== + + Context "Idempotency" { + BeforeEach { + if (Test-RealOpenClaw) { Set-ItResult -Skipped -Because "Real OpenClaw present" } + Plant-Artifacts + } + + It "second removal succeeds with nothing-to-remove" { + $r1 = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r1.ExitCode | Should -Be 0 + + $r2 = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $r2.ExitCode | Should -Be 0 + $r2.Output | Should -Match "result: nothing-to-remove" + } + } + + # ===================================================================== + # Detect -> Remove -> Detect cycle + # ===================================================================== + + Context "Full cycle" { + BeforeEach { + if (Test-RealOpenClaw) { Set-ItResult -Skipped -Because "Real OpenClaw present" } + Plant-Artifacts + } + + It "detect finds artifacts, removal cleans, detect confirms clean" { + $detect1 = Invoke-DetectScript @{ OPENCLAW_PROFILE = $Profile } + $detect1.ExitCode | Should -Be 1 + + $remove = Invoke-RemovalScript @{ OPENCLAW_PROFILE = $Profile } + $remove.ExitCode | Should -Be 0 + $remove.Output | Should -Match "result: all-removed" + + $detect2 = Invoke-DetectScript @{ OPENCLAW_PROFILE = $Profile } + $detect2.ExitCode | Should -Be 0 + $detect2.Output | Should -Match "summary: not-installed" + } + } + + # ===================================================================== + # Script content validation + # ===================================================================== + + Context "Script content" { + $scriptContent = Get-Content $Script -Raw + + It "contains exit codes 0, 1, 2" { + $scriptContent | Should -Match "exit 0" + $scriptContent | Should -Match "exit 1" + $scriptContent | Should -Match "exit 2" + } + + It "validates OPENCLAW_PROFILE with strict regex" { + $scriptContent | Should -Match "\[A-Za-z0-9_-\]" + } + + It "has dry-run support" { + $scriptContent | Should -Match "DryRun" + $scriptContent | Should -Match "dry-run" + } + + It "has keep-data support" { + $scriptContent | Should -Match "KeepData" + $scriptContent | Should -Match "skipped-state-dir" + } + + It "has Remove-Item for file removal" { + $scriptContent | Should -Match "Remove-Item" + } + + It "checks scoop" { + $scriptContent | Should -Match "scoop uninstall" + } + + It "checks npm" { + $scriptContent | Should -Match "npm uninstall" + } + + It "checks winget" { + $scriptContent | Should -Match "winget uninstall" + } + + It "kills gateway by port" { + $scriptContent | Should -Match "Stop-Process" + } + + It "handles scheduled tasks" { + $scriptContent | Should -Match "Unregister-ScheduledTask" + } + + It "handles WSL" { + $scriptContent | Should -Match "Remove-WslOpenclaw" + } + + It "no dangerous Remove-Item on root" { + $scriptContent | Should -Not -Match 'Remove-Item.*-Path\s+"?C:\\["\s]' + } + } +} diff --git a/tests/remove-openclaw.bats b/tests/remove-openclaw.bats new file mode 100644 index 0000000..34299f4 --- /dev/null +++ b/tests/remove-openclaw.bats @@ -0,0 +1,311 @@ +#!/usr/bin/env bats +# Tests for remove-openclaw.sh +# +# Uses OPENCLAW_PROFILE=batstest for isolation so planted artifacts +# live under ~/.openclaw-batstest/ instead of the default location. + +SCRIPT="$BATS_TEST_DIRNAME/../remove-openclaw.sh" +DETECT_SCRIPT="$BATS_TEST_DIRNAME/../detect-openclaw.sh" +PROFILE="batstest" +STATE_DIR="$HOME/.openclaw-${PROFILE}" +LOCAL_BIN="$HOME/.local/bin" +FAKE_BINARY="$LOCAL_BIN/openclaw" + +# -- helpers -------------------------------------------------------------- + +plant_artifacts() { + mkdir -p "$STATE_DIR" + printf '{"port": 18789, "version": "0.99.0-fake"}\n' > "$STATE_DIR/openclaw.json" + printf 'fake gateway binary\n' > "$STATE_DIR/gateway" + chmod +x "$STATE_DIR/gateway" + + mkdir -p "$LOCAL_BIN" + printf '#!/bin/sh\necho "fake openclaw"\n' > "$FAKE_BINARY" + chmod +x "$FAKE_BINARY" +} + +skip_if_real_openclaw() { + if [[ -d "$HOME/.openclaw" ]]; then + skip "Real OpenClaw state dir exists -- skipping to avoid interference" + fi + if command -v openclaw &>/dev/null; then + skip "Real openclaw binary in PATH -- skipping to avoid interference" + fi +} + +# -- setup / teardown ----------------------------------------------------- + +setup() { + rm -rf "$STATE_DIR" + rm -f "$FAKE_BINARY" +} + +teardown() { + rm -rf "$STATE_DIR" + rm -f "$FAKE_BINARY" +} + +# ========================================================================= +# Clean machine tests +# ========================================================================= + +@test "clean machine: exits 0" { + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [ "$status" -eq 0 ] +} + +@test "clean machine: reports nothing-to-remove" { + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [[ "$output" == *"result: nothing-to-remove"* ]] +} + +@test "clean machine: reports platform" { + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [[ "$output" == *"platform: "* ]] +} + +@test "clean machine: shows banner" { + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [[ "$output" == *"Knostic"* ]] + [[ "$output" == *"Removal Script"* ]] +} + +# ========================================================================= +# Profile validation (security) +# ========================================================================= + +@test "invalid profile with path traversal: exits 2" { + run env OPENCLAW_PROFILE="../etc/passwd" bash "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"result: error"* ]] + [[ "$output" == *"invalid OPENCLAW_PROFILE"* ]] +} + +@test "invalid profile with spaces: exits 2" { + run env OPENCLAW_PROFILE="bad profile" bash "$SCRIPT" + [ "$status" -eq 2 ] +} + +@test "invalid profile with shell metacharacters: exits 2" { + run env OPENCLAW_PROFILE='test;rm -rf /' bash "$SCRIPT" + [ "$status" -eq 2 ] +} + +@test "valid profile with alphanumeric and hyphens: exits 0" { + run env OPENCLAW_PROFILE="my-test_profile123" bash "$SCRIPT" + [ "$status" -eq 0 ] +} + +@test "empty profile (default): exits 0" { + run env OPENCLAW_PROFILE="" bash "$SCRIPT" + [ "$status" -eq 0 ] +} + +# ========================================================================= +# Planted artifact removal +# ========================================================================= + +@test "removal: cleans state directory" { + skip_if_real_openclaw + plant_artifacts + [ -d "$STATE_DIR" ] + + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -d "$STATE_DIR" ] +} + +@test "removal: cleans binary" { + skip_if_real_openclaw + plant_artifacts + [ -x "$FAKE_BINARY" ] + + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -f "$FAKE_BINARY" ] +} + +@test "removal: reports all-removed" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [[ "$output" == *"result: all-removed"* ]] +} + +@test "removal: output contains removed lines" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [[ "$output" == *"removed:"* ]] +} + +# ========================================================================= +# Dry-run mode +# ========================================================================= + +@test "dry-run: preserves state directory" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_DRY_RUN=1 bash "$SCRIPT" + [ "$status" -eq 0 ] + [ -d "$STATE_DIR" ] +} + +@test "dry-run: preserves binary" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_DRY_RUN=1 bash "$SCRIPT" + [ "$status" -eq 0 ] + [ -x "$FAKE_BINARY" ] +} + +@test "dry-run: reports mode in output" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_DRY_RUN=1 bash "$SCRIPT" + [[ "$output" == *"mode: dry-run"* ]] +} + +@test "dry-run: logs dry-run actions" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_DRY_RUN=1 bash "$SCRIPT" + [[ "$output" == *"dry-run:"* ]] +} + +# ========================================================================= +# Keep-data mode +# ========================================================================= + +@test "keep-data: preserves state directory" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_KEEP_DATA=1 bash "$SCRIPT" + [ "$status" -eq 0 ] + [ -d "$STATE_DIR" ] +} + +@test "keep-data: removes binary" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_KEEP_DATA=1 bash "$SCRIPT" + [ "$status" -eq 0 ] + [ ! -f "$FAKE_BINARY" ] +} + +@test "keep-data: reports keep-data in output" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_KEEP_DATA=1 bash "$SCRIPT" + [[ "$output" == *"keep-data: true"* ]] +} + +@test "keep-data: reports skipped state dir" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" OPENCLAW_KEEP_DATA=1 bash "$SCRIPT" + [[ "$output" == *"skipped-state-dir:"* ]] +} + +# ========================================================================= +# Idempotency +# ========================================================================= + +@test "idempotent: second removal succeeds" { + skip_if_real_openclaw + plant_artifacts + + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [ "$status" -eq 0 ] + + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"result: nothing-to-remove"* ]] +} + +# ========================================================================= +# Detect -> Remove -> Detect cycle +# ========================================================================= + +@test "full cycle: detect finds artifacts, removal cleans, detect confirms clean" { + skip_if_real_openclaw + plant_artifacts + + # detect should find planted artifacts (exit 1) + run env OPENCLAW_PROFILE="$PROFILE" bash "$DETECT_SCRIPT" + [ "$status" -eq 1 ] + [[ "$output" == *"installed"* ]] + + # remove + run env OPENCLAW_PROFILE="$PROFILE" bash "$SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"result: all-removed"* ]] + + # detect should report clean (exit 0) + run env OPENCLAW_PROFILE="$PROFILE" bash "$DETECT_SCRIPT" + [ "$status" -eq 0 ] + [[ "$output" == *"summary: not-installed"* ]] +} + +# ========================================================================= +# Script content validation (security / correctness) +# ========================================================================= + +@test "script: contains exit codes 0, 1, 2" { + grep -q 'exit 0' "$SCRIPT" + grep -q 'exit 1' "$SCRIPT" + grep -q 'exit 2' "$SCRIPT" +} + +@test "script: validates OPENCLAW_PROFILE with strict regex" { + grep -q '\^.A-Za-z0-9_-.' "$SCRIPT" +} + +@test "script: no dangerous rm -rf / pattern" { + # rm -rf /Applications is allowed; rm -rf / alone is not + ! grep -E 'rm -rf /[^A-Za-z]' "$SCRIPT" +} + +@test "script: no rm -rf with wildcards" { + ! grep -q 'rm -rf \*' "$SCRIPT" +} + +@test "script: has dry-run support" { + grep -q 'DRY_RUN' "$SCRIPT" + grep -q 'do_or_dry' "$SCRIPT" +} + +@test "script: has keep-data support" { + grep -q 'KEEP_DATA' "$SCRIPT" + grep -q 'skipped-state-dir' "$SCRIPT" +} + +@test "script: checks brew" { + grep -q 'brew uninstall' "$SCRIPT" +} + +@test "script: checks npm" { + grep -q 'npm uninstall' "$SCRIPT" +} + +@test "script: kills gateway by port" { + grep -q 'lsof' "$SCRIPT" || grep -q 'fuser' "$SCRIPT" +} + +@test "script: handles launchd on darwin" { + grep -q 'launchctl bootout' "$SCRIPT" +} + +@test "script: handles systemd on linux" { + grep -q 'systemctl --user' "$SCRIPT" +}