From 48249493de1034b7b266c2d5b2c3def0ad5dd02d Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Sat, 7 Nov 2026 11:25:29 -0500 Subject: [PATCH 1/2] feat(install): add uninstall scripts Add uninstall.sh (macOS/Linux) and uninstall.ps1 (Windows) to remove everything dtvem puts on a machine: - every dtvem root (DTVEM_ROOT, XDG_DATA_HOME/dtvem, ~/.local/share/dtvem, ~/.dtvem), with all installed runtime versions - the PATH changes made by the installers and `dtvem init`: the marked blocks in shell config files on Unix, and the User and System PATH entries on Windows (REG_EXPAND_SZ and %VAR% entries preserved, one UAC prompt when the System PATH needs editing) - on Windows, what RubyInstaller registered when older dtvem versions used its .exe to install Ruby: Programs and Features entries, Start menu folders, PATH entries and the .rb/.rbw file associations Both scripts list what they will remove and ask first (--yes / -Yes skips the prompt). They refuse /, a drive root and the home directory, only remove dtvem's own entries from a custom DTVEM_ROOT, and never follow links or junctions out of the root. Lint both scripts in script-lint.yml, publish them as release assets, and add an integration test that installs dtvem as the installers do, uninstalls it, and checks nothing is left on Linux, macOS (bash 3.2), and Windows (pwsh and Windows PowerShell 5.1). --- .../workflows/integration-test-uninstall.yml | 243 +++++++ .github/workflows/release.yml | 4 + .github/workflows/script-lint.yml | 31 +- CLAUDE.md | 20 +- README.md | 14 + uninstall.ps1 | 665 ++++++++++++++++++ uninstall.sh | 501 +++++++++++++ 7 files changed, 1469 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/integration-test-uninstall.yml create mode 100644 uninstall.ps1 create mode 100644 uninstall.sh diff --git a/.github/workflows/integration-test-uninstall.yml b/.github/workflows/integration-test-uninstall.yml new file mode 100644 index 0000000..2897cd6 --- /dev/null +++ b/.github/workflows/integration-test-uninstall.yml @@ -0,0 +1,243 @@ +name: Integration Tests - Uninstall Scripts + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'uninstall.sh' + - 'uninstall.ps1' + - 'install.sh' + - 'install.ps1' + - 'src/cmd/init.go' + - 'src/internal/path/**' + - '.github/workflows/integration-test-uninstall.yml' + pull_request: + branches: [main] + paths: + - 'uninstall.sh' + - 'uninstall.ps1' + - 'install.sh' + - 'install.ps1' + - 'src/cmd/init.go' + - 'src/internal/path/**' + - '.github/workflows/integration-test-uninstall.yml' + +permissions: + contents: read + +jobs: + unix: + name: Uninstall (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + env: + SHELL: /bin/bash + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Build dtvem + run: go build -o "$RUNNER_TEMP/dtvem" ./src + + - name: Install dtvem as install.sh does + run: | + set -eu + if [ "$(uname -s)" = Linux ]; then + root="$HOME/.local/share/dtvem" + else + root="$HOME/.dtvem" + fi + mkdir -p "$root/bin" + cp "$RUNNER_TEMP/dtvem" "$root/bin/dtvem" + + # The PATH line install.sh adds, and one the user wrote themselves + touch ~/.bashrc + { + echo "" + echo "# Added by dtvem installer" + echo "export PATH=\"$root/bin:\$PATH\"" + echo "export DTVEM_VERBOSE=1" + } >> ~/.bashrc + + # Adds the shims PATH line + "$root/bin/dtvem" init -y + + # A runtime version holding a read-only directory and a link to a + # directory outside the root (as `npm link` makes) + version="$root/versions/node/22.0.0" + mkdir -p "$version/lib/node_modules" "$version/readonly" "$RUNNER_TEMP/linked-project" + echo keep > "$RUNNER_TEMP/linked-project/keep.txt" + ln -s "$RUNNER_TEMP/linked-project" "$version/lib/node_modules/linked" + touch "$version/readonly/file" + chmod 555 "$version/readonly" + + echo "DTVEM_TEST_ROOT=$root" >> "$GITHUB_ENV" + cat ~/.bashrc + + # On macOS this is the stock bash 3.2, which `curl | bash` runs there + - name: Uninstall + run: /bin/bash ./uninstall.sh --yes + + - name: Verify + run: | + fail=0 + check() { + if eval "$1"; then echo "✓ $2"; else echo "✗ $2"; fail=1; fi + } + check '[ ! -e "$DTVEM_TEST_ROOT" ]' "dtvem root removed" + check '[ -f "$RUNNER_TEMP/linked-project/keep.txt" ]' "linked directory outside the root kept" + check '! grep -q "Added by dtvem" ~/.bashrc' "dtvem PATH lines removed" + check '! grep -qF "$DTVEM_TEST_ROOT" ~/.bashrc' "no PATH line names the root" + check 'grep -q "DTVEM_VERBOSE" ~/.bashrc' "the user's own line kept" + cat ~/.bashrc + exit $fail + + - name: Uninstall again (nothing to remove) + run: /bin/bash ./uninstall.sh --yes + + - name: Custom DTVEM_ROOT keeps other files + run: | + set -eu + root="$RUNNER_TEMP/tools" + mkdir -p "$root/bin" "$root/shims" "$root/versions/ruby/3.4.1" + cp "$RUNNER_TEMP/dtvem" "$root/bin/dtvem" + echo keep > "$root/bin/other-tool" + echo keep > "$root/notes.txt" + DTVEM_ROOT="$root" /bin/bash ./uninstall.sh --yes + if [ ! -f "$root/bin/other-tool" ] || [ ! -f "$root/notes.txt" ]; then + echo "✗ files dtvem didn't create were removed" + exit 1 + fi + if [ -e "$root/shims" ] || [ -e "$root/versions" ] || [ -e "$root/bin/dtvem" ]; then + echo "✗ dtvem's files are still there" + exit 1 + fi + echo "✓ only dtvem's files were removed" + + windows: + name: Uninstall (windows, ${{ matrix.shell }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + shell: [pwsh, powershell] + defaults: + run: + shell: pwsh + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Build dtvem + run: go build -o "$env:RUNNER_TEMP\dtvem.exe" ./src + + - name: Install dtvem as install.ps1 does + run: | + $root = Join-Path $env:USERPROFILE '.dtvem' + New-Item -ItemType Directory "$root\bin" -Force | Out-Null + Copy-Item "$env:RUNNER_TEMP\dtvem.exe" "$root\bin\dtvem.exe" + + # User PATH: the install directory (as install.ps1 adds it), a + # stale shims entry from an old install, and a %VAR% entry that + # must stay unexpanded + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + $userPath = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $key.SetValue('Path', "$root\bin;C:\old-install\.dtvem\shims;$userPath;%USERPROFILE%\keep-me", 'ExpandString') + $key.Close() + + # System PATH: the shims directory (the runner is an administrator) + & "$root\bin\dtvem.exe" init -y + if ($LASTEXITCODE) { exit $LASTEXITCODE } + + # A runtime version holding a read-only file, a path over 260 + # characters, and a junction to a directory outside the root (as + # `npm link` makes) + $version = "$root\versions\node\22.0.0" + New-Item -ItemType Directory "$version\node_modules", "$env:RUNNER_TEMP\linked-project" -Force | Out-Null + Set-Content "$env:RUNNER_TEMP\linked-project\keep.txt" 'keep' + New-Item -ItemType Junction -Path "$version\node_modules\linked" -Target "$env:RUNNER_TEMP\linked-project" | Out-Null + Set-Content "$version\readonly.txt" 'x' + (Get-Item "$version\readonly.txt").IsReadOnly = $true + $deep = $version + (('\' + ('d' * 50)) * 6) + [IO.Directory]::CreateDirectory("\\?\$deep") | Out-Null + [IO.File]::WriteAllText("\\?\$deep\deep.txt", 'x') + + # What RubyInstaller registered when older dtvem versions used it: + # a Programs and Features entry, a file type, the .rb association, + # a Start menu folder and a System PATH entry, all pointing at the + # deleted %TEMP%\dtvem-ruby- directory + $staging = Join-Path $env:TEMP 'dtvem-ruby-3.3.9\installed' + $uninstall = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\RubyInstaller-3.3-x64-mingw-ucrt_is1' + New-Item -Path $uninstall -Force | Out-Null + New-ItemProperty -Path $uninstall -Name DisplayName -Value 'Ruby 3.3.9-1-x64' | Out-Null + New-ItemProperty -Path $uninstall -Name InstallLocation -Value "$staging\" | Out-Null + New-Item -Path 'HKCU:\Software\Classes\RubyFile\shell\open\command' -Force | Out-Null + Set-Item -Path 'HKCU:\Software\Classes\RubyFile\shell\open\command' -Value "`"$staging\bin\ruby.exe`" `"%1`" %*" + New-Item -Path 'HKCU:\Software\Classes\.rb\OpenWithProgids' -Force | Out-Null + Set-Item -Path 'HKCU:\Software\Classes\.rb' -Value 'RubyFile' + $menu = Join-Path ([Environment]::GetFolderPath('Programs')) 'Ruby 3.3.9-1-x64' + New-Item -ItemType Directory $menu -Force | Out-Null + $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut("$menu\Interactive Ruby.lnk") + $shortcut.TargetPath = "$staging\bin\irb.bat" + $shortcut.Save() + $system = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true) + $systemPath = $system.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $system.SetValue('Path', "$systemPath;$staging\bin", $system.GetValueKind('Path')) + $system.Close() + + "DTVEM_TEST_ROOT=$root" >> $env:GITHUB_ENV + + - name: Uninstall + run: | + & ${{ matrix.shell }} -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 -Yes + if ($LASTEXITCODE) { exit $LASTEXITCODE } + + - name: Verify + run: | + $failed = $false + function Check($ok, $name) { + if ($ok) { "✓ $name" } else { "✗ $name"; $script:failed = $true } + } + + $user = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + $userPath = $user.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $userKind = $user.GetValueKind('Path') + $user.Close() + $system = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment') + $systemPath = $system.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $systemKind = $system.GetValueKind('Path') + $system.Close() + + Check (-not (Test-Path $env:DTVEM_TEST_ROOT)) 'dtvem root removed' + Check (Test-Path "$env:RUNNER_TEMP\linked-project\keep.txt") 'junction target outside the root kept' + Check ($userPath -notmatch 'dtvem') "User PATH has no dtvem entries: $userPath" + Check ($userPath -like '*%USERPROFILE%\keep-me*') 'User PATH %VAR% entry kept unexpanded' + Check ($userKind -eq 'ExpandString') "User PATH is still REG_EXPAND_SZ ($userKind)" + Check ($systemPath -notmatch 'dtvem') "System PATH has no dtvem entries: $systemPath" + Check ($systemPath -match 'system32') 'System PATH keeps its other entries' + Check ($systemKind -eq 'ExpandString') "System PATH is still REG_EXPAND_SZ ($systemKind)" + Check (-not (Test-Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\RubyInstaller-3.3-x64-mingw-ucrt_is1')) 'Programs and Features entry removed' + Check (-not (Test-Path 'HKCU:\Software\Classes\RubyFile')) 'RubyFile file type removed' + Check (-not (Get-Item 'HKCU:\Software\Classes\.rb').GetValue('')) '.rb association removed' + Check (Test-Path 'HKCU:\Software\Classes\.rb\OpenWithProgids') "other programs' .rb registrations kept" + Check (-not (Test-Path (Join-Path ([Environment]::GetFolderPath('Programs')) 'Ruby 3.3.9-1-x64'))) 'Start menu folder removed' + if ($failed) { exit 1 } + + - name: Uninstall again (nothing to remove) + run: | + & ${{ matrix.shell }} -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 -Yes + if ($LASTEXITCODE) { exit $LASTEXITCODE } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daf1ed3..b62ec50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,7 +223,9 @@ jobs: name: install-scripts path: | install.sh + uninstall.sh install.ps1 + uninstall.ps1 retention-days: 1 changelog: @@ -279,7 +281,9 @@ jobs: artifacts/build-windows-amd64/dtvem-${{ github.event.inputs.version }}-windows-amd64.zip artifacts/build-windows-arm64/dtvem-${{ github.event.inputs.version }}-windows-arm64.zip install.sh + uninstall.sh install.ps1 + uninstall.ps1 body: | ## What's New in v${{ github.event.inputs.version }} diff --git a/.github/workflows/script-lint.yml b/.github/workflows/script-lint.yml index 61f92ee..0efa7c3 100644 --- a/.github/workflows/script-lint.yml +++ b/.github/workflows/script-lint.yml @@ -6,18 +6,24 @@ on: paths: - 'install.sh' - 'install.ps1' + - 'uninstall.sh' + - 'uninstall.ps1' + - '.github/workflows/script-lint.yml' pull_request: branches: [ main ] paths: - 'install.sh' - 'install.ps1' + - 'uninstall.sh' + - 'uninstall.ps1' + - '.github/workflows/script-lint.yml' permissions: contents: read jobs: shellcheck: - name: Lint install.sh + name: Lint install.sh and uninstall.sh runs-on: ubuntu-latest steps: - name: Checkout code @@ -27,10 +33,10 @@ jobs: uses: ludeeus/action-shellcheck@master with: scandir: '.' - additional_files: 'install.sh' + additional_files: 'install.sh uninstall.sh' psscriptanalyzer: - name: Lint install.ps1 + name: Lint install.ps1 and uninstall.ps1 runs-on: ubuntu-latest steps: - name: Checkout code @@ -40,10 +46,19 @@ jobs: shell: pwsh run: | Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser - # Exclude PSAvoidUsingWriteHost - Write-Host is intentional for colored installer output - $results = Invoke-ScriptAnalyzer -Path ./install.ps1 -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSUseBOMForUnicodeEncodedFile - if ($results) { - $results | Format-Table -AutoSize + $failed = $false + foreach ($script in './install.ps1', './uninstall.ps1') { + # Exclude PSAvoidUsingWriteHost - Write-Host is intentional for colored installer output + $results = Invoke-ScriptAnalyzer -Path $script -Severity Error,Warning -ExcludeRule PSAvoidUsingWriteHost,PSUseBOMForUnicodeEncodedFile + if ($results) { + Write-Host "Issues in ${script}:" + $results | Format-Table -AutoSize + $failed = $true + } + else { + Write-Host "✅ No issues found in $script" + } + } + if ($failed) { exit 1 } - Write-Host "✅ No issues found in install.ps1" diff --git a/CLAUDE.md b/CLAUDE.md index b51d0d2..a0f2197 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -316,7 +316,8 @@ cd src && go test -cover ./... # With coverage | `build.yml` | PR, push to main | Lint, build, test on Windows/macOS/Linux. Posts coverage reports on PRs | | `release.yml` | Manual dispatch | Full release: validate, build 5 platforms, create GitHub Release, notify | | `commit-lint.yml` | PR | Validate PR titles follow conventional commits | -| `script-lint.yml` | PR (install scripts) | Lint install.sh and install.ps1 with shellcheck/PSScriptAnalyzer | +| `script-lint.yml` | PR (install scripts) | Lint the install and uninstall scripts with shellcheck/PSScriptAnalyzer | +| `integration-test-uninstall.yml` | PR (uninstall/install scripts, `init`, `internal/path`) | Install as the installer and `dtvem init` do, run the uninstall scripts, and check nothing is left (Linux, macOS bash 3.2, Windows pwsh and PowerShell 5.1) | | `contributors.yml` | Push to main | Auto-update contributors section in README | | `preview-changelog.yml` | PR | Preview release notes for PRs | | `integration-test.yml` | Manual dispatch | Full integration test suite (runtimes + migrations) | @@ -367,6 +368,23 @@ irm dtvem.io/install.ps1 | iex Features: Auto platform detection, PATH configuration, runs `dtvem init`. +### Uninstallers + +```bash +curl -fsSL dtvem.io/uninstall.sh | bash # --yes skips the prompt +``` + +```powershell +irm dtvem.io/uninstall.ps1 | iex # -Yes skips the prompt +``` + +They list what they will remove and ask first. They remove: +- **Every dtvem root**: `DTVEM_ROOT`, `XDG_DATA_HOME/dtvem`, `~/.local/share/dtvem` and `~/.dtvem`. A root not named `dtvem` or `.dtvem` (a custom `DTVEM_ROOT`) only loses the entries dtvem creates in it. The scripts refuse `/`, a drive root and the home directory. +- **PATH changes** from the installers and `dtvem init`. On Unix that is the `# Added by dtvem` / `# Added by dtvem installer` blocks in shell config files; lines without the marker are listed, never edited. On Windows it is User and System PATH entries, written back as in `writePathValue`. +- **RubyInstaller leftovers (Windows)**: what RubyInstaller registered when older dtvem versions installed Ruby with its `.exe` (Programs and Features entries, Start menu folders, PATH entries, the `.rb`/`.rbw` association, all pointing at a deleted `%TEMP%\dtvem-ruby-`). + +A PATH entry, line or registration is removed when it points into a root being removed, or no longer exists and was in a `dtvem`, `.dtvem` or `dtvem-ruby-*` directory; another install's existing directory is kept. Changes that need admin go through one UAC prompt. `uninstall.ps1` must stay ASCII: Windows PowerShell 5.1 reads a file without a BOM in the ANSI code page. When the installers or `dtvem init` change what they write, update the uninstallers to match. + --- ## UI Output System diff --git a/README.md b/README.md index ac54422..21cbbd3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,20 @@ irm dtvem.io/install.ps1 | iex See [Getting Started](https://dtvem.io/docs/user-guide/getting-started) for manual installation, building from source, and PATH configuration. +### 🗑️ Uninstalling + +**macOS / Linux:** +```bash +curl -fsSL dtvem.io/uninstall.sh | bash +``` + +**Windows (PowerShell):** +```powershell +irm dtvem.io/uninstall.ps1 | iex +``` + +This removes dtvem, every runtime version installed with it, and the PATH changes it made, after showing what it will remove and asking first. Version files in your projects are left alone. + ## 🚀 Quick Start ```bash diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..49a1177 --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,665 @@ +# dtvem uninstaller for Windows +# Usage: +# Standard: irm https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/uninstall.ps1 | iex +# No prompt: iex "& { $(irm https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/uninstall.ps1) } -Yes" +# +# Removes everything dtvem puts on a machine: +# - the dtvem directory: the dtvem binaries, shims, every runtime version +# installed with dtvem (and its global packages), config and cache +# - the dtvem entries the installer and `dtvem init` added to the User and +# System PATH +# - what RubyInstaller registered when older dtvem versions used it to +# install Ruby: Programs and Features entries, Start menu folders, PATH +# entries and the .rb and .rbw file associations +# Changes to the System PATH and to machine-wide settings need administrator +# rights, so Windows asks for them (UAC) when there are any to make. +# Project files (.dtvem\runtimes.json, .tool-versions, .nvmrc, ...) are left alone. + +param( + [switch]$Yes +) + +# The symbols are built from their code points so this file stays ASCII: +# Windows PowerShell 5.1 reads a script file without a BOM in the ANSI code +# page, where the UTF-8 bytes of these symbols include quote characters. + +function Write-Info { + param([string]$Message) + Write-Host "$([char]0x2192) " -ForegroundColor Cyan -NoNewline + Write-Host $Message +} + +function Write-Success { + param([string]$Message) + Write-Host "$([char]0x2713) " -ForegroundColor Green -NoNewline + Write-Host $Message +} + +function Write-Error-Custom { + param([string]$Message) + Write-Host "$([char]0x2717) " -ForegroundColor Red -NoNewline + Write-Host $Message +} + +function Write-Warning-Custom { + param([string]$Message) + Write-Host "$([char]0x26A0) " -ForegroundColor Yellow -NoNewline + Write-Host $Message +} + +function ConvertTo-FullPath { + param([string]$Path) + return [System.IO.Path]::GetFullPath($Path).TrimEnd('\') +} + +function Test-UnderDirectory { + # Whether Path is Directory or inside it + param([string]$Path, [string]$Directory) + return ($Path -eq $Directory) -or $Path.StartsWith("$Directory\", [System.StringComparison]::OrdinalIgnoreCase) +} + +function Test-OwnedRoot { + # Whether a root is a directory dtvem owns outright, named dtvem or .dtvem. + # A DTVEM_ROOT with any other name may hold other files, so only the + # entries dtvem creates in it are removed. + param([string]$Root) + $name = Split-Path -Leaf $Root + return ($name -eq 'dtvem') -or ($name -eq '.dtvem') +} + +function Get-RootEntry { + # The paths that removing a root deletes + param([string]$Root) + + if (Test-OwnedRoot -Root $Root) { + if (Test-Path -LiteralPath $Root) { + $Root + } + return + } + + foreach ($entry in 'shims', 'versions', 'config', 'cache', 'tmp', 'bin\dtvem.exe', 'bin\dtvem-shim.exe') { + $path = Join-Path $Root $entry + if (Test-Path -LiteralPath $path) { + $path + } + } +} + +function Get-DtvemRoot { + # Every dtvem root on this machine: DTVEM_ROOT, XDG_DATA_HOME\dtvem, and + # the default %USERPROFILE%\.dtvem + $candidates = @() + if ($env:DTVEM_ROOT) { + $candidates += $env:DTVEM_ROOT + } + if ($env:XDG_DATA_HOME) { + $candidates += Join-Path $env:XDG_DATA_HOME 'dtvem' + } + $candidates += Join-Path $env:USERPROFILE '.dtvem' + + $homeDir = ConvertTo-FullPath $env:USERPROFILE + $roots = @() + foreach ($candidate in $candidates) { + $root = ConvertTo-FullPath $candidate + if (-not (Test-Path -LiteralPath $root -PathType Container)) { + continue + } + if (($root -eq [System.IO.Path]::GetPathRoot($root).TrimEnd('\')) -or ($root -eq $homeDir)) { + Write-Warning-Custom "Skipping ${root}: it is a drive root or your home directory" + continue + } + if ($roots -contains $root) { + continue + } + if (@(Get-RootEntry -Root $root).Count -gt 0) { + $roots += $root + } + } + return $roots +} + +function Get-InstalledRuntime { + # The runtime versions installed under a root, one line per runtime + param([string]$Root) + + $versionsDir = Join-Path $Root 'versions' + if (-not (Test-Path -LiteralPath $versionsDir -PathType Container)) { + return + } + foreach ($runtimeDir in Get-ChildItem -LiteralPath $versionsDir -Directory) { + $versions = @(Get-ChildItem -LiteralPath $runtimeDir.FullName -Directory | ForEach-Object { $_.Name }) + if ($versions.Count -gt 0) { + "$($runtimeDir.Name): $($versions -join ', ')" + } + } +} + +function Test-DtvemLocation { + # Whether a path (a PATH entry, a shortcut target, ...) belongs to dtvem: + # it is in a dtvem root being removed, or it is gone and was in a dtvem + # directory (a dtvem root, or the %TEMP%\dtvem-ruby- directory + # older dtvem versions ran the Ruby installer in). Anything that still + # exists outside the roots, such as another dtvem install, is kept. + param([string]$Path, [string[]]$Roots) + + if (-not $Path -or -not $Path.Trim()) { + return $false + } + $full = [Environment]::ExpandEnvironmentVariables($Path.Trim().Trim('"')).Replace('/', '\').TrimEnd('\') + + foreach ($root in $Roots) { + if (Test-UnderDirectory -Path $full -Directory $root) { + return $true + } + } + + $dtvemDirs = @($full -split '\\' | Where-Object { ($_ -eq 'dtvem') -or ($_ -eq '.dtvem') -or ($_ -like 'dtvem-ruby-*') }) + if ($dtvemDirs.Count -eq 0) { + return $false + } + try { + return -not (Test-Path -LiteralPath $full) + } + catch { + return $false + } +} + +function Get-PathValue { + # The User or System PATH as stored in the registry, with %VAR% + # references left unexpanded + param([string]$Scope) + + if ($Scope -eq 'System') { + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment') + } + else { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + } + if (-not $key) { + return '' + } + try { + return [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + } + finally { + $key.Close() + } +} + +function Get-RegistryDefault { + # The default value of a registry key, if the key exists + param([string]$Key) + $item = Get-Item -LiteralPath $Key -ErrorAction SilentlyContinue + if ($item) { + return $item.GetValue('') + } +} + +function Get-CommandProgram { + # The program a command line such as "C:\ruby\bin\ruby.exe" "%1" %* runs + param([string]$Command) + if (-not $Command) { + return '' + } + $line = $Command.Trim() + if ($line.StartsWith('"')) { + return $line.Substring(1).Split('"')[0] + } + return $line.Split(' ')[0] +} + +function Test-DtvemShortcutFolder { + # Whether every shortcut in a Start menu folder points into a dtvem + # location, through its target or a path in its arguments + param([string]$Folder, [string[]]$Roots) + + $shortcuts = @(Get-ChildItem -LiteralPath $Folder -Filter '*.lnk' -Recurse -File -ErrorAction SilentlyContinue) + if ($shortcuts.Count -eq 0) { + return $false + } + try { + $shell = New-Object -ComObject WScript.Shell + foreach ($file in $shortcuts) { + $shortcut = $shell.CreateShortcut($file.FullName) + $paths = @($shortcut.TargetPath) + @($shortcut.Arguments -split '\s+') + if (@($paths | Where-Object { Test-DtvemLocation -Path $_ -Roots $Roots }).Count -eq 0) { + return $false + } + } + } + catch { + return $false + } + return $true +} + +function Find-Leftover { + # What dtvem left outside its root directories: PATH entries, and what + # RubyInstaller registered when older dtvem versions used it to install + # Ruby. Admin marks the ones that need administrator rights to remove. + param([string[]]$Roots) + + foreach ($scope in 'User', 'System') { + foreach ($entry in (Get-PathValue -Scope $scope) -split ';') { + if (Test-DtvemLocation -Path $entry -Roots $Roots) { + [pscustomobject]@{ Kind = 'Path'; Scope = $scope; Target = $entry; Admin = ($scope -eq 'System'); Label = "$scope PATH entry $($entry.Trim())" } + } + } + } + + foreach ($hive in 'HKCU:', 'HKLM:') { + $admin = $hive -eq 'HKLM:' + + $uninstallKey = "$hive\Software\Microsoft\Windows\CurrentVersion\Uninstall" + foreach ($key in @(Get-ChildItem -LiteralPath $uninstallKey -ErrorAction SilentlyContinue)) { + if (($key.PSChildName -like 'RubyInstaller-*') -and (Test-DtvemLocation -Path $key.GetValue('InstallLocation') -Roots $Roots)) { + $name = $key.GetValue('DisplayName') + if (-not $name) { + $name = $key.PSChildName + } + [pscustomobject]@{ Kind = 'RegistryKey'; Scope = ''; Target = $key.PSPath; Admin = $admin; Label = "Programs and Features entry `"$name`"" } + } + } + + foreach ($fileType in 'RubyFile', 'RubyWFile') { + $typeKey = "$hive\Software\Classes\$fileType" + $program = Get-CommandProgram -Command (Get-RegistryDefault -Key "$typeKey\shell\open\command") + if (-not (Test-DtvemLocation -Path $program -Roots $Roots)) { + continue + } + [pscustomobject]@{ Kind = 'RegistryKey'; Scope = ''; Target = $typeKey; Admin = $admin; Label = "File type $fileType, which opens $program" } + foreach ($extension in '.rb', '.rbw') { + $extensionKey = "$hive\Software\Classes\$extension" + if ((Get-RegistryDefault -Key $extensionKey) -eq $fileType) { + [pscustomobject]@{ Kind = 'RegistryDefault'; Scope = ''; Target = $extensionKey; Admin = $admin; Label = "$extension file association" } + } + } + } + } + + $startMenus = @( + @{ Dir = [Environment]::GetFolderPath('Programs'); Admin = $false }, + @{ Dir = [Environment]::GetFolderPath('CommonPrograms'); Admin = $true } + ) + foreach ($menu in $startMenus) { + if (-not $menu.Dir) { + continue + } + foreach ($folder in @(Get-ChildItem -LiteralPath $menu.Dir -Directory -Filter 'Ruby *' -ErrorAction SilentlyContinue)) { + if (Test-DtvemShortcutFolder -Folder $folder.FullName -Roots $Roots) { + [pscustomobject]@{ Kind = 'Folder'; Scope = ''; Target = $folder.FullName; Admin = $menu.Admin; Label = "Start menu folder `"$($folder.Name)`"" } + } + } + } +} + +function Send-EnvironmentChange { + # Tell running applications (Explorer, new terminals) that the + # environment changed, as [Environment]::SetEnvironmentVariable would. + $signature = '[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + $native = Add-Type -MemberDefinition $signature -Name 'NativeMethods' -Namespace 'DtvemUninstaller' -PassThru + $result = [UIntPtr]::Zero + [void]$native::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) +} + +function Clear-PathEntry { + # Takes the given entries off the User or System PATH in the registry, + # keeping every other entry as written. Windows only expands %VAR% + # entries such as %SystemRoot%\system32 in a REG_EXPAND_SZ value, so PATH + # is written back as REG_EXPAND_SZ when it was stored that way or holds a + # %VAR% reference ([Environment]::SetEnvironmentVariable would write + # REG_SZ). + param([string]$Scope, [string[]]$Entry) + + if ($Scope -eq 'System') { + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true) + } + else { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) + } + if (-not $key) { + throw "Could not open the $Scope environment in the registry" + } + try { + if ($key.GetValueNames() -notcontains 'Path') { + return + } + $value = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $kind = $key.GetValueKind('Path') + $newValue = @($value -split ';' | Where-Object { $Entry -notcontains $_ }) -join ';' + if ($newValue -eq $value) { + return + } + + if (($Scope -eq 'User') -and -not $newValue.Trim(';').Trim()) { + # The User PATH only held dtvem's entries: remove it, as it was + # before dtvem was installed + $key.DeleteValue('Path') + } + elseif (($kind -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) -or ($newValue -match '%[^%;]+%')) { + $key.SetValue('Path', $newValue, [Microsoft.Win32.RegistryValueKind]::ExpandString) + } + else { + $key.SetValue('Path', $newValue, [Microsoft.Win32.RegistryValueKind]::String) + } + } + finally { + $key.Close() + } + + try { + Send-EnvironmentChange + } + catch { + # Only running applications miss the change; new terminals still see it + Write-Verbose "Could not announce the PATH change: $_" + } +} + +function Clear-Leftover { + # Removes one item found by Find-Leftover + param([object]$Leftover) + + switch ($Leftover.Kind) { + 'Path' { + Clear-PathEntry -Scope $Leftover.Scope -Entry @($Leftover.Target) + } + 'RegistryDefault' { + # Target is HKCU:\ or HKLM:\ + $hive, $subKey = $Leftover.Target -split ':\\', 2 + $base = if ($hive -eq 'HKLM') { [Microsoft.Win32.Registry]::LocalMachine } else { [Microsoft.Win32.Registry]::CurrentUser } + $key = $base.OpenSubKey($subKey, $true) + if ($key) { + try { + $key.DeleteValue('', $false) + } + finally { + $key.Close() + } + } + } + default { + Remove-Item -LiteralPath $Leftover.Target -Recurse -Force + } + } +} + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-ElevatedCleanupScript { + # A standalone script that removes the given leftovers + param([object[]]$Leftover) + + $items =($Leftover | ForEach-Object { + $kind = [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_.Kind) + $scope = [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_.Scope) + $target = [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_.Target) + " [pscustomobject]@{ Kind = '$kind'; Scope = '$scope'; Target = '$target' }" + }) -join ",`n" + + return @" +`$ErrorActionPreference = 'Stop' +function Send-EnvironmentChange { +${function:Send-EnvironmentChange} +} +function Clear-PathEntry { +${function:Clear-PathEntry} +} +function Clear-Leftover { +${function:Clear-Leftover} +} +foreach (`$leftover in @( +$items +)) { + try { + Clear-Leftover -Leftover `$leftover + } + catch { + Write-Warning `$_ + } +} +"@ +} + +function Invoke-ElevatedCleanup { + # Removes leftovers that need administrator rights from an elevated + # PowerShell, which Windows asks the user to allow (UAC) + param([object[]]$Leftover) + + $elevatedScript = Get-ElevatedCleanupScript -Leftover $Leftover + $scriptPath =Join-Path ([System.IO.Path]::GetTempPath()) "dtvem-uninstall-$([guid]::NewGuid()).ps1" + [System.IO.File]::WriteAllText($scriptPath, $elevatedScript, [System.Text.Encoding]::UTF8) + try { + $shell = if ($PSVersionTable.PSEdition -eq 'Core') { Join-Path $PSHOME 'pwsh.exe' } else { Join-Path $PSHOME 'powershell.exe' } + Start-Process -FilePath $shell -Verb RunAs -Wait -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$scriptPath`"" + } + finally { + Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue + } +} + +function Uninstall-DtvemRoot { + # Removes a root (see Get-RootEntry), and reports whether it is gone + param([string]$Root) + + foreach ($path in @(Get-RootEntry -Root $Root)) { + if (Test-Path -LiteralPath $path -PathType Container) { + # rd removes read-only files and paths longer than 260 characters, + # and removes a junction or symlink (such as an `npm link`) + # without deleting what it points to. Remove-Item in Windows + # PowerShell 5.1 can follow a junction and empty its target. + $output = & cmd.exe /d /c "rd /s /q `"$path`" 2>&1" + if (Test-Path -LiteralPath $path) { + $output | Select-Object -First 5 | ForEach-Object { Write-Host " $_" } + } + } + else { + try { + Remove-Item -LiteralPath $path -Force + } + catch { + Write-Host " $($_.Exception.Message)" + } + } + } + + if (-not (Test-OwnedRoot -Root $Root)) { + foreach ($dir in (Join-Path $Root 'bin'), $Root) { + if ((Test-Path -LiteralPath $dir) -and -not (Get-ChildItem -LiteralPath $dir -Force)) { + Remove-Item -LiteralPath $dir -Force -ErrorAction SilentlyContinue + } + } + } + + return @(Get-RootEntry -Root $Root).Count -eq 0 +} + +function Confirm-Uninstall { + try { + $reply = Read-Host 'Remove dtvem? [y/N]' + } + catch { + Write-Error-Custom "Could not ask for confirmation: $($_.Exception.Message)" + Write-Info 'To uninstall without being asked, run:' + Write-Host ' iex "& { $(irm https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/uninstall.ps1) } -Yes"' + throw 'dtvem was not uninstalled' + } + Write-Host "" + return $reply -match '^\s*(y|yes)\s*$' +} + +function Show-Untouched { + # Reports dtvem-related things this script leaves alone + $others = @(Get-Command dtvem -CommandType Application -All -ErrorAction SilentlyContinue | ForEach-Object { $_.Source }) + if ($others.Count -gt 0) { + Write-Host "" + Write-Warning-Custom "Another dtvem is still on your PATH: $($others -join ', ')" + Write-Info "It wasn't installed by the dtvem installer. Remove it the way you installed it." + } + + $variables = @() + $userKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + if ($userKey) { + $variables += @($userKey.GetValueNames() | Where-Object { $_ -like 'DTVEM_*' } | ForEach-Object { "$_ (User)" }) + $userKey.Close() + } + $systemKey = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment') + if ($systemKey) { + $variables += @($systemKey.GetValueNames() | Where-Object { $_ -like 'DTVEM_*' } | ForEach-Object { "$_ (System)" }) + $systemKey.Close() + } + if ($variables.Count -gt 0) { + Write-Host "" + Write-Warning-Custom "These environment variables are still set: $($variables -join ', ')" + Write-Info "Remove them in System Properties > Environment Variables if you no longer need them." + } +} + +function Invoke-DtvemUninstall { + param([switch]$Yes) + + $ErrorActionPreference = 'Stop' + + Write-Host "" + Write-Host "========================================" -ForegroundColor Blue + Write-Host " dtvem uninstaller" -ForegroundColor Blue + Write-Host "========================================" -ForegroundColor Blue + Write-Host "" + + $roots = @(Get-DtvemRoot) + $leftovers = @(Find-Leftover -Roots $roots) + + if (($roots.Count -eq 0) -and ($leftovers.Count -eq 0)) { + Write-Success "dtvem is not installed: there is nothing to remove" + Show-Untouched + Write-Host "" + return + } + + Write-Info "This will remove:" + Write-Host "" + foreach ($root in $roots) { + if (Test-OwnedRoot -Root $root) { + Write-Host " $root" + } + else { + Write-Host " dtvem files in ${root}:" + Get-RootEntry -Root $root | ForEach-Object { Write-Host " $_" } + } + $runtimes = @(Get-InstalledRuntime -Root $root) + if ($runtimes.Count -gt 0) { + Write-Host " Installed runtimes, with their global packages:" + $runtimes | ForEach-Object { Write-Host " $_" } + } + Write-Host "" + } + if ($leftovers.Count -gt 0) { + foreach ($leftover in $leftovers) { + $note = if ($leftover.Admin) { ' (needs administrator rights)' } else { '' } + Write-Host " $($leftover.Label)$note" + } + Write-Host "" + } + + if (-not $Yes -and -not (Confirm-Uninstall)) { + Write-Info "Canceled: nothing was removed" + return + } + + $failed = $false + + # A directory can't be removed while it's the working directory + $here = (Get-Location).ProviderPath + foreach ($root in $roots) { + if ($here -and (Test-UnderDirectory -Path $here -Directory $root)) { + Set-Location -LiteralPath $env:USERPROFILE + [Environment]::CurrentDirectory = $env:USERPROFILE + Write-Info "Changed directory to $env:USERPROFILE, out of $root" + } + } + + foreach ($root in $roots) { + if (-not (Uninstall-DtvemRoot -Root $root)) { + Write-Error-Custom "Could not remove everything in $root" + Write-Info "Close any program using it (such as a running node, python or ruby) and run the uninstaller again." + $failed = $true + } + elseif (Test-Path -LiteralPath $root) { + Write-Success "Removed dtvem from $root (other files in it were left alone)" + } + else { + Write-Success "Removed $root" + } + } + + $isAdmin = Test-Administrator + $elevate = @() + foreach ($leftover in $leftovers) { + if ($leftover.Admin -and -not $isAdmin) { + $elevate += $leftover + continue + } + try { + Clear-Leftover -Leftover $leftover + } + catch { + Write-Host " $($_.Exception.Message)" + } + } + if ($elevate.Count -gt 0) { + Write-Info "Asking for administrator rights to remove the rest..." + try { + Invoke-ElevatedCleanup -Leftover $elevate + } + catch { + Write-Warning-Custom "$($_.Exception.Message)" + } + } + + if ($leftovers.Count -gt 0) { + $remaining = @(Find-Leftover -Roots $roots | ForEach-Object { $_.Label }) + $notRemoved = @($leftovers | Where-Object { $remaining -contains $_.Label }) + foreach ($leftover in $leftovers) { + if ($notRemoved -contains $leftover) { + Write-Error-Custom "Could not remove the $($leftover.Label)" + } + else { + Write-Success "Removed the $($leftover.Label)" + } + } + if ($notRemoved.Count -gt 0) { + Write-Info "Run the uninstaller again from an administrator PowerShell to remove the rest." + $failed = $true + } + } + + # This PowerShell session's PATH, so dtvem is gone from it right away + $sessionPath = @($env:Path -split ';' | Where-Object { -not (Test-DtvemLocation -Path $_ -Roots $roots) }) -join ';' + if ($sessionPath -ne $env:Path) { + $env:Path = $sessionPath + } + + Show-Untouched + + Write-Host "" + if ($failed) { + throw 'dtvem was not completely removed. Fix the errors above and run the uninstaller again.' + } + + Write-Host "========================================" -ForegroundColor Green + Write-Host " dtvem has been uninstalled" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + Write-Info "Restart other open terminals so the PATH change takes effect there." + Write-Info "Version files in your projects (.dtvem\runtimes.json, .tool-versions, .nvmrc," + Write-Info ".python-version, .ruby-version) were left in place." + Write-Host "" +} + +Invoke-DtvemUninstall -Yes:$Yes diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..9d0381f --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,501 @@ +#!/bin/bash +set -e + +# dtvem uninstaller for macOS and Linux +# Usage: +# Standard: curl -fsSL https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/uninstall.sh | bash +# No prompt: curl -fsSL https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/uninstall.sh | bash -s -- --yes +# +# Removes everything dtvem puts on a machine: +# - the dtvem directory: the dtvem binaries, shims, every runtime version +# installed with dtvem (and its global packages), config and cache +# - the PATH lines the installer and `dtvem init` added to shell config files +# Project files (.dtvem/runtimes.json, .tool-versions, .nvmrc, ...) are left alone. + +ASSUME_YES=false + +usage() { + echo "Usage: uninstall.sh [--yes]" + echo "" + echo "Removes dtvem, every runtime version installed with it, and the PATH" + echo "lines it added to your shell config files." + echo "" + echo "Options:" + echo " -y, --yes Uninstall without asking for confirmation" + echo " -h, --help Show this help" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -y|--yes) + ASSUME_YES=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +info() { + echo -e "${CYAN}→${NC} $1" +} + +success() { + echo -e "${GREEN}✓${NC} $1" +} + +error() { + echo -e "${RED}✗${NC} $1" +} + +warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +# Shell config files the installer and `dtvem init` add PATH lines to +SHELL_CONFIGS=( + "$HOME/.bashrc" + "$HOME/.bash_profile" + "$HOME/.profile" + "$HOME/.zshrc" + "$HOME/.config/fish/config.fish" +) + +# dtvem roots found on this machine (see find_roots) +ROOTS=() + +# PATH lines found by scan_config, as ": " +CONFIG_BLOCKS=() + +# Prints a path without trailing slashes +strip_slashes() { + local path=$1 + while [ "${#path}" -gt 1 ] && [ "${path%/}" != "$path" ]; do + path=${path%/} + done + echo "$path" +} + +# Prints a directory with symlinks resolved +real_dir() { + (cd "$1" 2>/dev/null && pwd -P) +} + +# Whether a root is a directory dtvem owns outright, named dtvem or .dtvem. +# A DTVEM_ROOT with any other name may hold other files, so only the +# entries dtvem creates in it are removed. +owned_root() { + case "$(basename "$1")" in + dtvem|.dtvem) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Prints the paths that removing a root deletes, one per line +root_entries() { + local root=$1 entry + + if owned_root "$root"; then + if [ -e "$root" ]; then + echo "$root" + fi + return 0 + fi + + for entry in shims versions config cache tmp bin/dtvem bin/dtvem-shim; do + if [ -e "$root/$entry" ] || [ -L "$root/$entry" ]; then + echo "$root/$entry" + fi + done +} + +# Finds every dtvem root on this machine: DTVEM_ROOT, XDG_DATA_HOME/dtvem, +# ~/.local/share/dtvem (the Linux default) and ~/.dtvem (the macOS default, +# and where Linux installs lived before dtvem followed XDG). +find_roots() { + local candidate dir real existing duplicate home_real + + home_real=$(real_dir "$HOME") || home_real=$HOME + + for candidate in "$DTVEM_ROOT" "${XDG_DATA_HOME:+$XDG_DATA_HOME/dtvem}" "$HOME/.local/share/dtvem" "$HOME/.dtvem"; do + if [ -z "$candidate" ]; then + continue + fi + dir=$(strip_slashes "$candidate") + if [ ! -d "$dir" ]; then + continue + fi + + real=$(real_dir "$dir") || real=$dir + if [ "$real" = "/" ] || [ "$real" = "$home_real" ]; then + warning "Skipping $dir: it is / or your home directory" + continue + fi + + duplicate=false + for existing in "${ROOTS[@]}"; do + if [ "$(real_dir "$existing")" = "$real" ]; then + duplicate=true + fi + done + if [ "$duplicate" = true ]; then + continue + fi + + if [ -n "$(root_entries "$dir")" ]; then + ROOTS+=("$dir") + fi + done +} + +# Prints the directory a PATH line written by dtvem puts on PATH: the first +# double-quoted string, up to any ":". dtvem writes +# export PATH=":$PATH" (bash, zsh, sh) +# set -gx PATH "" $PATH (fish) +path_line_dir() { + local line=${1%$'\r'} dir + + case $line in + *PATH*\"*\"*) + ;; + *) + return 1 + ;; + esac + + dir=${line#*\"} + dir=${dir%%\"*} + dir=${dir%%:*} + case $dir in + \$HOME/*|\$\{HOME\}/*|\~/*) + dir="$HOME/${dir#*/}" + ;; + esac + + if [ -z "$dir" ]; then + return 1 + fi + strip_slashes "$dir" +} + +# Whether a directory dtvem put on PATH should come off it: it is in a root +# being removed, or it is gone and was in a dtvem directory (left behind by +# an earlier install). Another install's existing directory is kept. +removable_dir() { + local dir=$1 root + + for root in "${ROOTS[@]}"; do + case $dir in + "$root"|"$root"/*) + return 0 + ;; + esac + done + + case "/$dir/" in + */dtvem/*|*/.dtvem/*) + [ ! -e "$dir" ] + ;; + *) + return 1 + ;; + esac +} + +# scan_config FILE APPLY +# +# Finds the blocks dtvem wrote to a shell config file: a "# Added by dtvem" +# or "# Added by dtvem installer" line followed by the PATH line it added. +# Blocks whose PATH line names a directory being removed (see +# removable_dir) are added to CONFIG_BLOCKS. With APPLY=true the file is +# rewritten without them, and without the blank line dtvem wrote above each. +# Lines without the marker above them are never touched. +scan_config() { + local file=$1 apply=$2 + local marker='^[[:space:]]*# Added by dtvem( installer)?[[:space:]]*$' + local blank='^[[:space:]]*$' + local line next dir i n last found=0 + local -a lines out + + if [ ! -f "$file" ]; then + return 0 + fi + + lines=() + while IFS= read -r line || [ -n "$line" ]; do + lines+=("$line") + done < "$file" + + out=() + n=${#lines[@]} + i=0 + while [ "$i" -lt "$n" ]; do + line=${lines[$i]} + if [[ $line =~ $marker ]] && [ $((i + 1)) -lt "$n" ]; then + next=${lines[$((i + 1))]} + if dir=$(path_line_dir "$next") && removable_dir "$dir"; then + CONFIG_BLOCKS+=("$file: ${next%$'\r'}") + found=$((found + 1)) + last=$((${#out[@]} - 1)) + if [ "$last" -ge 0 ] && [[ ${out[$last]} =~ $blank ]]; then + unset "out[$last]" + fi + i=$((i + 2)) + continue + fi + fi + out+=("$line") + i=$((i + 1)) + done + + if [ "$apply" != true ] || [ "$found" -eq 0 ]; then + return 0 + fi + + # Write in place (not via a temp file and mv) so a config file that is + # a symlink, such as one managed in a dotfiles repo, stays a symlink. + if [ "${#out[@]}" -eq 0 ]; then + : > "$file" || return 1 + else + printf '%s\n' "${out[@]}" > "$file" || return 1 + fi +} + +# Prints the runtime versions installed under a root, one runtime per line +installed_runtimes() { + local root=$1 runtime_dir version_dir versions + + for runtime_dir in "$root"/versions/*/; do + if [ ! -d "$runtime_dir" ]; then + continue + fi + versions="" + for version_dir in "$runtime_dir"*/; do + if [ -d "$version_dir" ]; then + versions="$versions $(basename "$version_dir")" + fi + done + if [ -n "$versions" ]; then + echo "$(basename "$runtime_dir"):$versions" + fi + done +} + +# Describes what removing a root deletes +show_root() { + local root=$1 entry runtimes + + if owned_root "$root"; then + echo " $root" + else + echo " dtvem files in $root:" + while IFS= read -r entry; do + echo " $entry" + done < <(root_entries "$root") + fi + + runtimes=$(installed_runtimes "$root") + if [ -n "$runtimes" ]; then + echo " Installed runtimes, with their global packages:" + while IFS= read -r entry; do + echo " $entry" + done <<< "$runtimes" + fi + echo "" +} + +# Deletes a path, making read-only directories writable if rm can't get +# through them. Neither rm -rf nor chmod -R follows symlinks, so a link to a +# directory outside the root (such as an `npm link`) is removed, not its target. +delete_path() { + if rm -rf "$1" 2>/dev/null; then + return 0 + fi + chmod -R u+w "$1" 2>/dev/null || true + rm -rf "$1" +} + +# Removes a root (see root_entries), and reports whether it is gone +remove_root() { + local root=$1 entry + + while IFS= read -r entry; do + delete_path "$entry" || true + done < <(root_entries "$root") + + if ! owned_root "$root"; then + rmdir "$root/bin" 2>/dev/null || true + rmdir "$root" 2>/dev/null || true + fi + + [ -z "$(root_entries "$root")" ] +} + +# Asks whether to go ahead, on the terminal: piped into bash, stdin is the +# script itself +confirm() { + local reply + + if [ "$ASSUME_YES" = true ]; then + return 0 + fi + + if ! (: < /dev/tty) 2>/dev/null; then + error "There is no terminal to ask for confirmation on" + info "To uninstall without being asked, run:" + echo " curl -fsSL https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/uninstall.sh | bash -s -- --yes" + exit 1 + fi + + printf "Remove dtvem? [y/N] " + read -r reply < /dev/tty || reply="" + echo "" + case $reply in + y|Y|yes|Yes|YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Reports what is still around that this script leaves alone +report_leftovers() { + local config matches line other found=false + + for config in "${SHELL_CONFIGS[@]}"; do + if [ ! -f "$config" ]; then + continue + fi + if ! matches=$(grep -in 'dtvem' "$config" 2>/dev/null); then + continue + fi + if [ "$found" = false ]; then + echo "" + warning "These lines in your shell config still mention dtvem and were left alone." + info "Remove them if you no longer need them:" + found=true + fi + while IFS= read -r line; do + echo " $config:$line" + done <<< "$matches" + done + + other=$(command -v dtvem 2>/dev/null) || other="" + if [ -n "$other" ]; then + echo "" + warning "Another dtvem is still on your PATH: $other" + info "It wasn't installed by the dtvem installer. Remove it the way you installed it." + fi + + if [ -n "$DTVEM_ROOT" ]; then + echo "" + info "DTVEM_ROOT is still set in your environment. Unset it where you set it." + fi +} + +main() { + local root block config before removed failed=false + + echo "" + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE} dtvem uninstaller${NC}" + echo -e "${BLUE}========================================${NC}" + echo "" + + find_roots + for config in "${SHELL_CONFIGS[@]}"; do + scan_config "$config" false + done + + if [ "${#ROOTS[@]}" -eq 0 ] && [ "${#CONFIG_BLOCKS[@]}" -eq 0 ]; then + success "dtvem is not installed: there is nothing to remove" + report_leftovers + echo "" + return 0 + fi + + info "This will remove:" + echo "" + for root in "${ROOTS[@]}"; do + show_root "$root" + done + if [ "${#CONFIG_BLOCKS[@]}" -gt 0 ]; then + echo " PATH lines added by dtvem:" + for block in "${CONFIG_BLOCKS[@]}"; do + echo " $block" + done + echo "" + fi + + if ! confirm; then + info "Canceled: nothing was removed" + return 0 + fi + + for root in "${ROOTS[@]}"; do + if ! remove_root "$root"; then + error "Could not remove everything in $root" + failed=true + elif [ -e "$root" ]; then + success "Removed dtvem from $root (other files in it were left alone)" + else + success "Removed $root" + fi + done + + CONFIG_BLOCKS=() + for config in "${SHELL_CONFIGS[@]}"; do + before=${#CONFIG_BLOCKS[@]} + if scan_config "$config" true; then + removed=$((${#CONFIG_BLOCKS[@]} - before)) + if [ "$removed" -gt 0 ]; then + success "Removed $removed PATH line(s) from $config" + fi + else + error "Could not update $config" + failed=true + fi + done + + report_leftovers + + echo "" + if [ "$failed" = true ]; then + error "dtvem was not completely removed. Fix the errors above and run the uninstaller again." + echo "" + exit 1 + fi + + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} dtvem has been uninstalled${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + info "Restart your terminal so the PATH change takes effect." + info "Version files in your projects (.dtvem/runtimes.json, .tool-versions, .nvmrc," + info ".python-version, .ruby-version) were left in place." + echo "" +} + +main From fe79d02be36ca1869c552d3e2fdf88eac493c190 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Sat, 7 Nov 2026 11:32:02 -0500 Subject: [PATCH 2/2] ci(uninstall): give the Windows test a REG_EXPAND_SZ System PATH The Windows uninstall test expected the System PATH to still be REG_EXPAND_SZ afterwards, but GitHub's Windows runner images store it as REG_SZ with no %VAR% entries, so the check failed although the uninstaller kept the value's type. Make the System PATH REG_EXPAND_SZ with a %SystemRoot% entry before `dtvem init`, log the type it had, and check the uninstaller keeps both the type and the unexpanded entry, as the User PATH check already does. --- .github/workflows/integration-test-uninstall.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/integration-test-uninstall.yml b/.github/workflows/integration-test-uninstall.yml index 2897cd6..9e67c00 100644 --- a/.github/workflows/integration-test-uninstall.yml +++ b/.github/workflows/integration-test-uninstall.yml @@ -159,6 +159,15 @@ jobs: $key.SetValue('Path', "$root\bin;C:\old-install\.dtvem\shims;$userPath;%USERPROFILE%\keep-me", 'ExpandString') $key.Close() + # System PATH: runner images store it as REG_SZ with no %VAR% + # entries. Make it REG_EXPAND_SZ with one, so the test shows the + # uninstaller keeps both. + $system = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true) + "System PATH was stored as $($system.GetValueKind('Path'))" + $systemPath = $system.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $system.SetValue('Path', "$systemPath;%SystemRoot%\keep-me", 'ExpandString') + $system.Close() + # System PATH: the shims directory (the runner is an administrator) & "$root\bin\dtvem.exe" init -y if ($LASTEXITCODE) { exit $LASTEXITCODE } @@ -229,6 +238,7 @@ jobs: Check ($userKind -eq 'ExpandString') "User PATH is still REG_EXPAND_SZ ($userKind)" Check ($systemPath -notmatch 'dtvem') "System PATH has no dtvem entries: $systemPath" Check ($systemPath -match 'system32') 'System PATH keeps its other entries' + Check ($systemPath -like '*%SystemRoot%\keep-me*') 'System PATH %VAR% entry kept unexpanded' Check ($systemKind -eq 'ExpandString') "System PATH is still REG_EXPAND_SZ ($systemKind)" Check (-not (Test-Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\RubyInstaller-3.3-x64-mingw-ucrt_is1')) 'Programs and Features entry removed' Check (-not (Test-Path 'HKCU:\Software\Classes\RubyFile')) 'RubyFile file type removed'