diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1301fc9e1d..201c9361a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -518,6 +518,50 @@ jobs: exit /b 1 ) + - name: Test implode (bash) + shell: bash + run: | + vp implode --yes + ls -la ~/ + VP_HOME="${USERPROFILE:-$HOME}/.vite-plus" + if [ -d "$VP_HOME" ]; then + echo "Error: $VP_HOME still exists after implode" + exit 1 + fi + # Reinstall + pnpm bootstrap-cli:ci + vp --version + + - name: Test implode (powershell) + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: | + vp implode --yes + Start-Sleep -Seconds 5 + dir "$env:USERPROFILE\" + if (Test-Path "$env:USERPROFILE\.vite-plus") { + Write-Error "~/.vite-plus still exists after implode" + exit 1 + } + pnpm bootstrap-cli:ci + vp --version + + - name: Test implode (cmd) + if: ${{ matrix.os == 'windows-latest' }} + shell: cmd + run: | + REM vp.exe renames its own parent directory; cmd.exe may report + REM "The system cannot find the path specified" on exit — ignore it. + vp implode --yes || ver >NUL + timeout /T 5 /NOBREAK >NUL + dir "%USERPROFILE%\" + if exist "%USERPROFILE%\.vite-plus" ( + echo Error: .vite-plus still exists after implode + exit /b 1 + ) + pnpm bootstrap-cli:ci + vp --version + install-e2e-test: name: Local CLI `vp install` E2E test needs: diff --git a/Cargo.lock b/Cargo.lock index ea865f37d3..09c901945b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7251,6 +7251,7 @@ dependencies = [ "chrono", "clap", "crossterm", + "directories", "flate2", "junction", "node-semver", diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index 82ab505e84..b3699f9989 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } +directories = { workspace = true } flate2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index f7a11df605..e476c073a4 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -679,6 +679,13 @@ pub enum Commands { #[arg(long)] registry: Option, }, + + /// Remove vp and all related data + Implode { + /// Skip confirmation prompt + #[arg(long, short = 'y')] + yes: bool, + }, } /// Arguments for the `env` command @@ -1965,6 +1972,7 @@ pub async fn run_command_with_options( }) .await } + Commands::Implode { yes } => commands::implode::execute(yes), } } diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs new file mode 100644 index 0000000000..9a84457db4 --- /dev/null +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -0,0 +1,416 @@ +//! `vp implode` — completely remove vp and all its data from this system. + +use std::{ + io::{IsTerminal, Write}, + process::ExitStatus, +}; + +use directories::BaseDirs; +use owo_colors::OwoColorize; +use vite_path::AbsolutePathBuf; +use vite_shared::output; +use vite_str::Str; + +use crate::{cli::exit_status, error::Error}; + +/// All shell profile paths to check, with `is_fish` flag. +const SHELL_PROFILES: &[(&str, bool)] = &[ + (".zshenv", false), + (".zshrc", false), + (".bash_profile", false), + (".bashrc", false), + (".profile", false), + (".config/fish/config.fish", true), +]; + +/// Comment marker written by the install script above the sourcing line. +const VITE_PLUS_COMMENT: &str = "# Vite+ bin"; + +pub fn execute(yes: bool) -> Result { + let Ok(home_dir) = vite_shared::get_vite_plus_home() else { + output::info("vite-plus is not installed (could not determine home directory)"); + return Ok(exit_status(0)); + }; + + if !home_dir.as_path().exists() { + output::info("vite-plus is not installed (directory does not exist)"); + return Ok(exit_status(0)); + } + + // Resolve user home for shell profile paths + let base_dirs = BaseDirs::new() + .ok_or_else(|| Error::Other("Could not determine user home directory".into()))?; + let user_home = AbsolutePathBuf::new(base_dirs.home_dir().to_path_buf()).unwrap(); + + // Collect shell profiles that contain Vite+ lines (content cached for cleaning) + let affected_profiles = collect_affected_profiles(&user_home); + + // Confirmation + if !yes && !confirm_implode(&home_dir, &affected_profiles)? { + return Ok(exit_status(0)); + } + + // Clean shell profiles using cached content (no re-read) + clean_affected_profiles(&affected_profiles); + + // Remove Windows PATH entry + #[cfg(windows)] + { + let bin_path = home_dir.join("bin"); + if let Err(e) = remove_windows_path_entry(&bin_path) { + output::warn(&vite_str::format!("Failed to clean Windows PATH: {e}")); + } else { + output::success("Removed vite-plus from Windows PATH"); + } + } + + // Remove the directory + remove_vite_plus_dir(&home_dir)?; + + output::raw(""); + output::success("vite-plus has been removed from your system."); + output::note("Restart your terminal to apply shell changes."); + + Ok(exit_status(0)) +} + +/// A shell profile that contains Vite+ sourcing lines. +struct AffectedProfile { + /// Display name (e.g. ".zshrc", ".config/fish/config.fish"). + name: Str, + /// Absolute path to the file. + path: AbsolutePathBuf, + /// Whether this is a fish shell profile. + is_fish: bool, + /// File content read during detection (reused for cleaning). + content: Str, +} + +/// Collect shell profiles that contain Vite+ sourcing lines. +/// Content is cached so we don't need to re-read during cleaning. +fn collect_affected_profiles(user_home: &AbsolutePathBuf) -> Vec { + let mut affected = Vec::new(); + for &(name, is_fish) in SHELL_PROFILES { + let path = user_home.join(name); + // Read directly — if the file doesn't exist, read_to_string returns Err + // which is_ok_and handles gracefully (no redundant exists() check). + if let Some(content) = + std::fs::read_to_string(&path).ok().filter(|c| has_vite_plus_lines(c, is_fish)) + { + affected.push(AffectedProfile { + name: Str::from(name), + path, + is_fish, + content: Str::from(content), + }); + } + } + affected +} + +/// Show confirmation prompt and require the user to type "uninstall". +/// Returns `Ok(true)` if confirmed, `Ok(false)` if aborted. +fn confirm_implode( + home_dir: &AbsolutePathBuf, + affected_profiles: &[AffectedProfile], +) -> Result { + if !std::io::stdin().is_terminal() { + return Err(Error::UserMessage( + "Cannot prompt for confirmation: stdin is not a TTY. Use --yes to skip confirmation." + .into(), + )); + } + + output::warn("This will completely remove vite-plus from your system!"); + output::raw(""); + output::raw(&vite_str::format!(" Directory: {}", home_dir.as_path().display())); + if !affected_profiles.is_empty() { + output::raw(" Shell profiles to clean:"); + for profile in affected_profiles { + output::raw(&vite_str::format!(" - ~/{}", profile.name)); + } + } + output::raw(""); + output::raw(&vite_str::format!("Type {} to confirm:", "uninstall".bold())); + + // String is needed here for read_line + #[expect(clippy::disallowed_types)] + let mut input = String::new(); + std::io::stdout().flush()?; + std::io::stdin().read_line(&mut input)?; + + if input.trim() != "uninstall" { + output::info("Aborted."); + return Ok(false); + } + + Ok(true) +} + +/// Clean all affected shell profiles using cached content (no re-read). +fn clean_affected_profiles(affected_profiles: &[AffectedProfile]) { + for profile in affected_profiles { + let cleaned = remove_vite_plus_lines(&profile.content, profile.is_fish); + match std::fs::write(&profile.path, cleaned.as_bytes()) { + Ok(()) => output::success(&vite_str::format!("Cleaned ~/{}", profile.name)), + Err(e) => { + output::warn(&vite_str::format!("Failed to clean ~/{}: {e}", profile.name)); + } + } + } +} + +/// Remove the ~/.vite-plus directory. +fn remove_vite_plus_dir(home_dir: &AbsolutePathBuf) -> Result<(), Error> { + #[cfg(unix)] + { + match std::fs::remove_dir_all(home_dir) { + Ok(()) => { + output::success(&vite_str::format!("Removed {}", home_dir.as_path().display())); + Ok(()) + } + Err(e) => { + output::error(&vite_str::format!( + "Failed to remove {}: {e}", + home_dir.as_path().display() + )); + Err(Error::CommandExecution(e)) + } + } + } + + #[cfg(windows)] + { + // On Windows, the running `vp` binary is always locked, so direct + // removal will fail. Rename the directory first so the original path + // is immediately free for reinstall, then schedule deletion of the + // renamed directory via a detached process. + let trash_path = + home_dir.as_path().with_extension(vite_str::format!("removing-{}", std::process::id())); + if let Err(e) = std::fs::rename(home_dir, &trash_path) { + output::error(&vite_str::format!( + "Failed to rename {} for removal: {e}", + home_dir.as_path().display() + )); + return Err(Error::CommandExecution(e)); + } + + match spawn_deferred_delete(&trash_path) { + Ok(_) => { + output::success(&vite_str::format!( + "Scheduled removal of {} (will complete shortly)", + home_dir.as_path().display() + )); + } + Err(e) => { + output::error(&vite_str::format!( + "Failed to schedule removal of {}: {e}", + home_dir.as_path().display() + )); + return Err(Error::CommandExecution(e)); + } + } + Ok(()) + } +} + +/// Build a `cmd.exe` script that retries `rmdir /S /Q` up to 10 times with +/// 1-second pauses, exiting as soon as the directory is gone. +#[cfg(windows)] +fn build_deferred_delete_script(trash_path: &std::path::Path) -> Str { + let p = trash_path.to_string_lossy(); + vite_str::format!( + "for /L %i in (1,1,10) do @(\ + if not exist \"{p}\" exit /B 0 & \ + rmdir /S /Q \"{p}\" 2>NUL & \ + if not exist \"{p}\" exit /B 0 & \ + timeout /T 1 /NOBREAK >NUL\ + )" + ) +} + +/// Spawn a detached `cmd.exe` process that retries deletion of `trash_path`. +#[cfg(windows)] +fn spawn_deferred_delete(trash_path: &std::path::Path) -> std::io::Result { + let script = build_deferred_delete_script(trash_path); + std::process::Command::new("cmd.exe") + .args(["/C", &script]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() +} + +/// Check if file content contains Vite+ sourcing lines. +fn has_vite_plus_lines(content: &str, is_fish: bool) -> bool { + let pattern = if is_fish { ".vite-plus/env.fish" } else { ".vite-plus/env\"" }; + content.lines().any(|line| line.contains(pattern)) +} + +/// Remove Vite+ lines from content, returning the cleaned string. +fn remove_vite_plus_lines(content: &str, is_fish: bool) -> Str { + let pattern = if is_fish { ".vite-plus/env.fish" } else { ".vite-plus/env\"" }; + let lines: Vec<&str> = content.lines().collect(); + let mut remove_indices = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + if line.contains(pattern) { + remove_indices.push(i); + // Also remove the comment line above + if i > 0 && lines[i - 1].contains(VITE_PLUS_COMMENT) { + remove_indices.push(i - 1); + // Also remove the blank line before the comment + if i > 1 && lines[i - 2].trim().is_empty() { + remove_indices.push(i - 2); + } + } + } + } + + if remove_indices.is_empty() { + return Str::from(content); + } + + #[expect(clippy::disallowed_types)] + let mut result = String::with_capacity(content.len()); + for (i, line) in lines.iter().enumerate() { + if !remove_indices.contains(&i) { + result.push_str(line); + result.push('\n'); + } + } + + // Preserve trailing newline behavior of original + if !content.ends_with('\n') && result.ends_with('\n') { + result.pop(); + } + + Str::from(result) +} + +/// Remove `.vite-plus\bin` from the Windows User PATH via PowerShell. +#[cfg(windows)] +fn remove_windows_path_entry(bin_path: &vite_path::AbsolutePath) -> std::io::Result<()> { + let bin_str = bin_path.as_path().to_string_lossy(); + let script = vite_str::format!( + "[Environment]::SetEnvironmentVariable('Path', \ + ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | \ + Where-Object {{ $_ -ne '{bin_str}' }}) -join ';', 'User')" + ); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .status()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, "PowerShell command failed")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_remove_vite_plus_lines_posix() { + let content = "# existing config\nexport FOO=bar\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n"; + let result = remove_vite_plus_lines(content, false); + assert_eq!(&*result, "# existing config\nexport FOO=bar\n"); + } + + #[test] + fn test_remove_vite_plus_lines_fish() { + let content = "# fish config\nset -x FOO bar\n\n# Vite+ bin (https://viteplus.dev)\nsource \"$HOME/.vite-plus/env.fish\"\n"; + let result = remove_vite_plus_lines(content, true); + assert_eq!(&*result, "# fish config\nset -x FOO bar\n"); + } + + #[test] + fn test_remove_vite_plus_lines_no_match() { + let content = "# just a normal config\nexport PATH=/usr/bin\n"; + let result = remove_vite_plus_lines(content, false); + assert_eq!(&*result, content); + } + + #[test] + fn test_remove_vite_plus_lines_absolute_path() { + let content = "# existing\n. \"/home/user/.vite-plus/env\"\n"; + let result = remove_vite_plus_lines(content, false); + assert_eq!(&*result, "# existing\n"); + } + + #[test] + fn test_remove_vite_plus_lines_preserves_surrounding() { + let content = "# before\nexport A=1\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n# after\nexport B=2\n"; + let result = remove_vite_plus_lines(content, false); + assert_eq!(&*result, "# before\nexport A=1\n# after\nexport B=2\n"); + } + + #[test] + fn test_clean_affected_profiles_integration() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let profile_path = temp_path.join(".zshrc"); + let original = "# my config\nexport FOO=bar\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n"; + std::fs::write(&profile_path, original).unwrap(); + + let profiles = vec![AffectedProfile { + name: Str::from(".zshrc"), + path: profile_path.clone(), + is_fish: false, + content: Str::from(original), + }]; + clean_affected_profiles(&profiles); + + let result = std::fs::read_to_string(&profile_path).unwrap(); + assert_eq!(result, "# my config\nexport FOO=bar\n"); + assert!(!result.contains(".vite-plus/env")); + } + + #[test] + fn test_remove_vite_plus_dir_success() { + let temp_dir = tempfile::tempdir().unwrap(); + let dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let target = dir.join("to-remove"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("file.txt"), "data").unwrap(); + + let result = remove_vite_plus_dir(&target); + assert!(result.is_ok()); + assert!(!target.as_path().exists()); + } + + #[test] + fn test_remove_vite_plus_dir_nonexistent() { + let temp_dir = tempfile::tempdir().unwrap(); + let dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let target = dir.join("does-not-exist"); + + let result = remove_vite_plus_dir(&target); + assert!(result.is_err()); + } + + #[test] + #[cfg(windows)] + fn test_build_deferred_delete_script() { + let path = std::path::Path::new(r"C:\Users\test\.vite-plus.removing-1234"); + let script = build_deferred_delete_script(path); + assert!(script.contains("rmdir /S /Q")); + assert!(script.contains(r"C:\Users\test\.vite-plus.removing-1234")); + assert!(script.contains("for /L %i in (1,1,10)")); + assert!(script.contains("timeout /T 1 /NOBREAK")); + } + + #[test] + fn test_execute_not_installed() { + let temp_dir = tempfile::tempdir().unwrap(); + let non_existent = temp_dir.path().join("does-not-exist"); + // Use thread-local test guard instead of mutating process-global env + let _guard = vite_shared::EnvConfig::test_guard( + vite_shared::EnvConfig::for_test_with_home(&non_existent), + ); + let result = execute(true); + assert!(result.is_ok()); + assert!(result.unwrap().success()); + } +} diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 2f2d06611d..11794f6f9a 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -145,6 +145,7 @@ pub mod env; pub mod vpx; // Self-Management +pub mod implode; pub mod upgrade; // Category C: Local CLI Delegation diff --git a/crates/vite_global_cli/src/help.rs b/crates/vite_global_cli/src/help.rs index e3cc30d6ff..6b26c08097 100644 --- a/crates/vite_global_cli/src/help.rs +++ b/crates/vite_global_cli/src/help.rs @@ -453,7 +453,10 @@ pub fn top_level_help_doc() -> HelpDoc { ), section_rows( "Maintain", - vec![row("upgrade", "Update vp itself to the latest version")], + vec![ + row("upgrade", "Update vp itself to the latest version"), + row("implode", "Remove vp and all related data"), + ], ), ], } diff --git a/packages/cli/snap-tests-global/cli-helper-message/snap.txt b/packages/cli/snap-tests-global/cli-helper-message/snap.txt index eefe1b4d55..93f0a5a250 100644 --- a/packages/cli/snap-tests-global/cli-helper-message/snap.txt +++ b/packages/cli/snap-tests-global/cli-helper-message/snap.txt @@ -44,6 +44,7 @@ Manage Dependencies: Maintain: upgrade Update vp itself to the latest version + implode Remove vp and all related data Options: -V, --version Print version diff --git a/packages/cli/src/config/__tests__/hooks.spec.ts b/packages/cli/src/config/__tests__/hooks.spec.ts index 6e27ee7ffb..8ec28d8f3f 100644 --- a/packages/cli/src/config/__tests__/hooks.spec.ts +++ b/packages/cli/src/config/__tests__/hooks.spec.ts @@ -1,4 +1,6 @@ -import { existsSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -15,31 +17,30 @@ function countDirnameCalls(script: string): number { } describe('install', () => { - it('should create _/pre-commit but not pre-commit in hooks dir root', () => { - const { execSync } = require('node:child_process'); - const { mkdtempSync, rmSync } = require('node:fs'); - const { tmpdir } = require('node:os'); + it.skipIf(process.platform === 'win32')( + 'should create _/pre-commit but not pre-commit in hooks dir root', + () => { + const tmp = mkdtempSync(join(tmpdir(), 'hooks-test-')); + const originalCwd = process.cwd(); + try { + // Set up a temporary git repo + execSync('git init', { cwd: tmp, stdio: 'ignore' }); + process.chdir(tmp); - const tmp = mkdtempSync(join(tmpdir(), 'hooks-test-')); - const originalCwd = process.cwd(); - try { - // Set up a temporary git repo - execSync('git init', { cwd: tmp, stdio: 'ignore' }); - process.chdir(tmp); + const hooksDir = '.vite-hooks'; + const result = install(hooksDir); + expect(result.isError).toBe(false); - const hooksDir = '.vite-hooks'; - const result = install(hooksDir); - expect(result.isError).toBe(false); - - // install() creates the internal shim at _/pre-commit - expect(existsSync(join(tmp, hooksDir, '_', 'pre-commit'))).toBe(true); - // install() does NOT create pre-commit at the hooks dir root - expect(existsSync(join(tmp, hooksDir, 'pre-commit'))).toBe(false); - } finally { - process.chdir(originalCwd); - rmSync(tmp, { recursive: true, force: true }); - } - }); + // install() creates the internal shim at _/pre-commit + expect(existsSync(join(tmp, hooksDir, '_', 'pre-commit'))).toBe(true); + // install() does NOT create pre-commit at the hooks dir root + expect(existsSync(join(tmp, hooksDir, 'pre-commit'))).toBe(false); + } finally { + process.chdir(originalCwd); + rmSync(tmp, { recursive: true, force: true }); + } + }, + ); }); describe('hookScript', () => { diff --git a/rfcs/implode-command.md b/rfcs/implode-command.md new file mode 100644 index 0000000000..e0ff23d224 --- /dev/null +++ b/rfcs/implode-command.md @@ -0,0 +1,264 @@ +# RFC: Implode (Self-Uninstall) Command + +## Status + +Implemented + +## Background + +Vite+ currently has no built-in way to uninstall itself. Users must manually delete `~/.vite-plus/` and hunt through shell profiles (`.zshrc`, `.bashrc`, `.profile`, `config.fish`, etc.) to remove the sourcing lines added by `install.sh`. This is error-prone and leaves artifacts behind. + +A native `vp implode` command cleanly removes all Vite+ artifacts from the system in a single step. + +### What the Install Script Writes + +The `install.sh` script adds the following block to shell profiles: + +``` + +# Vite+ bin (https://viteplus.dev) +. "$HOME/.vite-plus/env" +``` + +For fish shell: + +``` + +# Vite+ bin (https://viteplus.dev) +source "$HOME/.vite-plus/env.fish" +``` + +On Windows, `install.ps1` adds `~/.vite-plus/bin` to the User PATH environment variable. + +## Goals + +1. Provide a single command to completely remove Vite+ from the system +2. Clean up shell profiles (remove sourcing lines and associated comments) +3. Remove the `~/.vite-plus/` directory and all its contents +4. Handle Windows-specific cleanup (User PATH, locked binary) +5. Require explicit confirmation to prevent accidental uninstalls + +## Non-Goals + +1. Selective removal (e.g., keeping downloaded Node.js versions) +2. Backup before removal +3. Removing project-level `vite-plus` npm dependencies + +## User Stories + +### Story 1: Interactive Uninstall + +```bash +$ vp implode +warn: This will completely remove vite-plus from your system! + + Directory: /home/user/.vite-plus + Shell profiles to clean: + - ~/.zshenv + - ~/.bashrc + +Type uninstall to confirm: +uninstall +✓ Cleaned ~/.zshenv +✓ Cleaned ~/.bashrc +✓ Removed /home/user/.vite-plus + +✓ vite-plus has been removed from your system. +note: Restart your terminal to apply shell changes. +``` + +### Story 2: Non-Interactive Uninstall (CI) + +```bash +$ vp implode --yes +✓ Cleaned ~/.zshenv +✓ Cleaned ~/.bashrc +✓ Removed /home/user/.vite-plus + +✓ vite-plus has been removed from your system. +note: Restart your terminal to apply shell changes. +``` + +### Story 3: Not Installed + +```bash +$ vp implode --yes +info: vite-plus is not installed (directory does not exist) +``` + +### Story 4: Non-TTY Without --yes + +```bash +$ echo "" | vp implode +Cannot prompt for confirmation: stdin is not a TTY. Use --yes to skip confirmation. +``` + +## Technical Design + +### Command Interface + +``` +vp implode [OPTIONS] + +Options: + -y, --yes Skip confirmation prompt + -h, --help Print help +``` + +### Command Name: `implode` + +**Decision**: Use `implode` following mise's convention for a self-destruct command. + +**Alternatives considered**: + +- `self uninstall` / `self remove` — used by rustup (`rustup self uninstall`); requires subcommand group +- `uninstall` — ambiguous with package uninstall operations + +**Rationale**: + +- Single word, memorable, unambiguous +- Follows mise precedent (`mise implode`) +- Cannot be confused with package management operations + +### Implementation Flow + +``` +┌───────────────────────────────────────────────┐ +│ vp implode │ +├───────────────────────────────────────────────┤ +│ 1. Resolve ~/.vite-plus via get_vite_plus_home│ +│ 2. Scan shell profiles for Vite+ lines │ +│ 3. Confirmation prompt (unless --yes) │ +│ 4. Clean shell profiles │ +│ 5. Remove Windows PATH entry (Windows only) │ +│ 6. Remove ~/.vite-plus/ directory │ +│ 7. Print success message │ +└───────────────────────────────────────────────┘ +``` + +#### Step 1: Resolve Home Directory + +Use `vite_shared::get_vite_plus_home()` to determine the install directory. If it doesn't exist, print "not installed" and exit 0. + +#### Step 2: Scan Shell Profiles + +Check the following files for Vite+ sourcing lines: + +| Shell | Files | +| ----- | -------------------------------------------- | +| zsh | `~/.zshenv`, `~/.zshrc` | +| bash | `~/.bash_profile`, `~/.bashrc`, `~/.profile` | +| fish | `~/.config/fish/config.fish` | + +**POSIX detection pattern**: Lines containing `.vite-plus/env"` (trailing quote avoids matching `env.fish`). + +**Fish detection pattern**: Lines containing `.vite-plus/env.fish`. + +#### Step 3: Confirmation + +Unless `--yes` is passed: + +- If stdin is not a TTY, return an error asking for `--yes` +- Display what will be removed (directory path + affected shell profiles) +- Require the user to type `uninstall` to confirm (similar to `rustup self uninstall`) + +#### Step 4: Shell Profile Cleanup + +For each affected file, remove: + +1. The sourcing line (`. "$HOME/.vite-plus/env"` or `source ... env.fish`) +2. The comment line above it (`# Vite+ bin (https://viteplus.dev)`) +3. The blank line before the comment (added by the install script) + +Shell profile cleanup is non-fatal — if a file can't be written, a warning is printed and the process continues. + +#### Step 5: Windows PATH Cleanup + +On Windows, run PowerShell to remove `.vite-plus\bin` from the User PATH environment variable: + +```powershell +[Environment]::SetEnvironmentVariable('Path', + ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | + Where-Object { $_ -ne '' }) -join ';', 'User') +``` + +#### Step 6: Remove Directory + +**Unix**: `std::fs::remove_dir_all` works even while the binary is running (Unix doesn't delete open files until all file descriptors are closed). + +**Windows**: The running `vp.exe` is always locked by the OS. Strategy: + +1. Rename `~/.vite-plus` to `~/.vite-plus.removing-` so the original path is immediately free for reinstall +2. Spawn a detached `cmd.exe` process that retries `rmdir /S /Q` up to 10 times with 1-second pauses (via `timeout /T 1 /NOBREAK`), exiting as soon as the directory is gone + +### File Structure + +``` +crates/vite_global_cli/ +├── src/ +│ ├── commands/ +│ │ ├── implode.rs # Full implementation +│ │ ├── mod.rs # Add implode module +│ │ └── ... +│ └── cli.rs # Add Implode command variant +``` + +### Error Handling + +| Error | Behavior | +| ---------------------------- | ----------------------------- | +| Home dir not found | Print "not installed", exit 0 | +| Home dir doesn't exist | Print "not installed", exit 0 | +| Can't determine user home | Return error | +| Shell profile write failure | Warn and continue | +| Windows PATH cleanup failure | Warn and continue | +| Directory removal failure | Return error | +| Non-TTY without --yes | Return error with suggestion | + +## Testing Strategy + +### Unit Tests + +- `test_remove_vite_plus_lines_posix` — strips comment + sourcing from mock `.zshrc` +- `test_remove_vite_plus_lines_fish` — strips fish `source` syntax +- `test_remove_vite_plus_lines_no_match` — no modification when no Vite+ lines present +- `test_remove_vite_plus_lines_absolute_path` — handles `/home/user/.vite-plus/env` variant +- `test_remove_vite_plus_lines_preserves_surrounding` — other content untouched +- `test_clean_shell_profile_integration` — tempdir-based integration test +- `test_execute_not_installed` — points `VITE_PLUS_HOME` at non-existent path, verifies success + +### CI Tests + +Implode tests run in `.github/workflows/ci.yml` alongside the upgrade tests, across all platforms (bash on all, powershell and cmd on Windows): + +1. Run `vp implode --yes` +2. Verify `~/.vite-plus/` is removed +3. Reinstall via `pnpm bootstrap-cli:ci` +4. Verify reinstallation works (`vp --version`) + +### Manual Testing + +```bash +# Build and install +pnpm bootstrap-cli + +# Test interactive confirmation (cancel) +vp implode + +# Test full uninstall +vp implode --yes + +# Verify cleanup +ls ~/.vite-plus # should not exist +grep vite-plus ~/.zshenv ~/.zshrc ~/.bashrc # should find nothing + +# Verify vp is gone +which vp # should not be found (after terminal restart) +``` + +## References + +- [RFC: Upgrade Command](./upgrade-command.md) +- [RFC: Global CLI (Rust Binary)](./global-cli-rust-binary.md) +- [Install Script](../packages/cli/install.sh) +- [Install Script (Windows)](../packages/cli/install.ps1)