From d258d7be17f3c026fe7961fcda2c7267c5a22484 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 15:47:59 +0800 Subject: [PATCH 1/9] feat(global-cli): add `vp implode` command to completely uninstall vite-plus --- .github/workflows/test-standalone-install.yml | 85 ++++ Cargo.lock | 1 + crates/vite_global_cli/Cargo.toml | 1 + crates/vite_global_cli/src/cli.rs | 8 + .../vite_global_cli/src/commands/implode.rs | 362 ++++++++++++++++++ crates/vite_global_cli/src/commands/mod.rs | 1 + crates/vite_global_cli/src/help.rs | 5 +- .../cli-helper-message/snap.txt | 1 + rfcs/implode-command.md | 265 +++++++++++++ 9 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 crates/vite_global_cli/src/commands/implode.rs create mode 100644 rfcs/implode-command.md diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 17db91b627..5619084af9 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,6 +8,7 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'crates/vite_global_cli/src/commands/implode.rs' - '.github/workflows/test-standalone-install.yml' concurrency: @@ -114,6 +115,32 @@ jobs: vp upgrade --rollback vp --version + - name: Verify implode and reinstall + run: | + # Implode (uninstall) + vp implode --yes + # Verify directory removed + if [ -d "$HOME/.vite-plus" ]; then + echo "Error: ~/.vite-plus still exists after implode" + exit 1 + fi + # Verify shell profile cleaned + for f in ~/.zshenv ~/.zshrc ~/.bashrc ~/.bash_profile ~/.profile; do + if [ -f "$f" ] && grep -q "vite-plus/env" "$f"; then + echo "Error: $f still contains vite-plus/env line" + exit 1 + fi + done + # Reinstall + cat packages/cli/install.sh | bash + # Source shell config + if [ -f ~/.zshenv ]; then source ~/.zshenv + elif [ -f ~/.bashrc ]; then source ~/.bashrc + else export PATH="$HOME/.vite-plus/bin:$PATH" + fi + # Verify reinstall works + vp --version + test-install-sh-arm64: name: Test install.sh (Linux ARM64 glibc via QEMU) runs-on: ubuntu-latest @@ -173,6 +200,28 @@ jobs: vp upgrade --rollback vp --version + # Verify implode and reinstall + vp implode --yes + if [ -d \"\$HOME/.vite-plus\" ]; then + echo \"Error: ~/.vite-plus still exists after implode\" + exit 1 + fi + for f in ~/.profile ~/.bashrc; do + if [ -f \"\$f\" ] && grep -q \"vite-plus/env\" \"\$f\"; then + echo \"Error: \$f still contains vite-plus/env line\" + exit 1 + fi + done + cat /workspace/packages/cli/install.sh | bash + if [ -f ~/.profile ]; then + source ~/.profile + elif [ -f ~/.bashrc ]; then + source ~/.bashrc + else + export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" + fi + vp --version + # FIXME: qemu: uncaught target signal 11 (Segmentation fault) - core dumped # vp create vite --no-interactive --no-agent -- hello --no-interactive -t vanilla # cd hello && vp run build @@ -254,6 +303,18 @@ jobs: vp upgrade --rollback vp --version + - name: Verify implode and reinstall + shell: powershell + run: | + vp implode --yes + if (Test-Path "$env:USERPROFILE\.vite-plus") { + Write-Error "~/.vite-plus still exists after implode" + exit 1 + } + & ./packages/cli/install.ps1 + $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" + vp --version + test-install-ps1-arm64: name: Test install.ps1 (Windows ARM64) runs-on: windows-11-arm @@ -321,6 +382,18 @@ jobs: vp upgrade --rollback vp --version + - name: Verify implode and reinstall + shell: pwsh + run: | + vp implode --yes + if (Test-Path "$env:USERPROFILE\.vite-plus") { + Write-Error "~/.vite-plus still exists after implode" + exit 1 + } + & ./packages/cli/install.ps1 + $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" + vp --version + test-install-ps1: name: Test install.ps1 (Windows x64) runs-on: windows-latest @@ -350,6 +423,18 @@ jobs: vp upgrade --rollback vp --version + - name: Verify implode and reinstall + shell: pwsh + run: | + vp implode --yes + if (Test-Path "$env:USERPROFILE\.vite-plus") { + Write-Error "~/.vite-plus still exists after implode" + exit 1 + } + & ./packages/cli/install.ps1 + $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" + vp --version + - name: Verify installation on powershell shell: pwsh working-directory: ${{ runner.temp }} 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..ef869423bf 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, }, + + /// Completely remove vp and all its data from this system + 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..21b869b96c --- /dev/null +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -0,0 +1,362 @@ +//! `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 binary may be locked. Try direct removal first. + match std::fs::remove_dir_all(home_dir) { + Ok(()) => { + output::success(&vite_str::format!("Removed {}", home_dir.as_path().display())); + } + Err(_) => { + // Binary is locked — schedule deletion via a detached process + let home_str = home_dir.as_path().to_string_lossy(); + let result = std::process::Command::new("cmd.exe") + .args([ + "/C", + &vite_str::format!( + "ping -n 3 127.0.0.1 >NUL && rmdir /S /Q \"{home_str}\"" + ), + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + + match result { + 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(()) + } +} + +/// 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_execute_not_installed() { + let temp_dir = tempfile::tempdir().unwrap(); + let non_existent = temp_dir.path().join("does-not-exist"); + // Point VITE_PLUS_HOME at a non-existent path + unsafe { std::env::set_var("VITE_PLUS_HOME", non_existent.to_str().unwrap()) }; + let result = execute(true); + unsafe { std::env::remove_var("VITE_PLUS_HOME") }; + 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..2d92e13bb6 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", "Completely remove vp and all its data from this system"), + ], ), ], } 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..6e823d070f 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 Completely remove vp and all its data from this system Options: -V, --version Print version diff --git a/rfcs/implode-command.md b/rfcs/implode-command.md new file mode 100644 index 0000000000..9b095d2d28 --- /dev/null +++ b/rfcs/implode-command.md @@ -0,0 +1,265 @@ +# 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 `.exe` may be locked. Strategy: + +1. Try direct `remove_dir_all` first +2. If locked, spawn a detached `cmd.exe /C "ping -n 3 127.0.0.1 >NUL && rmdir /S /Q ..."` to delete after a short delay + +### 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 + +Added "Verify implode and reinstall" step to each job in `.github/workflows/test-standalone-install.yml`: + +1. Run `vp implode --yes` +2. Verify `~/.vite-plus/` is removed +3. Verify shell profiles are cleaned +4. Re-run install script +5. 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) From 20b9ab6601a473eaa7f567f753e8ad96cfc7d630 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 15:55:34 +0800 Subject: [PATCH 2/9] chore: update implode command description --- crates/vite_global_cli/src/cli.rs | 2 +- crates/vite_global_cli/src/help.rs | 2 +- packages/cli/snap-tests-global/cli-helper-message/snap.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index ef869423bf..e476c073a4 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -680,7 +680,7 @@ pub enum Commands { registry: Option, }, - /// Completely remove vp and all its data from this system + /// Remove vp and all related data Implode { /// Skip confirmation prompt #[arg(long, short = 'y')] diff --git a/crates/vite_global_cli/src/help.rs b/crates/vite_global_cli/src/help.rs index 2d92e13bb6..6b26c08097 100644 --- a/crates/vite_global_cli/src/help.rs +++ b/crates/vite_global_cli/src/help.rs @@ -455,7 +455,7 @@ pub fn top_level_help_doc() -> HelpDoc { "Maintain", vec![ row("upgrade", "Update vp itself to the latest version"), - row("implode", "Completely remove vp and all its data from this system"), + 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 6e823d070f..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,7 +44,7 @@ Manage Dependencies: Maintain: upgrade Update vp itself to the latest version - implode Completely remove vp and all its data from this system + implode Remove vp and all related data Options: -V, --version Print version From fbb6068b906d7a594cc96f7fa3c396ac4d545bca Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 16:15:48 +0800 Subject: [PATCH 3/9] fix: use EnvConfig::test_guard instead of mutating global env in test Also remove implode.rs from CI paths trigger. --- .github/workflows/test-standalone-install.yml | 1 - crates/vite_global_cli/src/commands/implode.rs | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 5619084af9..38b1044efe 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,7 +8,6 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' - - 'crates/vite_global_cli/src/commands/implode.rs' - '.github/workflows/test-standalone-install.yml' concurrency: diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index 21b869b96c..013b44f903 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -352,10 +352,11 @@ mod tests { fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); let non_existent = temp_dir.path().join("does-not-exist"); - // Point VITE_PLUS_HOME at a non-existent path - unsafe { std::env::set_var("VITE_PLUS_HOME", non_existent.to_str().unwrap()) }; + // 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); - unsafe { std::env::remove_var("VITE_PLUS_HOME") }; assert!(result.is_ok()); assert!(result.unwrap().success()); } From b38f66103099c95e2323103eb5b21ab57e1fe468 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 17:33:28 +0800 Subject: [PATCH 4/9] fix: log Windows removal errors and add remove_vite_plus_dir tests - Log remove_dir_all error on Windows fallback path for debugging - Print cmd.exe command and show its stdout/stderr output - Rename directory before scheduled delete to avoid racing with reinstall - Add unit tests for remove_vite_plus_dir (success + nonexistent) --- .../vite_global_cli/src/commands/implode.rs | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index 013b44f903..31a2b823f0 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -186,19 +186,32 @@ fn remove_vite_plus_dir(home_dir: &AbsolutePathBuf) -> Result<(), Error> { Ok(()) => { output::success(&vite_str::format!("Removed {}", home_dir.as_path().display())); } - Err(_) => { - // Binary is locked — schedule deletion via a detached process - let home_str = home_dir.as_path().to_string_lossy(); + Err(e) => { + output::warn(&vite_str::format!( + "Direct removal failed ({}), renaming before scheduled delete", + e + )); + // Binary is locked — 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)); + } + + let trash_str = trash_path.to_string_lossy(); + let cmd_arg = + vite_str::format!("ping -n 3 127.0.0.1 >NUL && rmdir /S /Q \"{trash_str}\""); + output::info(&vite_str::format!("Running: cmd.exe /C {cmd_arg}")); let result = std::process::Command::new("cmd.exe") - .args([ - "/C", - &vite_str::format!( - "ping -n 3 127.0.0.1 >NUL && rmdir /S /Q \"{home_str}\"" - ), - ]) + .args(["/C", &cmd_arg]) .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) .spawn(); match result { @@ -348,6 +361,29 @@ mod tests { 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] fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); From cf273796e9d6b67feff5c7b38bbdb5b48d800b0f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 19:34:00 +0800 Subject: [PATCH 5/9] refactor(global-cli): replace ping hack with cmd.exe retry loop for Windows deferred deletion Extract `build_deferred_delete_script()` and `spawn_deferred_delete()` helpers for robustness. Move implode tests from test-standalone-install.yml to ci.yml alongside the existing upgrade tests. --- .github/workflows/ci.yml | 39 +++++++++ .github/workflows/test-standalone-install.yml | 84 ------------------- .../vite_global_cli/src/commands/implode.rs | 49 ++++++++--- 3 files changed, 78 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1301fc9e1d..8be9558881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -518,6 +518,45 @@ jobs: exit /b 1 ) + - name: Test implode (bash) + shell: bash + run: | + vp implode --yes + 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 + 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: | + vp implode --yes + timeout /T 5 /NOBREAK >NUL + if exist "%USERPROFILE%\.vite-plus" ( + echo Error: .vite-plus still exists after implode + exit /b 1 + ) + call pnpm bootstrap-cli:ci + vp --version + install-e2e-test: name: Local CLI `vp install` E2E test needs: diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 38b1044efe..17db91b627 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -114,32 +114,6 @@ jobs: vp upgrade --rollback vp --version - - name: Verify implode and reinstall - run: | - # Implode (uninstall) - vp implode --yes - # Verify directory removed - if [ -d "$HOME/.vite-plus" ]; then - echo "Error: ~/.vite-plus still exists after implode" - exit 1 - fi - # Verify shell profile cleaned - for f in ~/.zshenv ~/.zshrc ~/.bashrc ~/.bash_profile ~/.profile; do - if [ -f "$f" ] && grep -q "vite-plus/env" "$f"; then - echo "Error: $f still contains vite-plus/env line" - exit 1 - fi - done - # Reinstall - cat packages/cli/install.sh | bash - # Source shell config - if [ -f ~/.zshenv ]; then source ~/.zshenv - elif [ -f ~/.bashrc ]; then source ~/.bashrc - else export PATH="$HOME/.vite-plus/bin:$PATH" - fi - # Verify reinstall works - vp --version - test-install-sh-arm64: name: Test install.sh (Linux ARM64 glibc via QEMU) runs-on: ubuntu-latest @@ -199,28 +173,6 @@ jobs: vp upgrade --rollback vp --version - # Verify implode and reinstall - vp implode --yes - if [ -d \"\$HOME/.vite-plus\" ]; then - echo \"Error: ~/.vite-plus still exists after implode\" - exit 1 - fi - for f in ~/.profile ~/.bashrc; do - if [ -f \"\$f\" ] && grep -q \"vite-plus/env\" \"\$f\"; then - echo \"Error: \$f still contains vite-plus/env line\" - exit 1 - fi - done - cat /workspace/packages/cli/install.sh | bash - if [ -f ~/.profile ]; then - source ~/.profile - elif [ -f ~/.bashrc ]; then - source ~/.bashrc - else - export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" - fi - vp --version - # FIXME: qemu: uncaught target signal 11 (Segmentation fault) - core dumped # vp create vite --no-interactive --no-agent -- hello --no-interactive -t vanilla # cd hello && vp run build @@ -302,18 +254,6 @@ jobs: vp upgrade --rollback vp --version - - name: Verify implode and reinstall - shell: powershell - run: | - vp implode --yes - if (Test-Path "$env:USERPROFILE\.vite-plus") { - Write-Error "~/.vite-plus still exists after implode" - exit 1 - } - & ./packages/cli/install.ps1 - $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" - vp --version - test-install-ps1-arm64: name: Test install.ps1 (Windows ARM64) runs-on: windows-11-arm @@ -381,18 +321,6 @@ jobs: vp upgrade --rollback vp --version - - name: Verify implode and reinstall - shell: pwsh - run: | - vp implode --yes - if (Test-Path "$env:USERPROFILE\.vite-plus") { - Write-Error "~/.vite-plus still exists after implode" - exit 1 - } - & ./packages/cli/install.ps1 - $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" - vp --version - test-install-ps1: name: Test install.ps1 (Windows x64) runs-on: windows-latest @@ -422,18 +350,6 @@ jobs: vp upgrade --rollback vp --version - - name: Verify implode and reinstall - shell: pwsh - run: | - vp implode --yes - if (Test-Path "$env:USERPROFILE\.vite-plus") { - Write-Error "~/.vite-plus still exists after implode" - exit 1 - } - & ./packages/cli/install.ps1 - $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" - vp --version - - name: Verify installation on powershell shell: pwsh working-directory: ${{ runner.temp }} diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index 31a2b823f0..07f357ee06 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -205,16 +205,7 @@ fn remove_vite_plus_dir(home_dir: &AbsolutePathBuf) -> Result<(), Error> { return Err(Error::CommandExecution(e)); } - let trash_str = trash_path.to_string_lossy(); - let cmd_arg = - vite_str::format!("ping -n 3 127.0.0.1 >NUL && rmdir /S /Q \"{trash_str}\""); - output::info(&vite_str::format!("Running: cmd.exe /C {cmd_arg}")); - let result = std::process::Command::new("cmd.exe") - .args(["/C", &cmd_arg]) - .stdin(std::process::Stdio::null()) - .spawn(); - - match result { + match spawn_deferred_delete(&trash_path) { Ok(_) => { output::success(&vite_str::format!( "Scheduled removal of {} (will complete shortly)", @@ -235,6 +226,33 @@ fn remove_vite_plus_dir(home_dir: &AbsolutePathBuf) -> Result<(), Error> { } } +/// 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\"" }; @@ -384,6 +402,17 @@ mod tests { 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(); From c16de4b607915f8c439a36cad3fa44359de75c7c Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 20:22:20 +0800 Subject: [PATCH 6/9] fix(global-cli): skip doomed remove_dir_all on Windows, go straight to rename + deferred delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The running vp binary is always locked, so remove_dir_all always fails with "Access is denied" — skip it and rename immediately. --- .github/workflows/ci.yml | 3 +- .../vite_global_cli/src/commands/implode.rs | 60 ++++++++----------- 2 files changed, 26 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8be9558881..33d544b815 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -554,7 +554,8 @@ jobs: echo Error: .vite-plus still exists after implode exit /b 1 ) - call pnpm bootstrap-cli:ci + dir "%USERPROFILE%\" + pnpm bootstrap-cli:ci vp --version install-e2e-test: diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index 07f357ee06..9a84457db4 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -181,45 +181,33 @@ fn remove_vite_plus_dir(home_dir: &AbsolutePathBuf) -> Result<(), Error> { #[cfg(windows)] { - // On Windows, the running binary may be locked. Try direct removal first. - match std::fs::remove_dir_all(home_dir) { - Ok(()) => { - output::success(&vite_str::format!("Removed {}", home_dir.as_path().display())); + // 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::warn(&vite_str::format!( - "Direct removal failed ({}), renaming before scheduled delete", - e + output::error(&vite_str::format!( + "Failed to schedule removal of {}: {e}", + home_dir.as_path().display() )); - // Binary is locked — 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)); - } - } + return Err(Error::CommandExecution(e)); } } Ok(()) From d5e6bab3e7eda060b59e54681fcfb37a37adc573 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 20:24:22 +0800 Subject: [PATCH 7/9] docs: update implode RFC to reflect Windows retry loop and CI test location --- .github/workflows/ci.yml | 4 +++- rfcs/implode-command.md | 13 ++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33d544b815..246896ca56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -522,6 +522,7 @@ jobs: 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" @@ -537,6 +538,7 @@ jobs: 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 @@ -550,11 +552,11 @@ jobs: run: | vp implode --yes timeout /T 5 /NOBREAK >NUL + dir "%USERPROFILE%\" if exist "%USERPROFILE%\.vite-plus" ( echo Error: .vite-plus still exists after implode exit /b 1 ) - dir "%USERPROFILE%\" pnpm bootstrap-cli:ci vp --version diff --git a/rfcs/implode-command.md b/rfcs/implode-command.md index 9b095d2d28..e0ff23d224 100644 --- a/rfcs/implode-command.md +++ b/rfcs/implode-command.md @@ -186,10 +186,10 @@ On Windows, run PowerShell to remove `.vite-plus\bin` from the User PATH environ **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 `.exe` may be locked. Strategy: +**Windows**: The running `vp.exe` is always locked by the OS. Strategy: -1. Try direct `remove_dir_all` first -2. If locked, spawn a detached `cmd.exe /C "ping -n 3 127.0.0.1 >NUL && rmdir /S /Q ..."` to delete after a short delay +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 @@ -229,13 +229,12 @@ crates/vite_global_cli/ ### CI Tests -Added "Verify implode and reinstall" step to each job in `.github/workflows/test-standalone-install.yml`: +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. Verify shell profiles are cleaned -4. Re-run install script -5. Verify reinstallation works (`vp --version`) +3. Reinstall via `pnpm bootstrap-cli:ci` +4. Verify reinstallation works (`vp --version`) ### Manual Testing From cdba149eca77cd0b4af70d3debe74f7623638453 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 21:26:21 +0800 Subject: [PATCH 8/9] fix(ci): handle cmd.exe path error after vp implode renames its own directory --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 246896ca56..201c9361a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -550,7 +550,9 @@ jobs: if: ${{ matrix.os == 'windows-latest' }} shell: cmd run: | - vp implode --yes + 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" ( From 51f3988e25859446f6c23456b2801578442eb80c Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 10 Mar 2026 21:53:37 +0800 Subject: [PATCH 9/9] fix: skip hooks install test on Windows and use imports instead of require --- .../cli/src/config/__tests__/hooks.spec.ts | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) 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', () => {