From 724519b30c36e5f0452f6c4e874d154c6cd44d24 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 11:43:44 +0800 Subject: [PATCH 01/13] feat(cli): add `vp self-update` command Implement `vp self-update` (alias: `vp upgrade`) that downloads and installs a new vp CLI version from the npm registry with SHA-512 integrity verification. Features: - Version resolution from npm registry (latest, specific version, or dist-tag) - Parallel download of platform binary + main JS package tarballs - SHA-512 SRI integrity verification for both tarballs - Atomic symlink swap on Unix, junction swap on Windows - Production dependency installation via new binary - Shim refresh after update - Automatic cleanup of old versions (keeps 5 most recent) - `--check` flag to check for updates without installing - `--rollback` to revert to previous version - `--force`, `--silent`, `--tag`, `--registry` flags - Path traversal protection in tarball extraction - Post-extraction validation of critical files --- Cargo.lock | 5 + Cargo.toml | 1 + crates/vite_global_cli/Cargo.toml | 5 + crates/vite_global_cli/src/cli.rs | 51 ++ crates/vite_global_cli/src/commands/mod.rs | 3 + .../src/commands/self_update/install.rs | 408 ++++++++++++ .../src/commands/self_update/integrity.rs | 76 +++ .../src/commands/self_update/mod.rs | 253 ++++++++ .../src/commands/self_update/platform.rs | 73 +++ .../src/commands/self_update/registry.rs | 109 ++++ crates/vite_global_cli/src/error.rs | 9 + crates/vite_install/src/lib.rs | 4 +- crates/vite_install/src/request.rs | 16 + rfcs/self-update-command.md | 603 ++++++++++++++++++ 14 files changed, 1614 insertions(+), 2 deletions(-) create mode 100644 crates/vite_global_cli/src/commands/self_update/install.rs create mode 100644 crates/vite_global_cli/src/commands/self_update/integrity.rs create mode 100644 crates/vite_global_cli/src/commands/self_update/mod.rs create mode 100644 crates/vite_global_cli/src/commands/self_update/platform.rs create mode 100644 crates/vite_global_cli/src/commands/self_update/registry.rs create mode 100644 rfcs/self-update-command.md diff --git a/Cargo.lock b/Cargo.lock index c7ba236162..380708e8b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7129,12 +7129,17 @@ dependencies = [ name = "vite_global_cli" version = "0.0.0" dependencies = [ + "base64 0.22.1", "chrono", "clap", + "flate2", + "node-semver", "owo-colors", "serde", "serde_json", "serial_test", + "sha2", + "tar", "tempfile", "thiserror 2.0.17", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 479eff45aa..2bc69320cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ async-channel = "2.3.1" async-scoped = "0.9.0" async-trait = "0.1.89" backon = "1.3.0" +base64 = "0.22.1" base-encode = "0.3.1" base64-simd = "0.8.0" bincode = "2.0.1" diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index b652f4839c..89c7803ff1 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -12,10 +12,15 @@ name = "vp" path = "src/main.rs" [dependencies] +base64 = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } +flate2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +node-semver = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 40d0bed8b5..1323853eb2 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -588,6 +588,40 @@ pub enum Commands { /// Manage Node.js versions Env(EnvArgs), + + // ========================================================================= + // Category E: Self-Management + // ========================================================================= + /// Update vp itself to the latest version + #[command(name = "self-update", visible_alias = "upgrade")] + SelfUpdate { + /// Target version (e.g., "0.2.0"). Defaults to latest. + version: Option, + + /// npm dist-tag to install (default: "latest", also: "test") + #[arg(long, default_value = "latest")] + tag: String, + + /// Check for updates without installing + #[arg(long)] + check: bool, + + /// Revert to the previously active version + #[arg(long)] + rollback: bool, + + /// Force reinstall even if already on the target version + #[arg(long)] + force: bool, + + /// Suppress output + #[arg(long)] + silent: bool, + + /// Custom npm registry URL + #[arg(long)] + registry: Option, + }, } /// Arguments for the `env` command @@ -1511,6 +1545,20 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result commands::delegate::execute(cwd, "cache", &args).await, Commands::Env(args) => commands::env::execute(cwd, args).await, + + // Category E: Self-Management + Commands::SelfUpdate { version, tag, check, rollback, force, silent, registry } => { + commands::self_update::execute(commands::self_update::SelfUpdateOptions { + version, + tag, + check, + rollback, + force, + silent, + registry, + }) + .await + } } } @@ -1554,6 +1602,9 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command { {bold}run{reset} Run tasks {bold}env{reset} Manage Node.js versions +{bold_underline}Self-Management:{reset} + {bold}self-update, upgrade{reset} Update vp itself to the latest version + {bold_underline}Package Manager Commands:{reset} {bold}install, i{reset} Install all dependencies, or add packages if package names are provided {bold}add{reset} Add packages to dependencies diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 734ae6438c..fbb7980125 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -101,6 +101,9 @@ pub mod version; // Category D: Environment Management pub mod env; +// Category E: Self-Management +pub mod self_update; + // Category C: Local CLI Delegation pub mod delegate; diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs new file mode 100644 index 0000000000..e3eeec1aac --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -0,0 +1,408 @@ +//! Installation logic for self-update. +//! +//! Handles tarball extraction, dependency installation, symlink swapping, +//! and version cleanup. + +use std::{ + io::{Cursor, Read as _}, + path::Path, +}; + +use flate2::read::GzDecoder; +use tar::Archive; +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::error::Error; + +/// Validate that a path from a tarball entry is safe (no path traversal). +/// +/// Returns `false` if the path contains `..` components or is absolute. +fn is_safe_tar_path(path: &Path) -> bool { + !path.is_absolute() && !path.components().any(|c| matches!(c, std::path::Component::ParentDir)) +} + +/// Files/directories to extract from the main package tarball. +const MAIN_PACKAGE_ENTRIES: &[&str] = + &["dist/", "templates/", "rules/", "AGENTS.md", "package.json"]; + +/// Extract the platform-specific package (binary + .node files). +/// +/// From the platform tarball, extracts: +/// - The `vp` binary → `{version_dir}/bin/vp` +/// - Any `.node` files → `{version_dir}/dist/` +pub async fn extract_platform_package( + tgz_data: &[u8], + version_dir: &AbsolutePath, +) -> Result<(), Error> { + let bin_dir = version_dir.join("bin"); + let dist_dir = version_dir.join("dist"); + tokio::fs::create_dir_all(&bin_dir).await?; + tokio::fs::create_dir_all(&dist_dir).await?; + + let data = tgz_data.to_vec(); + let bin_dir_clone = bin_dir.clone(); + let dist_dir_clone = dist_dir.clone(); + + tokio::task::spawn_blocking(move || { + let cursor = Cursor::new(data); + let decoder = GzDecoder::new(cursor); + let mut archive = Archive::new(decoder); + + for entry_result in archive.entries()? { + let mut entry = entry_result?; + let path = entry.path()?.to_path_buf(); + + // Strip the leading `package/` prefix that npm tarballs have + let relative = path.strip_prefix("package").unwrap_or(&path).to_path_buf(); + + // Reject paths with traversal components (security) + if !is_safe_tar_path(&relative) { + continue; + } + + let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if file_name == "vp" || file_name == "vp.exe" { + // Binary goes to bin/ + let target = bin_dir_clone.join(file_name); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + std::fs::write(&target, &buf)?; + + // Set executable permission on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?; + } + } else if file_name.ends_with(".node") { + // .node NAPI files go to dist/ + let target = dist_dir_clone.join(file_name); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + std::fs::write(&target, &buf)?; + } + } + + Ok::<(), Error>(()) + }) + .await + .map_err(|e| Error::SelfUpdate(format!("Task join error: {e}").into()))??; + + Ok(()) +} + +/// Extract the main package (JS bundles, templates, rules, package.json). +/// +/// Copies specific directories and files from the tarball to the version directory. +pub async fn extract_main_package( + tgz_data: &[u8], + version_dir: &AbsolutePath, +) -> Result<(), Error> { + let version_dir_owned = version_dir.as_path().to_path_buf(); + let data = tgz_data.to_vec(); + + tokio::task::spawn_blocking(move || { + let cursor = Cursor::new(data); + let decoder = GzDecoder::new(cursor); + let mut archive = Archive::new(decoder); + + for entry_result in archive.entries()? { + let mut entry = entry_result?; + let path = entry.path()?.to_path_buf(); + + // Strip the leading `package/` prefix + let relative = path.strip_prefix("package").unwrap_or(&path).to_path_buf(); + + // Reject paths with traversal components (security) + if !is_safe_tar_path(&relative) { + continue; + } + + let relative_str = relative.to_string_lossy(); + + // Check if this entry matches our allowed list + let should_extract = MAIN_PACKAGE_ENTRIES.iter().any(|allowed| { + if allowed.ends_with('/') { + // Directory prefix match + relative_str.starts_with(allowed) + } else { + // Exact file match + relative_str == *allowed + } + }); + + if !should_extract { + continue; + } + + let target = version_dir_owned.join(&*relative_str); + + if entry.header().entry_type().is_dir() { + std::fs::create_dir_all(&target)?; + } else { + // Ensure parent directory exists + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + std::fs::write(&target, &buf)?; + } + } + + Ok::<(), Error>(()) + }) + .await + .map_err(|e| Error::SelfUpdate(format!("Task join error: {e}").into()))??; + + Ok(()) +} + +/// Strip devDependencies and optionalDependencies from package.json. +pub async fn strip_dev_dependencies(version_dir: &AbsolutePath) -> Result<(), Error> { + let package_json_path = version_dir.join("package.json"); + + if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { + return Ok(()); + } + + let content = tokio::fs::read_to_string(&package_json_path).await?; + let mut json: serde_json::Value = serde_json::from_str(&content)?; + + if let Some(obj) = json.as_object_mut() { + obj.remove("devDependencies"); + obj.remove("optionalDependencies"); + } + + let updated = serde_json::to_string_pretty(&json)?; + tokio::fs::write(&package_json_path, format!("{updated}\n")).await?; + + Ok(()) +} + +/// Install production dependencies using the new version's binary. +/// +/// Spawns: `{version_dir}/bin/vp install --silent` with `CI=true`. +pub async fn install_production_deps(version_dir: &AbsolutePath) -> Result<(), Error> { + let vp_binary = version_dir.join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); + + if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + format!("New binary not found at {}", vp_binary.as_path().display()).into(), + )); + } + + tracing::debug!("Running vp install in {}", version_dir.as_path().display()); + + let status = tokio::process::Command::new(vp_binary.as_path()) + .args(["install", "--silent"]) + .current_dir(version_dir) + .env("CI", "true") + .status() + .await?; + + if !status.success() { + return Err(Error::SelfUpdate( + format!( + "Failed to install production dependencies (exit code: {})", + status.code().unwrap_or(-1) + ) + .into(), + )); + } + + Ok(()) +} + +/// Save the current version before swapping, for rollback support. +/// +/// Reads the `current` symlink target and writes the version to `.previous-version`. +pub async fn save_previous_version(install_dir: &AbsolutePath) -> Result, Error> { + let current_link = install_dir.join("current"); + + if !tokio::fs::try_exists(¤t_link).await.unwrap_or(false) { + return Ok(None); + } + + let target = tokio::fs::read_link(¤t_link).await?; + let version = target.file_name().and_then(|n| n.to_str()).map(String::from); + + if let Some(ref v) = version { + let prev_file = install_dir.join(".previous-version"); + tokio::fs::write(&prev_file, v).await?; + tracing::debug!("Saved previous version: {}", v); + } + + Ok(version) +} + +/// Atomically swap the `current` symlink to point to a new version. +/// +/// On Unix: creates a temp symlink then renames (atomic). +/// On Windows: removes junction and creates a new one. +pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Result<(), Error> { + let current_link = install_dir.join("current"); + let version_dir = install_dir.join(version); + + // Verify the version directory exists + if !tokio::fs::try_exists(&version_dir).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + format!("Version directory does not exist: {}", version_dir.as_path().display()).into(), + )); + } + + #[cfg(unix)] + { + // Atomic symlink swap: create temp link, then rename over current + let temp_link = install_dir.join("current.new"); + + // Remove temp link if it exists from a previous failed attempt + let _ = tokio::fs::remove_file(&temp_link).await; + + tokio::fs::symlink(version, &temp_link).await?; + tokio::fs::rename(&temp_link, ¤t_link).await?; + } + + #[cfg(windows)] + { + // Windows: junction swap (not atomic) + use std::process::Command; + + // Remove existing junction (use symlink_metadata to detect broken junctions too) + if std::fs::symlink_metadata(current_link.as_path()).is_ok() { + let status = Command::new("cmd") + .args(["/c", "rmdir", ¤t_link.as_path().display().to_string()]) + .status()?; + if !status.success() { + return Err(Error::SelfUpdate("Failed to remove existing junction".into())); + } + } + + // Create new junction + let status = Command::new("cmd") + .args([ + "/c", + "mklink", + "/J", + ¤t_link.as_path().display().to_string(), + &version_dir.as_path().display().to_string(), + ]) + .status()?; + if !status.success() { + return Err(Error::SelfUpdate("Failed to create junction".into())); + } + } + + tracing::debug!("Swapped current → {}", version); + Ok(()) +} + +/// Refresh shims by running `vp env setup --refresh` with the new binary. +pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { + let vp_binary = + install_dir.join("current").join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); + + if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { + tracing::warn!( + "New binary not found at {}, skipping shim refresh", + vp_binary.as_path().display() + ); + return Ok(()); + } + + tracing::debug!("Refreshing shims..."); + + let status = tokio::process::Command::new(vp_binary.as_path()) + .args(["env", "setup", "--refresh"]) + .status() + .await?; + + if !status.success() { + tracing::warn!( + "Shim refresh exited with code {}, continuing anyway", + status.code().unwrap_or(-1) + ); + } + + Ok(()) +} + +/// Clean up old version directories, keeping at most `max_keep` versions. +/// +/// Sorts by semver (newest first) and removes the oldest beyond the limit. +pub async fn cleanup_old_versions( + install_dir: &AbsolutePath, + max_keep: usize, +) -> Result<(), Error> { + let mut versions: Vec<(node_semver::Version, AbsolutePathBuf)> = Vec::new(); + + let mut entries = tokio::fs::read_dir(install_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Only consider entries that parse as semver + if let Ok(ver) = node_semver::Version::parse(&name_str) { + let path = AbsolutePathBuf::new(entry.path()).ok_or_else(|| { + Error::SelfUpdate( + format!("Invalid absolute path: {}", entry.path().display()).into(), + ) + })?; + versions.push((ver, path)); + } + } + + // Sort newest first + versions.sort_by(|a, b| b.0.cmp(&a.0)); + + // Remove versions beyond the keep limit + for (_ver, path) in versions.into_iter().skip(max_keep) { + tracing::debug!("Cleaning up old version: {}", path.as_path().display()); + if let Err(e) = tokio::fs::remove_dir_all(&path).await { + tracing::warn!("Failed to remove {}: {}", path.as_path().display(), e); + } + } + + Ok(()) +} + +/// Read the previous version from `.previous-version` file. +pub async fn read_previous_version(install_dir: &AbsolutePath) -> Result, Error> { + let prev_file = install_dir.join(".previous-version"); + + if !tokio::fs::try_exists(&prev_file).await.unwrap_or(false) { + return Ok(None); + } + + let content = tokio::fs::read_to_string(&prev_file).await?; + let version = content.trim().to_string(); + + if version.is_empty() { Ok(None) } else { Ok(Some(version)) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_safe_tar_path_normal() { + assert!(is_safe_tar_path(Path::new("dist/index.js"))); + assert!(is_safe_tar_path(Path::new("bin/vp"))); + assert!(is_safe_tar_path(Path::new("package.json"))); + assert!(is_safe_tar_path(Path::new("templates/react/index.ts"))); + } + + #[test] + fn test_is_safe_tar_path_traversal() { + assert!(!is_safe_tar_path(Path::new("../etc/passwd"))); + assert!(!is_safe_tar_path(Path::new("dist/../../etc/passwd"))); + assert!(!is_safe_tar_path(Path::new(".."))); + } + + #[test] + fn test_is_safe_tar_path_absolute() { + assert!(!is_safe_tar_path(Path::new("/etc/passwd"))); + assert!(!is_safe_tar_path(Path::new("/usr/bin/vp"))); + } +} diff --git a/crates/vite_global_cli/src/commands/self_update/integrity.rs b/crates/vite_global_cli/src/commands/self_update/integrity.rs new file mode 100644 index 0000000000..5dd0811164 --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/integrity.rs @@ -0,0 +1,76 @@ +//! Integrity verification for downloaded tarballs. +//! +//! Verifies SHA-512 integrity using the Subresource Integrity (SRI) format +//! that npm registries provide: `sha512-{base64}`. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use sha2::{Digest, Sha512}; + +use crate::error::Error; + +/// Verify the integrity of data against an SRI hash. +/// +/// Parses the SRI format `sha512-{base64}`, computes the SHA-512 hash +/// of the data, base64-encodes it, and compares. +pub fn verify_integrity(data: &[u8], expected_sri: &str) -> Result<(), Error> { + let expected_b64 = expected_sri + .strip_prefix("sha512-") + .ok_or_else(|| Error::UnsupportedIntegrity(expected_sri.into()))?; + + let mut hasher = Sha512::new(); + hasher.update(data); + let actual_b64 = STANDARD.encode(hasher.finalize()); + + if actual_b64 != expected_b64 { + return Err(Error::IntegrityMismatch { + expected: expected_sri.into(), + actual: format!("sha512-{actual_b64}").into(), + }); + } + + tracing::debug!("Integrity verification successful"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verify_integrity_valid() { + let data = b"Hello, World!"; + let mut hasher = Sha512::new(); + hasher.update(data); + let hash = STANDARD.encode(hasher.finalize()); + let sri = format!("sha512-{hash}"); + + assert!(verify_integrity(data, &sri).is_ok()); + } + + #[test] + fn test_verify_integrity_mismatch() { + let data = b"Hello, World!"; + let sri = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + let err = verify_integrity(data, sri).unwrap_err(); + assert!(matches!(err, Error::IntegrityMismatch { .. })); + } + + #[test] + fn test_verify_integrity_unsupported_format() { + let data = b"Hello, World!"; + let sri = "sha256-abc123"; + + let err = verify_integrity(data, sri).unwrap_err(); + assert!(matches!(err, Error::UnsupportedIntegrity(_))); + } + + #[test] + fn test_verify_integrity_no_prefix() { + let data = b"Hello, World!"; + let sri = "not-a-valid-sri"; + + let err = verify_integrity(data, sri).unwrap_err(); + assert!(matches!(err, Error::UnsupportedIntegrity(_))); + } +} diff --git a/crates/vite_global_cli/src/commands/self_update/mod.rs b/crates/vite_global_cli/src/commands/self_update/mod.rs new file mode 100644 index 0000000000..3df44fcb2d --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/mod.rs @@ -0,0 +1,253 @@ +//! Self-update command for the vp CLI. +//! +//! Downloads and installs a new version of the CLI from the npm registry +//! with SHA-512 integrity verification. + +mod install; +mod integrity; +mod platform; +mod registry; + +use std::process::ExitStatus; + +use owo_colors::OwoColorize; +use vite_install::request::HttpClient; +use vite_path::AbsolutePathBuf; + +use crate::{commands::env::config::get_vite_plus_home, error::Error}; + +/// Options for the self-update command. +pub struct SelfUpdateOptions { + /// Target version (e.g., "0.2.0"). None means use the tag. + pub version: Option, + /// npm dist-tag (default: "latest") + pub tag: String, + /// Check for updates without installing + pub check: bool, + /// Revert to previous version + pub rollback: bool, + /// Force reinstall even if already on the target version + pub force: bool, + /// Suppress output + pub silent: bool, + /// Custom npm registry URL + pub registry: Option, +} + +/// Maximum number of old versions to keep. +const MAX_VERSIONS_KEEP: usize = 5; + +/// Execute the self-update command. +#[allow(clippy::print_stdout, clippy::print_stderr)] +pub async fn execute(options: SelfUpdateOptions) -> Result { + let install_dir = get_vite_plus_home()?; + + // Handle --rollback + if options.rollback { + return execute_rollback(&install_dir, options.silent).await; + } + + // Step 1: Detect platform + let platform_suffix = platform::detect_platform_suffix()?; + tracing::debug!("Platform: {}", platform_suffix); + + // Step 2: Determine version to resolve + let version_or_tag = options.version.as_deref().unwrap_or(&options.tag); + + if !options.silent { + eprintln!("info: checking for updates..."); + } + + // Step 3: Resolve version from npm registry + let resolved = + registry::resolve_version(version_or_tag, &platform_suffix, options.registry.as_deref()) + .await?; + + let current_version = env!("CARGO_PKG_VERSION"); + + if !options.silent { + eprintln!("info: found vite-plus-cli@{} (current: {})", resolved.version, current_version); + } + + // Step 4: Handle --check (report and exit) + if options.check { + if resolved.version == current_version { + println!("\n{} Already up to date ({})", "\u{2714}".green(), current_version); + } else { + println!("Update available: {} \u{2192} {}", current_version, resolved.version); + println!("Run `vp self-update` to update."); + } + return Ok(ExitStatus::default()); + } + + // Step 5: Handle already up-to-date + if resolved.version == current_version && !options.force { + if !options.silent { + println!("\n{} Already up to date ({})", "\u{2714}".green(), current_version); + } + return Ok(ExitStatus::default()); + } + + if !options.silent { + eprintln!( + "info: downloading vite-plus-cli@{} for {}...", + resolved.version, platform_suffix + ); + } + + // Step 6: Download both tarballs + let client = HttpClient::new(); + + let (platform_data, main_data) = tokio::try_join!( + async { + client.get_bytes(&resolved.platform_tarball_url).await.map_err(|e| { + Error::SelfUpdate(format!("Failed to download platform package: {e}").into()) + }) + }, + async { + client.get_bytes(&resolved.main_tarball_url).await.map_err(|e| { + Error::SelfUpdate(format!("Failed to download main package: {e}").into()) + }) + }, + )?; + + // Step 7: Verify integrity + integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; + integrity::verify_integrity(&main_data, &resolved.main_integrity)?; + + if !options.silent { + eprintln!("info: installing..."); + } + + // Step 8: Create version directory + let version_dir = install_dir.join(&resolved.version); + tokio::fs::create_dir_all(&version_dir).await?; + + // Step 9: Extract platform package (binary + .node files) + let result = install_platform_and_main( + &platform_data, + &main_data, + &version_dir, + &install_dir, + &resolved.version, + current_version, + options.silent, + ) + .await; + + // On failure, clean up the version directory + if result.is_err() { + tracing::debug!("Cleaning up failed install at {}", version_dir.as_path().display()); + let _ = tokio::fs::remove_dir_all(&version_dir).await; + } + + result +} + +/// Core installation logic, separated for error cleanup. +#[allow(clippy::print_stdout, clippy::print_stderr)] +async fn install_platform_and_main( + platform_data: &[u8], + main_data: &[u8], + version_dir: &AbsolutePathBuf, + install_dir: &AbsolutePathBuf, + new_version: &str, + current_version: &str, + silent: bool, +) -> Result { + // Extract platform package + install::extract_platform_package(platform_data, version_dir).await?; + + // Extract main package + install::extract_main_package(main_data, version_dir).await?; + + // Verify critical files were extracted + let binary_name = if cfg!(windows) { "vp.exe" } else { "vp" }; + let binary_path = version_dir.join("bin").join(binary_name); + if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + "Binary not found after extraction. The download may be corrupted.".into(), + )); + } + let package_json_path = version_dir.join("package.json"); + if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + "package.json not found after extraction. The download may be corrupted.".into(), + )); + } + + // Strip dev dependencies from package.json + install::strip_dev_dependencies(version_dir).await?; + + // Install production dependencies + install::install_production_deps(version_dir).await?; + + // Save previous version for rollback + let previous_version = install::save_previous_version(install_dir).await?; + tracing::debug!("Previous version: {:?}", previous_version); + + // Swap current link + install::swap_current_link(install_dir, new_version).await?; + + // Refresh shims + install::refresh_shims(install_dir).await?; + + // Cleanup old versions + install::cleanup_old_versions(install_dir, MAX_VERSIONS_KEEP).await?; + + if !silent { + println!( + "\n{} Updated vite-plus from {} \u{2192} {}", + "\u{2714}".green(), + current_version, + new_version + ); + println!( + "\n Release notes: https://github.com/voidzero-dev/vite-plus/releases/tag/v{}", + new_version + ); + } + + Ok(ExitStatus::default()) +} + +/// Execute rollback to the previous version. +#[allow(clippy::print_stdout, clippy::print_stderr)] +async fn execute_rollback( + install_dir: &AbsolutePathBuf, + silent: bool, +) -> Result { + let previous = install::read_previous_version(install_dir) + .await? + .ok_or_else(|| Error::SelfUpdate("No previous version found. Cannot rollback.".into()))?; + + // Verify the version directory still exists + let prev_dir = install_dir.join(&previous); + if !tokio::fs::try_exists(&prev_dir).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + format!("Previous version directory ({}) no longer exists. Cannot rollback.", previous) + .into(), + )); + } + + if !silent { + let current_version = env!("CARGO_PKG_VERSION"); + eprintln!("info: rolling back to previous version..."); + eprintln!("info: switching from {} \u{2192} {}", current_version, previous); + } + + // Save the current version as the new "previous" before swapping + install::save_previous_version(install_dir).await?; + + // Swap to the previous version + install::swap_current_link(install_dir, &previous).await?; + + // Refresh shims + install::refresh_shims(install_dir).await?; + + if !silent { + println!("\n{} Rolled back to {}", "\u{2714}".green(), previous); + } + + Ok(ExitStatus::default()) +} diff --git a/crates/vite_global_cli/src/commands/self_update/platform.rs b/crates/vite_global_cli/src/commands/self_update/platform.rs new file mode 100644 index 0000000000..47f06e3f3c --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/platform.rs @@ -0,0 +1,73 @@ +//! Platform detection for self-update. +//! +//! Detects the current platform and returns the npm package suffix +//! used to find the correct platform-specific binary package. + +use crate::error::Error; + +/// Detect the current platform suffix for npm package naming. +/// +/// Returns strings like `darwin-arm64`, `linux-x64-gnu`, `linux-arm64-musl`, `win32-x64`. +pub fn detect_platform_suffix() -> Result { + let os_name = if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "win32" + } else { + return Err(Error::SelfUpdate( + format!("Unsupported operating system: {}", std::env::consts::OS).into(), + )); + }; + + let arch_name = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + return Err(Error::SelfUpdate( + format!("Unsupported architecture: {}", std::env::consts::ARCH).into(), + )); + }; + + if os_name == "linux" { + let libc = if cfg!(target_env = "musl") { "musl" } else { "gnu" }; + Ok(format!("{os_name}-{arch_name}-{libc}")) + } else { + Ok(format!("{os_name}-{arch_name}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_platform_suffix() { + let suffix = detect_platform_suffix().unwrap(); + + // Should be non-empty and contain a dash + assert!(!suffix.is_empty()); + assert!(suffix.contains('-')); + + // Should match the current platform + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + assert_eq!(suffix, "darwin-arm64"); + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + assert_eq!(suffix, "darwin-x64"); + + #[cfg(all(target_os = "linux", target_arch = "x86_64", not(target_env = "musl")))] + assert_eq!(suffix, "linux-x64-gnu"); + + #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "musl"))] + assert_eq!(suffix, "linux-x64-musl"); + + #[cfg(all(target_os = "linux", target_arch = "aarch64", not(target_env = "musl")))] + assert_eq!(suffix, "linux-arm64-gnu"); + + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + assert_eq!(suffix, "win32-x64"); + } +} diff --git a/crates/vite_global_cli/src/commands/self_update/registry.rs b/crates/vite_global_cli/src/commands/self_update/registry.rs new file mode 100644 index 0000000000..53301bce43 --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/registry.rs @@ -0,0 +1,109 @@ +//! npm registry client for version resolution. +//! +//! Queries the npm registry to resolve versions and get tarball URLs +//! with integrity hashes for both the main package and platform-specific package. + +use std::collections::HashMap; + +use serde::Deserialize; +use vite_install::{config::NPM_REGISTRY, request::HttpClient}; + +use crate::error::Error; + +/// npm package version metadata (subset of fields we need). +#[derive(Debug, Deserialize)] +pub struct PackageVersionMetadata { + pub version: String, + pub dist: DistInfo, + #[serde(default, rename = "optionalDependencies")] + pub optional_dependencies: HashMap, +} + +/// Distribution info from npm registry. +#[derive(Debug, Deserialize)] +pub struct DistInfo { + pub tarball: String, + pub integrity: String, +} + +/// Resolved version info with URLs and integrity for both packages. +#[derive(Debug)] +pub struct ResolvedVersion { + pub version: String, + pub main_tarball_url: String, + pub main_integrity: String, + pub platform_tarball_url: String, + pub platform_integrity: String, +} + +const MAIN_PACKAGE_NAME: &str = "vite-plus-cli"; +const PLATFORM_PACKAGE_SCOPE: &str = "@voidzero-dev"; + +/// Resolve a version from the npm registry. +/// +/// Makes two HTTP calls: +/// 1. Main package metadata to get version, tarball URL, integrity, and optional deps +/// 2. Platform package metadata to get platform-specific tarball URL and integrity +pub async fn resolve_version( + version_or_tag: &str, + platform_suffix: &str, + registry_override: Option<&str>, +) -> Result { + let registry = registry_override.unwrap_or_else(|| &NPM_REGISTRY); + let client = HttpClient::new(); + + // Step 1: Fetch main package metadata + let main_url = format!("{registry}/{MAIN_PACKAGE_NAME}/{version_or_tag}"); + tracing::debug!("Fetching main package metadata: {}", main_url); + + let main_meta: PackageVersionMetadata = client.get_json(&main_url).await.map_err(|e| { + Error::SelfUpdate(format!("Failed to fetch package metadata from {main_url}: {e}").into()) + })?; + + // Step 2: Determine platform package name from optionalDependencies + let platform_package_name = + format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{platform_suffix}"); + + if !main_meta.optional_dependencies.contains_key(&platform_package_name) { + return Err(Error::SelfUpdate( + format!( + "Platform package '{platform_package_name}' not found in optionalDependencies of {MAIN_PACKAGE_NAME}@{}. \ + Your platform ({platform_suffix}) may not be supported.", + main_meta.version + ) + .into(), + )); + } + + // Step 3: Fetch platform package metadata + let platform_url = format!("{registry}/{platform_package_name}/{}", main_meta.version); + tracing::debug!("Fetching platform package metadata: {}", platform_url); + + let platform_meta: PackageVersionMetadata = + client.get_json(&platform_url).await.map_err(|e| { + Error::SelfUpdate( + format!("Failed to fetch platform package metadata from {platform_url}: {e}") + .into(), + ) + })?; + + Ok(ResolvedVersion { + version: main_meta.version, + main_tarball_url: main_meta.dist.tarball, + main_integrity: main_meta.dist.integrity, + platform_tarball_url: platform_meta.dist.tarball, + platform_integrity: platform_meta.dist.integrity, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_package_name_construction() { + let suffix = "darwin-arm64"; + let name = format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{suffix}"); + assert_eq!(name, "@voidzero-dev/vite-plus-cli-darwin-arm64"); + } +} diff --git a/crates/vite_global_cli/src/error.rs b/crates/vite_global_cli/src/error.rs index 13f41d3a4a..3d335cc8b4 100644 --- a/crates/vite_global_cli/src/error.rs +++ b/crates/vite_global_cli/src/error.rs @@ -51,4 +51,13 @@ pub enum Error { "Executable '{bin_name}' is already installed by {existing_package}\n\nPlease remove {existing_package} before installing {new_package}, or use --force to auto-replace" )] BinaryConflict { bin_name: String, existing_package: String, new_package: String }, + + #[error("Self-update error: {0}")] + SelfUpdate(Str), + + #[error("Integrity mismatch: expected {expected}, got {actual}")] + IntegrityMismatch { expected: Str, actual: Str }, + + #[error("Unsupported integrity format: {0} (only sha512 is supported)")] + UnsupportedIntegrity(Str), } diff --git a/crates/vite_install/src/lib.rs b/crates/vite_install/src/lib.rs index f6a233fba1..84d871f27a 100644 --- a/crates/vite_install/src/lib.rs +++ b/crates/vite_install/src/lib.rs @@ -1,7 +1,7 @@ pub mod commands; -mod config; +pub mod config; pub mod package_manager; -mod request; +pub mod request; mod shim; pub use package_manager::{ diff --git a/crates/vite_install/src/request.rs b/crates/vite_install/src/request.rs index 650490b45b..b2cc23657c 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_install/src/request.rs @@ -40,6 +40,22 @@ impl HttpClient { Self { max_times, min_delay } } + /// Get raw bytes from a URL + /// + /// # Arguments + /// + /// * `url` - The URL to fetch bytes from + /// + /// # Returns + /// + /// * `Ok(Vec)` - The raw bytes from the response + /// * `Err(e)` - If the request fails + pub async fn get_bytes(&self, url: &str) -> Result, Error> { + tracing::debug!("Fetching bytes from: {}", url); + let response = self.get(url).await?; + Ok(response.bytes().await?.to_vec()) + } + async fn get(&self, url: &str) -> Result { let response = (|| async { reqwest::get(url).await?.error_for_status() }) .retry( diff --git a/rfcs/self-update-command.md b/rfcs/self-update-command.md new file mode 100644 index 0000000000..0f25dc273f --- /dev/null +++ b/rfcs/self-update-command.md @@ -0,0 +1,603 @@ +# RFC: Self-Update Command + +## Status + +Draft + +## Background + +Vite+ is distributed as a standalone Rust binary via bash installation (`curl -fsSL https://viteplus.dev/install.sh | bash`). Currently, users must re-run the full install script to update to a new version. This is friction-heavy and unfamiliar to users who expect a built-in update mechanism (like `rustup update`, `volta fetch`, or `brew upgrade`). + +A native `vp self-update` command would allow users to update the CLI in-place with a single command, improving the upgrade experience significantly. + +### Current Installation Structure + +``` +~/.vite-plus/ +├── bin/ +│ ├── vp → ../current/bin/vp # Stable symlink (in PATH) +│ ├── node → ../current/bin/node # Shim symlinks +│ ├── npm → ../current/bin/npm +│ └── npx → ../current/bin/npx +├── current → 0.1.0/ # Symlink to active version +├── 0.1.0/ # Version directory +│ ├── bin/vp # Actual binary +│ ├── dist/ # JS bundles + .node files +│ ├── package.json +│ └── node_modules/ +├── 0.0.9/ # Previous version (kept for rollback) +├── env # POSIX shell env (sourced by shell config) +├── env.fish # Fish shell env +└── env.ps1 # PowerShell env +``` + +Key invariant: `~/.vite-plus/bin/vp` is a symlink to `../current/bin/vp` (Unix) or a `.cmd` wrapper calling `current\bin\vp.exe` (Windows), and `current` is a symlink (Unix) or junction (Windows) to the active version directory. Upgrading swaps the `current` link — atomic on Unix, near-instant on Windows. + +## Goals + +1. Provide a fast, reliable `vp self-update` command that upgrades the CLI to the latest (or specified) version +2. Reuse the same npm-based distribution channel (no new infrastructure) +3. Support atomic upgrades with automatic rollback on failure +4. Keep the last 5 versions for manual rollback +5. Support version pinning and channel selection (latest, test) + +## Non-Goals + +1. Auto-update on every command invocation (may be a future enhancement) +2. Windows PowerShell install path (covered by `install.ps1`) +3. Migrating away from npm as the distribution channel +4. Updating Node.js versions (already handled by `vp env`) + +## User Stories + +### Story 1: Quick Update to Latest + +A developer sees that a new version of Vite+ is available and wants to update. + +```bash +$ vp self-update +info: checking for updates... +info: found vite-plus-cli@0.2.0 (current: 0.1.0) +info: downloading vite-plus-cli@0.2.0 for darwin-arm64... +info: installing... + +✔ Updated vite-plus from 0.1.0 → 0.2.0 + + Release notes: https://github.com/voidzero-dev/vite-plus/releases/tag/v0.2.0 +``` + +### Story 2: Already Up to Date + +```bash +$ vp self-update +info: checking for updates... + +✔ Already up to date (0.2.0) +``` + +### Story 3: Update to a Specific Version + +```bash +$ vp self-update 0.1.5 +info: checking for updates... +info: found vite-plus-cli@0.1.5 (current: 0.2.0) +info: downloading vite-plus-cli@0.1.5 for darwin-arm64... +info: installing... + +✔ Updated vite-plus from 0.2.0 → 0.1.5 +``` + +### Story 4: Install a Test Channel Build + +```bash +$ vp self-update --tag test +info: checking for updates... +info: found vite-plus-cli@0.3.0-beta.1 (current: 0.2.0) +info: downloading vite-plus-cli@0.3.0-beta.1 for darwin-arm64... +info: installing... + +✔ Updated vite-plus from 0.2.0 → 0.3.0-beta.1 +``` + +### Story 5: Rollback to Previous Version + +```bash +$ vp self-update --rollback +info: rolling back to previous version... +info: switching from 0.2.0 → 0.1.0 + +✔ Rolled back to 0.1.0 +``` + +### Story 6: Check for Updates Without Installing + +```bash +$ vp self-update --check +info: checking for updates... +Update available: 0.2.0 → 0.3.0 +Run `vp self-update` to update. +``` + +### Story 7: CI Environment — Non-interactive + +```bash +# In CI, just update silently +$ vp self-update --silent +``` + +## Technical Design + +### Command Interface + +``` +vp self-update [VERSION] [OPTIONS] +vp upgrade [VERSION] [OPTIONS] # alias + +Arguments: + [VERSION] Target version (e.g., "0.2.0"). Defaults to "latest" + +Options: + --tag npm dist-tag to install (default: "latest", also: "test") + --check Check for updates without installing + --rollback Revert to the previously active version + --force Force reinstall even if already on the target version + --silent Suppress output (useful in CI) + --registry Custom npm registry URL (overrides NPM_CONFIG_REGISTRY) +``` + +### Architecture + +The self-update command is implemented entirely in Rust within the `vite_global_cli` crate, mirroring the logic of `install.sh` but running as a native subprocess workflow. + +``` +┌─────────────────────────────────────────────────┐ +│ vp self-update │ +├─────────────────────────────────────────────────┤ +│ 1. Resolve version (npm registry query) │ +│ 2. Check if already installed │ +│ 3. Download platform binary (.tgz) │ +│ 4. Download main JS bundle (.tgz) │ +│ 5. Extract to ~/.vite-plus/{version}/ │ +│ 6. Install production dependencies │ +│ 7. Atomic swap: current → {version} │ +│ 8. Refresh shims (vp env setup --refresh) │ +│ 9. Cleanup old versions (keep 5) │ +└─────────────────────────────────────────────────┘ +``` + +### Implementation Flow + +#### Step 1: Version Resolution + +Query the npm registry for the target version: + +``` +GET {registry}/vite-plus-cli/{version_or_tag} +``` + +- If `VERSION` arg is provided, use it directly +- If `--tag` is provided, resolve that dist-tag (e.g., `latest`, `test`) +- Default to `latest` + +Parse the JSON response to extract: +- `version`: the resolved semver version +- `optionalDependencies`: to find the platform-specific package name + +#### Step 2: Version Comparison + +Compare the resolved version against the currently running binary's version (`env!("CARGO_PKG_VERSION")`). + +- If same version and `--force` is not set: print "already up to date" and exit +- If target is older: proceed (allows deliberate downgrade) + +#### Step 3: Download and Verify + +Download two tarballs from the npm registry: + +1. **Platform binary**: `{registry}/@voidzero-dev/vite-plus-cli-{platform_suffix}/-/vite-plus-cli-{suffix}-{version}.tgz` + - Contains: `vp` binary + `.node` NAPI files +2. **Main package**: `{registry}/vite-plus-cli/-/vite-plus-cli-{version}.tgz` + - Contains: `dist/` (JS bundles), `package.json`, `templates/`, `rules/`, `AGENTS.md` + +**Integrity verification**: Each tarball is verified against the `integrity` field from the npm registry metadata. The npm registry provides SHA-512 hashes in the [Subresource Integrity](https://w3c.github.io/webappsec-subresource-integrity/) format: + +```json +{ + "dist": { + "tarball": "https://registry.npmjs.org/vite-plus-cli/-/vite-plus-cli-0.0.0-xxx.tgz", + "integrity": "sha512-Z3se9k/NTRf8s5eSmuSoMOFFB/TUGBHIoeWDU5VoHV...", + "shasum": "3399579218148ae410011bde8934e12209743ef3" + } +} +``` + +Verification flow: +1. Download tarball to temp file +2. Compute SHA-512 hash of the downloaded file +3. Base64-encode and compare against `integrity` field (format: `sha512-{base64}`) +4. If mismatch: delete temp file, report error, abort update + +```rust +use sha2::{Sha512, Digest}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; + +fn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> { + // Parse "sha512-{base64}" format + let expected_hash = expected.strip_prefix("sha512-") + .ok_or(Error::UnsupportedIntegrity(expected.into()))?; + + let mut hasher = Sha512::new(); + hasher.update(data); + let actual_hash = STANDARD.encode(hasher.finalize()); + + if actual_hash != expected_hash { + return Err(Error::IntegrityMismatch { + expected: expected.into(), + actual: format!("sha512-{}", actual_hash), + }); + } + Ok(()) +} +``` + +To get the `integrity` field for the platform package, we need to query its metadata separately: +- Main package metadata: `{registry}/vite-plus-cli/{version}` → contains `dist.integrity` +- Platform package metadata: `{registry}/@voidzero-dev/vite-plus-cli-{suffix}/{version}` → contains `dist.integrity` + +Platform detection reuses existing logic from `vite_js_runtime` or mirrors the bash script's approach: +- `uname -s` → os (darwin, linux) +- `uname -m` → arch (x64, arm64) +- Linux: detect gnu vs musl libc + +#### Step 4: Extract and Install + +1. Create `~/.vite-plus/{version}/` with `bin/` and `dist/` subdirectories +2. Extract platform binary to `{version}/bin/vp`, set executable permissions +3. Extract `.node` files to `{version}/dist/` +4. Extract JS bundle, templates, rules, package.json to `{version}/` +5. Strip `devDependencies` and `optionalDependencies` from package.json +6. Run `vp install --silent` in the version directory to install production dependencies + +#### Step 5: Version Swap + +**Unix (macOS/Linux)** — Atomic symlink swap: + +```rust +// Atomic symlink swap using rename +let temp_link = install_dir.join("current.new"); +std::os::unix::fs::symlink(version, &temp_link)?; +std::fs::rename(&temp_link, install_dir.join("current"))?; +``` + +This is atomic on POSIX systems because `rename()` on a symlink is an atomic operation. + +**Windows** — Junction swap (non-atomic, matching `install.ps1`): + +```rust +// Windows uses junctions (mklink /J) — no admin privileges required +let current_link = install_dir.join("current"); + +// Remove existing junction +if current_link.exists() { + junction::delete(¤t_link)?; +} + +// Create new junction pointing to version directory +junction::create(version_dir, ¤t_link)?; +``` + +Key differences on Windows: +- **Junctions** (`mklink /J`) are used instead of symlinks — junctions don't require admin privileges +- Junctions only work for directories (which `current` is), and use absolute paths internally +- The swap is **not atomic** — there's a brief window (~milliseconds) where `current` doesn't exist +- `bin/vp` is a `.cmd` wrapper (not a symlink), so it doesn't need updating during self-update +- This matches the existing `install.ps1` behavior exactly + +#### Step 6: Post-Update + +1. Refresh shims: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx shims point to the new version +2. Cleanup: Remove old version directories, keeping the 5 most recent + +#### Step 7: Running Binary Consideration + +The running `vp` process is **not** the binary being replaced. The flow is: + +``` +# Unix +~/.vite-plus/bin/vp → ../current/bin/vp → {old_version}/bin/vp + +# Windows +~/.vite-plus/bin/vp.cmd → current\bin\vp.exe → {old_version}\bin\vp.exe +``` + +After the `current` link swap, any **new** invocation of `vp` will use the new binary. The currently running process continues to execute from the old version's binary file on disk: +- **Unix**: The old binary remains valid because Unix doesn't delete open files until all file descriptors are closed +- **Windows**: The old `.exe` file is locked while running, but since we install to a **new version directory** (not overwriting in-place), there's no conflict. The old version directory is preserved (kept in the "last 5" cleanup policy) + +### Rollback Design + +The `--rollback` flag switches the `current` symlink to the previously active version. + +To track the previous version, we can: +1. Read the `current` symlink target before updating +2. After the update, write the previous version to `~/.vite-plus/.previous-version` + +For `--rollback`: +1. Read `~/.vite-plus/.previous-version` +2. Verify that version directory still exists +3. Swap `current` symlink to point to it +4. Update `.previous-version` to point to the version we just rolled back from + +### Error Handling + +| Error | Recovery | +|-------|----------| +| Network failure during download | Clean up partial temp files, exit with helpful message | +| Integrity mismatch (SHA-512) | Delete downloaded file, report expected vs actual hash, abort | +| Corrupted tarball | Verify extraction success, clean up version dir if partial | +| `vp install` fails | Remove the version dir, keep current version unchanged | +| Disk full | Detect and report, clean up partial state | +| Permission denied | Report with suggestion to check directory ownership | +| Registry returns error | Parse npm error JSON, show human-readable message | + +Key principle: **The `current` symlink is only swapped after all steps succeed.** If any step fails, the existing installation is untouched. + +### File Structure + +``` +crates/vite_global_cli/ +├── src/ +│ ├── commands/ +│ │ ├── self_update/ +│ │ │ ├── mod.rs # Module root, public execute() function +│ │ │ ├── registry.rs # npm registry client (version resolution, tarball URLs) +│ │ │ ├── platform.rs # Platform detection (os, arch, libc) +│ │ │ ├── download.rs # HTTP download + tarball extraction +│ │ │ └── install.rs # Extract, dependency install, symlink swap, cleanup +│ │ ├── mod.rs # Add self_update module +│ │ └── ... +│ └── cli.rs # Add SelfUpdate command variant +``` + +### Platform Detection + +```rust +fn detect_platform() -> Result { + let os = std::env::consts::OS; // "macos", "linux", "windows" + let arch = std::env::consts::ARCH; // "x86_64", "aarch64" + + let os_name = match os { + "macos" => "darwin", + "linux" => "linux", + "windows" => "win32", + _ => return Err(Error::UnsupportedPlatform(os.into())), + }; + + let arch_name = match arch { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => return Err(Error::UnsupportedArch(arch.into())), + }; + + if os_name == "linux" { + let libc = detect_libc(); // "gnu" or "musl" + Ok(format!("{os_name}-{arch_name}-{libc}")) + } else { + Ok(format!("{os_name}-{arch_name}")) + } +} +``` + +### Registry Client + +Uses `reqwest` (already a dependency via `vite_js_runtime`) for HTTP requests: + +```rust +async fn resolve_version(registry: &str, version_or_tag: &str) -> Result { + let url = format!("{}/vite-plus-cli/{}", registry, version_or_tag); + let response = reqwest::get(&url).await?.json::().await?; + Ok(response) +} +``` + +### CLI Integration + +Add `SelfUpdate` to the `Commands` enum in `cli.rs`: + +```rust +/// Update vp itself to the latest version +#[command(name = "self-update", visible_alias = "upgrade")] +SelfUpdate { + /// Target version (default: latest) + version: Option, + + /// npm dist-tag (default: "latest") + #[arg(long, default_value = "latest")] + tag: String, + + /// Check for updates without installing + #[arg(long)] + check: bool, + + /// Revert to previous version + #[arg(long)] + rollback: bool, + + /// Force reinstall even if up to date + #[arg(long)] + force: bool, + + /// Suppress output + #[arg(long)] + silent: bool, + + /// Custom npm registry URL + #[arg(long)] + registry: Option, +}, +``` + +## Design Decisions + +### 1. Command Name: `self-update` + +**Decision**: Use `vp self-update` (with hyphen). + +**Alternatives considered**: +- `vp upgrade` — used by Deno, Bun, proto; shorter but ambiguous with `vp update` (packages) +- `vp self upgrade` — used by rustup (`rustup self update`); requires subcommand group + +**Rationale**: +- Matches pnpm (`pnpm self-update`) and mise (`mise self-update`) conventions +- Zero ambiguity with `vp update` (which updates npm packages) +- The hyphen is consistent with `list-remote` in `vp env` +- Tools without self-update (fnm, volta, nvm) require re-running install scripts — worse UX +- `upgrade` is registered as a visible alias, so `vp upgrade` also works (matches Deno/Bun/proto users' expectations) + +### 2. Pure Rust Implementation (No Shell Script Re-execution) + +**Decision**: Implement the update logic entirely in Rust. + +**Rationale**: +- No dependency on bash or curl being installed +- Better error handling and progress reporting +- Consistent behavior across platforms +- The install.sh script remains for first-time installation only + +### 3. Reuse npm Distribution Channel + +**Decision**: Download tarballs from the same npm registry used by `install.sh`. + +**Rationale**: +- No new infrastructure needed +- Same release pipeline, same artifacts +- Supports custom registries and mirrors via `--registry` or `NPM_CONFIG_REGISTRY` +- Users behind corporate proxies already have npm registry access configured + +### 4. No Automatic Update Checks + +**Decision**: Do not check for updates on every `vp` invocation. + +**Rationale**: +- Avoids unexpected network requests that slow down commands +- Avoids privacy concerns (phoning home on every run) +- Users can opt into periodic checks via their own cron/launchd if desired +- This can be revisited as a future enhancement with proper opt-in + +### 5. Keep 5 Versions for Rollback + +**Decision**: Maintain the same cleanup policy as `install.sh` (keep 5 most recent versions). + +**Rationale**: +- Consistent with existing behavior +- Provides rollback safety net without unbounded disk usage +- Each version is ~20-30MB, so 5 versions is ~100-150MB total + +## Implementation Phases + +### Phase 0 (P0): Core Self-Update + +**Scope:** +- `vp self-update` — downloads and installs the latest version +- `vp self-update ` — installs a specific version +- `--tag`, `--force`, `--silent` flags +- Platform detection, npm registry query, download, extract, symlink swap +- Version cleanup (keep 5) +- Error handling with clean rollback + +**Files to create/modify:** +- `crates/vite_global_cli/src/commands/self_update/mod.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/registry.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/platform.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/download.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/install.rs` (new) +- `crates/vite_global_cli/src/commands/mod.rs` (add module) +- `crates/vite_global_cli/src/cli.rs` (add command variant + routing) + +**Success Criteria:** +- [ ] `vp self-update` downloads and installs the latest version +- [ ] `vp self-update 0.x.y` installs a specific version +- [ ] Downloaded tarballs are verified against npm registry `integrity` (SHA-512) +- [ ] Running binary is not affected during update +- [ ] Failed update leaves the current installation untouched +- [ ] Old versions are cleaned up (max 5 retained) +- [ ] Works on macOS, Linux, and Windows + +### Phase 1 (P1): Rollback and Check + +**Scope:** +- `--rollback` flag with `.previous-version` tracking +- `--check` flag for update availability check + +**Success Criteria:** +- [ ] `vp self-update --rollback` reverts to previous version +- [ ] `vp self-update --check` shows available update without installing + +### Phase 2 (P2): Enhanced UX + +**Scope:** +- Progress bar for downloads (using `indicatif` or similar) +- Release notes URL in update success message +- `--registry` flag for custom npm registry + +**Success Criteria:** +- [ ] Download progress is visible for large binaries +- [ ] Release notes link is shown after successful update + +## Testing Strategy + +### Unit Tests + +- Version comparison logic (semver parsing, equality, ordering) +- Platform detection (mock `std::env::consts`) +- Registry URL construction +- Symlink swap atomicity + +### Integration Tests + +- Download and extract a real package from the test npm tag +- Verify version directory structure after install +- Verify `current` symlink points to new version +- Verify old version cleanup + +### Snap Tests + +```bash +# Test: self-update check (mock registry response) +pnpm -F vite-plus-cli snap-test self-update-check + +# Test: self-update to specific version +pnpm -F vite-plus-cli snap-test self-update-version +``` + +### Manual Testing + +```bash +# Build and install current version +pnpm bootstrap-cli + +# Run self-update to latest published version +vp self-update + +# Verify version changed +vp -V + +# Test rollback +vp self-update --rollback +vp -V +``` + +## Future Enhancements + +- **Automatic update check**: Periodic background check with opt-in notification (e.g., once per day, cached result) +- **Update channels**: Allow pinning to a channel (stable, beta, nightly) via config file +- **Delta updates**: Download only changed files instead of full tarballs +- **Windows support**: Extend to PowerShell-based update mechanism for Windows native installs + +## References + +- [RFC: Global CLI (Rust Binary)](./global-cli-rust-binary.md) +- [RFC: Split Global CLI](./split-global-cli.md) +- [RFC: Env Command](./env-command.md) +- [Install Script](../packages/global/install.sh) +- [Release Workflow](../.github/workflows/release.yml) From f6d602e98d4a07a50c889819acf246ea43ca1fd1 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 11:55:43 +0800 Subject: [PATCH 02/13] test(cli): add snap tests for `vp self-update` command - Add self-update help to cli-helper-message snap test - Add command-self-update-check test (--check flag and upgrade alias) - Add command-self-update-rollback test (error when no previous version) - Rename help section to "Maintenance Commands" for consistency - Fix description alignment to match other sections --- crates/vite_global_cli/src/cli.rs | 6 +-- .../snap-tests/cli-helper-message/snap.txt | 20 ++++++++++ .../snap-tests/cli-helper-message/steps.json | 3 +- .../command-self-update-check/snap.txt | 11 ++++++ .../command-self-update-check/steps.json | 7 ++++ .../command-self-update-rollback/snap.txt | 2 + .../command-self-update-rollback/steps.json | 4 ++ rfcs/self-update-command.md | 39 ++++++++++++++----- 8 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 packages/global/snap-tests/command-self-update-check/snap.txt create mode 100644 packages/global/snap-tests/command-self-update-check/steps.json create mode 100644 packages/global/snap-tests/command-self-update-rollback/snap.txt create mode 100644 packages/global/snap-tests/command-self-update-rollback/steps.json diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 1323853eb2..6b0a7ff2ab 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -1602,9 +1602,6 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command { {bold}run{reset} Run tasks {bold}env{reset} Manage Node.js versions -{bold_underline}Self-Management:{reset} - {bold}self-update, upgrade{reset} Update vp itself to the latest version - {bold_underline}Package Manager Commands:{reset} {bold}install, i{reset} Install all dependencies, or add packages if package names are provided {bold}add{reset} Add packages to dependencies @@ -1619,6 +1616,9 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command { {bold}unlink{reset} Unlink packages {bold}update, up{reset} Update packages to their latest versions {bold}why, explain{reset} Show why a package is installed + +{bold_underline}Maintenance Commands:{reset} + {bold}self-update, upgrade{reset} Update vp itself to the latest version " ); let help_template = format!( diff --git a/packages/global/snap-tests/cli-helper-message/snap.txt b/packages/global/snap-tests/cli-helper-message/snap.txt index 0e29fdbc79..24a76a1530 100644 --- a/packages/global/snap-tests/cli-helper-message/snap.txt +++ b/packages/global/snap-tests/cli-helper-message/snap.txt @@ -31,6 +31,9 @@ Package Manager Commands: update, up Update packages to their latest versions why, explain Show why a package is installed +Maintenance Commands: + self-update, upgrade Update vp itself to the latest version + Options: -V, --version Print version -h, --help Print help @@ -336,3 +339,20 @@ Global Packages: vp uninstall -g # Uninstall a global package vp update -g [package] # Update global package(s) vp list -g [package] # List installed global packages + +> vp self-update -h # show self-update help message +Update vp itself to the latest version + +Usage: vp self-update [OPTIONS] [VERSION] + +Arguments: + [VERSION] Target version (e.g., "0.2.0"). Defaults to latest + +Options: + --tag npm dist-tag to install (default: "latest", also: "test") [default: latest] + --check Check for updates without installing + --rollback Revert to the previously active version + --force Force reinstall even if already on the target version + --silent Suppress output + --registry Custom npm registry URL + -h, --help Print help diff --git a/packages/global/snap-tests/cli-helper-message/steps.json b/packages/global/snap-tests/cli-helper-message/steps.json index 9479bec876..763f692eff 100644 --- a/packages/global/snap-tests/cli-helper-message/steps.json +++ b/packages/global/snap-tests/cli-helper-message/steps.json @@ -13,6 +13,7 @@ "vp why -h # show why help message", "vp info -h # show info help message", "vp pm -h # show pm help message", - "vp env # show env help message" + "vp env # show env help message", + "vp self-update -h # show self-update help message" ] } diff --git a/packages/global/snap-tests/command-self-update-check/snap.txt b/packages/global/snap-tests/command-self-update-check/snap.txt new file mode 100644 index 0000000000..729c3d0573 --- /dev/null +++ b/packages/global/snap-tests/command-self-update-check/snap.txt @@ -0,0 +1,11 @@ +> vp self-update --check # check for updates without installing +info: checking for updates... +info: found vite-plus-cli@ +Update available: +Run `vp self-update` to update. + +> vp upgrade --check # check using upgrade alias +info: checking for updates... +info: found vite-plus-cli@ +Update available: +Run `vp self-update` to update. diff --git a/packages/global/snap-tests/command-self-update-check/steps.json b/packages/global/snap-tests/command-self-update-check/steps.json new file mode 100644 index 0000000000..22fcef559e --- /dev/null +++ b/packages/global/snap-tests/command-self-update-check/steps.json @@ -0,0 +1,7 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "vp self-update --check # check for updates without installing", + "vp upgrade --check # check using upgrade alias" + ] +} diff --git a/packages/global/snap-tests/command-self-update-rollback/snap.txt b/packages/global/snap-tests/command-self-update-rollback/snap.txt new file mode 100644 index 0000000000..3283d2e45e --- /dev/null +++ b/packages/global/snap-tests/command-self-update-rollback/snap.txt @@ -0,0 +1,2 @@ +[1]> vp self-update --rollback # should fail with no previous version +Error: Self-update error: No previous version found. Cannot rollback. diff --git a/packages/global/snap-tests/command-self-update-rollback/steps.json b/packages/global/snap-tests/command-self-update-rollback/steps.json new file mode 100644 index 0000000000..a7f812cb48 --- /dev/null +++ b/packages/global/snap-tests/command-self-update-rollback/steps.json @@ -0,0 +1,4 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": ["vp self-update --rollback # should fail with no previous version"] +} diff --git a/rfcs/self-update-command.md b/rfcs/self-update-command.md index 0f25dc273f..1b4065747d 100644 --- a/rfcs/self-update-command.md +++ b/rfcs/self-update-command.md @@ -180,6 +180,7 @@ GET {registry}/vite-plus-cli/{version_or_tag} - Default to `latest` Parse the JSON response to extract: + - `version`: the resolved semver version - `optionalDependencies`: to find the platform-specific package name @@ -212,6 +213,7 @@ Download two tarballs from the npm registry: ``` Verification flow: + 1. Download tarball to temp file 2. Compute SHA-512 hash of the downloaded file 3. Base64-encode and compare against `integrity` field (format: `sha512-{base64}`) @@ -241,10 +243,12 @@ fn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> { ``` To get the `integrity` field for the platform package, we need to query its metadata separately: + - Main package metadata: `{registry}/vite-plus-cli/{version}` → contains `dist.integrity` - Platform package metadata: `{registry}/@voidzero-dev/vite-plus-cli-{suffix}/{version}` → contains `dist.integrity` Platform detection reuses existing logic from `vite_js_runtime` or mirrors the bash script's approach: + - `uname -s` → os (darwin, linux) - `uname -m` → arch (x64, arm64) - Linux: detect gnu vs musl libc @@ -287,6 +291,7 @@ junction::create(version_dir, ¤t_link)?; ``` Key differences on Windows: + - **Junctions** (`mklink /J`) are used instead of symlinks — junctions don't require admin privileges - Junctions only work for directories (which `current` is), and use absolute paths internally - The swap is **not atomic** — there's a brief window (~milliseconds) where `current` doesn't exist @@ -311,6 +316,7 @@ The running `vp` process is **not** the binary being replaced. The flow is: ``` After the `current` link swap, any **new** invocation of `vp` will use the new binary. The currently running process continues to execute from the old version's binary file on disk: + - **Unix**: The old binary remains valid because Unix doesn't delete open files until all file descriptors are closed - **Windows**: The old `.exe` file is locked while running, but since we install to a **new version directory** (not overwriting in-place), there's no conflict. The old version directory is preserved (kept in the "last 5" cleanup policy) @@ -319,10 +325,12 @@ After the `current` link swap, any **new** invocation of `vp` will use the new b The `--rollback` flag switches the `current` symlink to the previously active version. To track the previous version, we can: + 1. Read the `current` symlink target before updating 2. After the update, write the previous version to `~/.vite-plus/.previous-version` For `--rollback`: + 1. Read `~/.vite-plus/.previous-version` 2. Verify that version directory still exists 3. Swap `current` symlink to point to it @@ -330,15 +338,15 @@ For `--rollback`: ### Error Handling -| Error | Recovery | -|-------|----------| -| Network failure during download | Clean up partial temp files, exit with helpful message | -| Integrity mismatch (SHA-512) | Delete downloaded file, report expected vs actual hash, abort | -| Corrupted tarball | Verify extraction success, clean up version dir if partial | -| `vp install` fails | Remove the version dir, keep current version unchanged | -| Disk full | Detect and report, clean up partial state | -| Permission denied | Report with suggestion to check directory ownership | -| Registry returns error | Parse npm error JSON, show human-readable message | +| Error | Recovery | +| ------------------------------- | ------------------------------------------------------------- | +| Network failure during download | Clean up partial temp files, exit with helpful message | +| Integrity mismatch (SHA-512) | Delete downloaded file, report expected vs actual hash, abort | +| Corrupted tarball | Verify extraction success, clean up version dir if partial | +| `vp install` fails | Remove the version dir, keep current version unchanged | +| Disk full | Detect and report, clean up partial state | +| Permission denied | Report with suggestion to check directory ownership | +| Registry returns error | Parse npm error JSON, show human-readable message | Key principle: **The `current` symlink is only swapped after all steps succeed.** If any step fails, the existing installation is untouched. @@ -444,10 +452,12 @@ SelfUpdate { **Decision**: Use `vp self-update` (with hyphen). **Alternatives considered**: + - `vp upgrade` — used by Deno, Bun, proto; shorter but ambiguous with `vp update` (packages) - `vp self upgrade` — used by rustup (`rustup self update`); requires subcommand group **Rationale**: + - Matches pnpm (`pnpm self-update`) and mise (`mise self-update`) conventions - Zero ambiguity with `vp update` (which updates npm packages) - The hyphen is consistent with `list-remote` in `vp env` @@ -459,6 +469,7 @@ SelfUpdate { **Decision**: Implement the update logic entirely in Rust. **Rationale**: + - No dependency on bash or curl being installed - Better error handling and progress reporting - Consistent behavior across platforms @@ -469,6 +480,7 @@ SelfUpdate { **Decision**: Download tarballs from the same npm registry used by `install.sh`. **Rationale**: + - No new infrastructure needed - Same release pipeline, same artifacts - Supports custom registries and mirrors via `--registry` or `NPM_CONFIG_REGISTRY` @@ -479,6 +491,7 @@ SelfUpdate { **Decision**: Do not check for updates on every `vp` invocation. **Rationale**: + - Avoids unexpected network requests that slow down commands - Avoids privacy concerns (phoning home on every run) - Users can opt into periodic checks via their own cron/launchd if desired @@ -489,6 +502,7 @@ SelfUpdate { **Decision**: Maintain the same cleanup policy as `install.sh` (keep 5 most recent versions). **Rationale**: + - Consistent with existing behavior - Provides rollback safety net without unbounded disk usage - Each version is ~20-30MB, so 5 versions is ~100-150MB total @@ -498,6 +512,7 @@ SelfUpdate { ### Phase 0 (P0): Core Self-Update **Scope:** + - `vp self-update` — downloads and installs the latest version - `vp self-update ` — installs a specific version - `--tag`, `--force`, `--silent` flags @@ -506,6 +521,7 @@ SelfUpdate { - Error handling with clean rollback **Files to create/modify:** + - `crates/vite_global_cli/src/commands/self_update/mod.rs` (new) - `crates/vite_global_cli/src/commands/self_update/registry.rs` (new) - `crates/vite_global_cli/src/commands/self_update/platform.rs` (new) @@ -515,6 +531,7 @@ SelfUpdate { - `crates/vite_global_cli/src/cli.rs` (add command variant + routing) **Success Criteria:** + - [ ] `vp self-update` downloads and installs the latest version - [ ] `vp self-update 0.x.y` installs a specific version - [ ] Downloaded tarballs are verified against npm registry `integrity` (SHA-512) @@ -526,21 +543,25 @@ SelfUpdate { ### Phase 1 (P1): Rollback and Check **Scope:** + - `--rollback` flag with `.previous-version` tracking - `--check` flag for update availability check **Success Criteria:** + - [ ] `vp self-update --rollback` reverts to previous version - [ ] `vp self-update --check` shows available update without installing ### Phase 2 (P2): Enhanced UX **Scope:** + - Progress bar for downloads (using `indicatif` or similar) - Release notes URL in update success message - `--registry` flag for custom npm registry **Success Criteria:** + - [ ] Download progress is visible for large binaries - [ ] Release notes link is shown after successful update From dca853c4375829e2e52c0dd422f987cb6886173d Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 16:00:11 +0800 Subject: [PATCH 03/13] fix(cli): fix self-update Windows suffix, cleanup safety, and post-swap error handling - Add `-msvc` suffix for Windows platform detection (win32-x64-msvc) - Sort cleanup by creation time instead of semver, matching install.sh - Add protected_versions to cleanup to prevent deleting active version on downgrade - Make post-swap operations (shim refresh, cleanup) non-fatal - Add tests for all three fixes - Sync RFC with implementation changes --- .../src/commands/self_update/install.rs | 99 +++++++++++++++++-- .../src/commands/self_update/mod.rs | 18 +++- .../src/commands/self_update/platform.rs | 6 +- .../src/commands/self_update/registry.rs | 46 +++++++++ rfcs/self-update-command.md | 21 ++-- 5 files changed, 168 insertions(+), 22 deletions(-) diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs index e3eeec1aac..a44d89c55a 100644 --- a/crates/vite_global_cli/src/commands/self_update/install.rs +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -330,12 +330,15 @@ pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { /// Clean up old version directories, keeping at most `max_keep` versions. /// -/// Sorts by semver (newest first) and removes the oldest beyond the limit. +/// Sorts by creation time (newest first, matching install.sh behavior) and removes +/// the oldest beyond the limit. Protected versions are never removed, even if they +/// fall outside the keep limit (e.g., the active version after a downgrade). pub async fn cleanup_old_versions( install_dir: &AbsolutePath, max_keep: usize, + protected_versions: &[&str], ) -> Result<(), Error> { - let mut versions: Vec<(node_semver::Version, AbsolutePathBuf)> = Vec::new(); + let mut versions: Vec<(std::time::SystemTime, AbsolutePathBuf)> = Vec::new(); let mut entries = tokio::fs::read_dir(install_dir).await?; while let Some(entry) = entries.next_entry().await? { @@ -343,21 +346,31 @@ pub async fn cleanup_old_versions( let name_str = name.to_string_lossy(); // Only consider entries that parse as semver - if let Ok(ver) = node_semver::Version::parse(&name_str) { + if node_semver::Version::parse(&name_str).is_ok() { + let metadata = entry.metadata().await?; + // Use creation time (birth time), fallback to modified time + let time = metadata.created().unwrap_or_else(|_| { + metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); let path = AbsolutePathBuf::new(entry.path()).ok_or_else(|| { Error::SelfUpdate( format!("Invalid absolute path: {}", entry.path().display()).into(), ) })?; - versions.push((ver, path)); + versions.push((time, path)); } } - // Sort newest first + // Sort newest first (by creation time, matching install.sh) versions.sort_by(|a, b| b.0.cmp(&a.0)); - // Remove versions beyond the keep limit - for (_ver, path) in versions.into_iter().skip(max_keep) { + // Remove versions beyond the keep limit, but never remove protected versions + for (_time, path) in versions.into_iter().skip(max_keep) { + let name = path.as_path().file_name().and_then(|n| n.to_str()).unwrap_or(""); + if protected_versions.contains(&name) { + tracing::debug!("Skipping protected version: {}", name); + continue; + } tracing::debug!("Cleaning up old version: {}", path.as_path().display()); if let Err(e) = tokio::fs::remove_dir_all(&path).await { tracing::warn!("Failed to remove {}: {}", path.as_path().display(), e); @@ -405,4 +418,76 @@ mod tests { assert!(!is_safe_tar_path(Path::new("/etc/passwd"))); assert!(!is_safe_tar_path(Path::new("/usr/bin/vp"))); } + + #[tokio::test] + async fn test_cleanup_preserves_active_downgraded_version() { + let temp = tempfile::tempdir().unwrap(); + let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + + // Create 7 version directories with staggered creation times. + // Simulate: installed 0.1-0.7 in order, then rolled back to 0.2.0 + for v in ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0"] { + tokio::fs::create_dir(install_dir.join(v)).await.unwrap(); + // Small delay to ensure distinct creation times + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Simulate rollback: current points to 0.2.0 (low semver rank) + #[cfg(unix)] + tokio::fs::symlink("0.2.0", install_dir.join("current")).await.unwrap(); + + // Cleanup keeping top 5, with 0.2.0 protected (the active version) + cleanup_old_versions(&install_dir, 5, &["0.2.0"]).await.unwrap(); + + // 0.2.0 is the active version — it MUST survive cleanup + assert!( + tokio::fs::try_exists(install_dir.join("0.2.0")).await.unwrap(), + "Active version 0.2.0 was deleted by cleanup" + ); + } + + #[tokio::test] + async fn test_cleanup_sorts_by_creation_time_not_semver() { + let temp = tempfile::tempdir().unwrap(); + let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + + // Create versions in non-semver order with creation times: + // 0.5.0 (oldest), 0.1.0, 0.3.0, 0.7.0, 0.2.0, 0.6.0 (newest) + for v in ["0.5.0", "0.1.0", "0.3.0", "0.7.0", "0.2.0", "0.6.0"] { + tokio::fs::create_dir(install_dir.join(v)).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Keep top 4 by creation time → keep 0.6.0, 0.2.0, 0.7.0, 0.3.0 + // Remove 0.1.0 and 0.5.0 (oldest by creation time) + cleanup_old_versions(&install_dir, 4, &[]).await.unwrap(); + + // The 4 newest by creation time should survive + assert!(tokio::fs::try_exists(install_dir.join("0.6.0")).await.unwrap()); + assert!(tokio::fs::try_exists(install_dir.join("0.2.0")).await.unwrap()); + assert!(tokio::fs::try_exists(install_dir.join("0.7.0")).await.unwrap()); + assert!(tokio::fs::try_exists(install_dir.join("0.3.0")).await.unwrap()); + + // The 2 oldest by creation time should be removed + assert!( + !tokio::fs::try_exists(install_dir.join("0.5.0")).await.unwrap(), + "0.5.0 (oldest by creation time) should have been removed" + ); + assert!( + !tokio::fs::try_exists(install_dir.join("0.1.0")).await.unwrap(), + "0.1.0 (second oldest by creation time) should have been removed" + ); + } + + #[tokio::test] + async fn test_cleanup_old_versions_with_nonexistent_dir() { + // Verifies that cleanup_old_versions propagates errors on non-existent dir. + // In the real flow, such errors from post-swap operations should be non-fatal. + let non_existent = AbsolutePathBuf::new(std::path::PathBuf::from( + "/tmp/non-existent-self-update-test-dir", + )) + .unwrap(); + let result = cleanup_old_versions(&non_existent, 5, &[]).await; + assert!(result.is_err(), "cleanup_old_versions should error on non-existent dir"); + } } diff --git a/crates/vite_global_cli/src/commands/self_update/mod.rs b/crates/vite_global_cli/src/commands/self_update/mod.rs index 3df44fcb2d..670d5f0a17 100644 --- a/crates/vite_global_cli/src/commands/self_update/mod.rs +++ b/crates/vite_global_cli/src/commands/self_update/mod.rs @@ -186,14 +186,22 @@ async fn install_platform_and_main( let previous_version = install::save_previous_version(install_dir).await?; tracing::debug!("Previous version: {:?}", previous_version); - // Swap current link + // Swap current link — POINT OF NO RETURN install::swap_current_link(install_dir, new_version).await?; - // Refresh shims - install::refresh_shims(install_dir).await?; + // Post-swap operations: non-fatal (the update already succeeded) + if let Err(e) = install::refresh_shims(install_dir).await { + eprintln!("warn: Shim refresh failed (non-fatal): {e}"); + } - // Cleanup old versions - install::cleanup_old_versions(install_dir, MAX_VERSIONS_KEEP).await?; + let mut protected = vec![new_version]; + if let Some(ref prev) = previous_version { + protected.push(prev.as_str()); + } + if let Err(e) = install::cleanup_old_versions(install_dir, MAX_VERSIONS_KEEP, &protected).await + { + eprintln!("warn: Old version cleanup failed (non-fatal): {e}"); + } if !silent { println!( diff --git a/crates/vite_global_cli/src/commands/self_update/platform.rs b/crates/vite_global_cli/src/commands/self_update/platform.rs index 47f06e3f3c..39a4b24d57 100644 --- a/crates/vite_global_cli/src/commands/self_update/platform.rs +++ b/crates/vite_global_cli/src/commands/self_update/platform.rs @@ -7,7 +7,7 @@ use crate::error::Error; /// Detect the current platform suffix for npm package naming. /// -/// Returns strings like `darwin-arm64`, `linux-x64-gnu`, `linux-arm64-musl`, `win32-x64`. +/// Returns strings like `darwin-arm64`, `linux-x64-gnu`, `linux-arm64-musl`, `win32-x64-msvc`. pub fn detect_platform_suffix() -> Result { let os_name = if cfg!(target_os = "macos") { "darwin" @@ -34,6 +34,8 @@ pub fn detect_platform_suffix() -> Result { if os_name == "linux" { let libc = if cfg!(target_env = "musl") { "musl" } else { "gnu" }; Ok(format!("{os_name}-{arch_name}-{libc}")) + } else if os_name == "win32" { + Ok(format!("{os_name}-{arch_name}-msvc")) } else { Ok(format!("{os_name}-{arch_name}")) } @@ -68,6 +70,6 @@ mod tests { assert_eq!(suffix, "linux-arm64-gnu"); #[cfg(all(target_os = "windows", target_arch = "x86_64"))] - assert_eq!(suffix, "win32-x64"); + assert_eq!(suffix, "win32-x64-msvc"); } } diff --git a/crates/vite_global_cli/src/commands/self_update/registry.rs b/crates/vite_global_cli/src/commands/self_update/registry.rs index 53301bce43..6e4ec1c1b9 100644 --- a/crates/vite_global_cli/src/commands/self_update/registry.rs +++ b/crates/vite_global_cli/src/commands/self_update/registry.rs @@ -106,4 +106,50 @@ mod tests { let name = format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{suffix}"); assert_eq!(name, "@voidzero-dev/vite-plus-cli-darwin-arm64"); } + + #[test] + fn test_all_platform_suffixes_match_published_packages() { + // These are the actual published optionalDependencies keys + // (from packages/global/publish-native-addons.ts RUST_TARGETS keys) + let published_suffixes = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64-gnu", + "linux-x64-gnu", + "win32-arm64-msvc", + "win32-x64-msvc", + ]; + + let published_deps: HashMap = published_suffixes + .iter() + .map(|s| { + (format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{s}"), "0.1.0".to_string()) + }) + .collect(); + + // All known platform suffixes that detect_platform_suffix() can return + let detection_suffixes = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64-gnu", + "linux-x64-gnu", + "linux-arm64-musl", + "linux-x64-musl", + "win32-arm64-msvc", + "win32-x64-msvc", + ]; + + for suffix in &detection_suffixes { + let package_name = format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{suffix}"); + // musl variants are not published, so skip them + if suffix.contains("musl") { + continue; + } + assert!( + published_deps.contains_key(&package_name), + "Platform suffix '{suffix}' produces package name '{package_name}' \ + which does not match any published package" + ); + } + } } diff --git a/rfcs/self-update-command.md b/rfcs/self-update-command.md index 1b4065747d..597acddfb5 100644 --- a/rfcs/self-update-command.md +++ b/rfcs/self-update-command.md @@ -160,8 +160,8 @@ The self-update command is implemented entirely in Rust within the `vite_global_ │ 5. Extract to ~/.vite-plus/{version}/ │ │ 6. Install production dependencies │ │ 7. Atomic swap: current → {version} │ -│ 8. Refresh shims (vp env setup --refresh) │ -│ 9. Cleanup old versions (keep 5) │ +│ 8. Refresh shims (non-fatal) │ +│ 9. Cleanup old versions (non-fatal, keep 5) │ └─────────────────────────────────────────────────┘ ``` @@ -298,10 +298,12 @@ Key differences on Windows: - `bin/vp` is a `.cmd` wrapper (not a symlink), so it doesn't need updating during self-update - This matches the existing `install.ps1` behavior exactly -#### Step 6: Post-Update +#### Step 6: Post-Update (Non-Fatal) -1. Refresh shims: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx shims point to the new version -2. Cleanup: Remove old version directories, keeping the 5 most recent +After the symlink swap (the **point of no return**), post-update operations are treated as non-fatal. Errors are printed to stderr as warnings but do not trigger the outer error handler (which would delete the now-active version directory). + +1. **Refresh shims**: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx shims point to the new version. If this fails, the user can run it manually. +2. **Cleanup old versions**: Remove old version directories, keeping the 5 most recent by **creation time** (matching `install.sh` behavior). The new version and the previous version are always protected from cleanup, even if they fall outside the top 5 (e.g., after a downgrade via `--rollback`). #### Step 7: Running Binary Consideration @@ -348,7 +350,7 @@ For `--rollback`: | Permission denied | Report with suggestion to check directory ownership | | Registry returns error | Parse npm error JSON, show human-readable message | -Key principle: **The `current` symlink is only swapped after all steps succeed.** If any step fails, the existing installation is untouched. +Key principle: **The `current` symlink is only swapped after all pre-swap steps succeed.** If any pre-swap step fails, the existing installation is untouched. Post-swap operations (shim refresh, old version cleanup) are non-fatal — their errors are printed to stderr as warnings but do not roll back the update. ### File Structure @@ -390,6 +392,8 @@ fn detect_platform() -> Result { if os_name == "linux" { let libc = detect_libc(); // "gnu" or "musl" Ok(format!("{os_name}-{arch_name}-{libc}")) + } else if os_name == "win32" { + Ok(format!("{os_name}-{arch_name}-msvc")) } else { Ok(format!("{os_name}-{arch_name}")) } @@ -499,13 +503,14 @@ SelfUpdate { ### 5. Keep 5 Versions for Rollback -**Decision**: Maintain the same cleanup policy as `install.sh` (keep 5 most recent versions). +**Decision**: Maintain the same cleanup policy as `install.sh` (keep 5 most recent versions by creation time, with protected versions). **Rationale**: -- Consistent with existing behavior +- Consistent with existing `install.sh` behavior (sorts by creation time, not semver) - Provides rollback safety net without unbounded disk usage - Each version is ~20-30MB, so 5 versions is ~100-150MB total +- The active version and previous version are always protected from cleanup, preventing accidental deletion after a downgrade ## Implementation Phases From bd9c545cf4ab05ea06c59a77f6d3cbc319ad7fff Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 16:02:12 +0800 Subject: [PATCH 04/13] fix(cli): handle Unix-style absolute paths in tar safety check on Windows `Path::is_absolute()` on Windows only recognizes `C:\...` style paths, so `/etc/passwd` from a tar archive was not rejected. Add explicit check for leading `/` since tar archives always use Unix-style paths. --- crates/vite_global_cli/src/commands/self_update/install.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs index a44d89c55a..845719c74f 100644 --- a/crates/vite_global_cli/src/commands/self_update/install.rs +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -18,7 +18,12 @@ use crate::error::Error; /// /// Returns `false` if the path contains `..` components or is absolute. fn is_safe_tar_path(path: &Path) -> bool { - !path.is_absolute() && !path.components().any(|c| matches!(c, std::path::Component::ParentDir)) + // Also check for Unix-style absolute paths, since tar archives always use forward + // slashes and `Path::is_absolute()` on Windows only recognizes `C:\...` style paths. + let starts_with_slash = path.to_string_lossy().starts_with('/'); + !path.is_absolute() + && !starts_with_slash + && !path.components().any(|c| matches!(c, std::path::Component::ParentDir)) } /// Files/directories to extract from the main package tarball. From e1a466d91bf59663ad16d018abc763c46d67f118 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 16:10:38 +0800 Subject: [PATCH 05/13] test(ci): add e2e self-update tests to standalone install workflow Test the full self-update cycle (check, upgrade alias, update, rollback) on all platforms: Linux x64, macOS x64/ARM64, Linux ARM64 via QEMU, and Windows x64. --- .github/workflows/test-standalone-install.yml | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 5e7d8047b4..754bedf6de 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -103,6 +103,19 @@ jobs: which npx which vp + - name: Verify self-update + run: | + # --check queries npm registry and prints update status + vp self-update --check + # upgrade alias should also work + vp upgrade --check + # full self-update: download, extract, swap symlink + vp self-update --tag test --force + vp --version + # rollback to the previous version (should succeed after a real update) + vp self-update --rollback + vp --version + test-install-sh-arm64: name: Test install.sh (Linux ARM64 glibc via QEMU) runs-on: ubuntu-latest @@ -155,6 +168,14 @@ jobs: export VITE_LOG=trace vp env run --node 24 -- node -p \"process.versions\" + # Verify self-update + vp self-update --check + vp upgrade --check + vp self-update --tag test --force + vp --version + vp self-update --rollback + vp --version + # FIXME: qemu: uncaught target signal 11 (Segmentation fault) - core dumped # vp new create-vite --no-interactive --no-agent -- hello --no-interactive -t vanilla # cd hello && vp run build @@ -180,6 +201,20 @@ jobs: run: | echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH + - name: Verify self-update + shell: pwsh + run: | + # --check queries npm registry and prints update status + vp self-update --check + # upgrade alias should also work + vp upgrade --check + # full self-update: download, extract, swap symlink + vp self-update --tag test --force + vp --version + # rollback to the previous version (should succeed after a real update) + vp self-update --rollback + vp --version + - name: Verify installation on powershell shell: pwsh working-directory: ${{ runner.temp }} From c12b0120e41f2140e7c27a166bceeddb5605ce2c Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 16:27:36 +0800 Subject: [PATCH 06/13] fix(cli): use platform-agnostic temp dir in self-update test `/tmp/...` is not a valid absolute path on Windows, causing `AbsolutePathBuf::new` to return `None`. Use `std::env::temp_dir()` instead. --- crates/vite_global_cli/src/commands/self_update/install.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs index 845719c74f..58c5beb4d9 100644 --- a/crates/vite_global_cli/src/commands/self_update/install.rs +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -488,10 +488,9 @@ mod tests { async fn test_cleanup_old_versions_with_nonexistent_dir() { // Verifies that cleanup_old_versions propagates errors on non-existent dir. // In the real flow, such errors from post-swap operations should be non-fatal. - let non_existent = AbsolutePathBuf::new(std::path::PathBuf::from( - "/tmp/non-existent-self-update-test-dir", - )) - .unwrap(); + let non_existent = + AbsolutePathBuf::new(std::env::temp_dir().join("non-existent-self-update-test-dir")) + .unwrap(); let result = cleanup_old_versions(&non_existent, 5, &[]).await; assert!(result.is_err(), "cleanup_old_versions should error on non-existent dir"); } From 8e8b8190706e6e3851c7f5a1c8a2f87c6dbdfc92 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 16:57:00 +0800 Subject: [PATCH 07/13] test(ci): add cli-self-update job to CI workflow Add a dedicated job that builds the CLI from source, runs self-update with --tag test --force, verifies version changed, then rollbacks and verifies version restored. Includes version assertions using the installed package.json to ensure correctness after both update and rollback. Runs on all 3 platforms (Linux, macOS, Windows). --- .github/workflows/ci.yml | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 445eb9d72a..c28ca87f40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -361,6 +361,105 @@ jobs: RUST_BACKTRACE=1 pnpm test git diff --exit-code + cli-self-update: + name: CLI self-update test + needs: + - download-previous-rolldown-binaries + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + - os: namespace-profile-mac-default + - os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: ./.github/actions/clone + + - name: Configure Git for access to vite-task + run: git config --global url."https://x-access-token:${{ secrets.VITE_TASK_TOKEN }}@github.com/".insteadOf "ssh://git@github.com/" + + - run: | + brew install rustup + rustup install stable + echo "PATH=/opt/homebrew/opt/rustup/bin:$PATH" >> $GITHUB_ENV + if: ${{ matrix.os == 'namespace-profile-mac-default' }} + + - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0 + with: + save-cache: ${{ github.ref_name == 'main' }} + cache-key: cli-self-update + + - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: rolldown-binaries + path: ./rolldown/packages/rolldown/src + merge-multiple: true + + - name: Build with upstream + uses: ./.github/actions/build-upstream + with: + target: ${{ matrix.os == 'ubuntu-latest' && 'x86_64-unknown-linux-gnu' || matrix.os == 'windows-latest' && 'x86_64-pc-windows-msvc' || 'aarch64-apple-darwin' }} + + - name: Build CLI + run: | + pnpm bootstrap-cli:ci + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "$USERPROFILE\.vite-plus-dev\bin" >> $GITHUB_PATH + else + echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + fi + + - name: Verify CLI installation + run: | + which vp + vp --version + + - name: Test self-update + run: | + # Helper to read the installed CLI version from package.json + get_cli_version() { + node -p "require(require('path').resolve(process.env.USERPROFILE || process.env.HOME, '.vite-plus-dev', 'current', 'package.json')).version" + } + + # Save initial (dev build) version + INITIAL_VERSION=$(get_cli_version) + echo "Initial version: $INITIAL_VERSION" + + # --check queries npm registry and prints update status + vp self-update --check + # upgrade alias should also work + vp upgrade --check + + # full self-update: download, extract, swap + vp self-update --tag test --force + vp --version + vp env doctor + + # Verify version changed after update + UPDATED_VERSION=$(get_cli_version) + echo "Updated version: $UPDATED_VERSION" + if [ "$UPDATED_VERSION" == "$INITIAL_VERSION" ]; then + echo "Error: version should have changed after self-update (still $INITIAL_VERSION)" + exit 1 + fi + + # rollback to the previous version + vp self-update --rollback + vp --version + vp env doctor + + # Verify version restored after rollback + ROLLBACK_VERSION=$(get_cli_version) + echo "Rollback version: $ROLLBACK_VERSION" + if [ "$ROLLBACK_VERSION" != "$INITIAL_VERSION" ]; then + echo "Error: version should have been restored after rollback (expected $INITIAL_VERSION, got $ROLLBACK_VERSION)" + exit 1 + fi + install-e2e-test: name: Local CLI `vite install` E2E test needs: @@ -440,6 +539,7 @@ jobs: - lint - run - cli-e2e-test + - cli-self-update steps: - run: exit 1 # Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379 From c4b2e7a795a9afe6e674b048dd2f485e300b19e5 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 19:18:13 +0800 Subject: [PATCH 08/13] fix(cli): use ~/.vite-plus in CI to match real user environment CI now installs `vp` to `~/.vite-plus` instead of `~/.vite-plus-dev`, matching the real user environment. Local dev (`vp-dev`) continues to use `~/.vite-plus-dev`. Snap output normalizes both paths to `` for stable cross-environment snapshots. --- .github/workflows/ci.yml | 46 +++++++++---------- .github/workflows/e2e-test.yml | 2 +- CONTRIBUTING.md | 2 +- Cargo.lock | 11 +++++ Cargo.toml | 1 + crates/vite_global_cli/Cargo.toml | 3 ++ crates/vite_global_cli/src/cli.rs | 4 +- crates/vite_global_cli/src/commands/mod.rs | 2 +- .../src/commands/self_update/install.rs | 31 ++++--------- .../snap-tests/command-env-which/snap.txt | 8 ++-- .../__snapshots__/utils.spec.ts.snap | 11 +++++ packages/tools/src/__tests__/utils.spec.ts | 17 ++++++- packages/tools/src/install-global-cli.ts | 34 +++++++------- packages/tools/src/snap-test.ts | 13 ++++-- packages/tools/src/utils.ts | 2 + 15 files changed, 110 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c28ca87f40..ae23685581 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,7 @@ jobs: - name: Build CLI run: | pnpm bootstrap-cli:ci - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH - name: Print help for built-in commands run: | @@ -198,9 +198,9 @@ jobs: run: | pnpm bootstrap-cli:ci if [[ "$RUNNER_OS" == "Windows" ]]; then - echo "$USERPROFILE\.vite-plus-dev\bin" >> $GITHUB_PATH + echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH else - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH fi - name: Verify CLI installation @@ -239,16 +239,16 @@ jobs: where.exe tsc # Test 2: Verify the package was installed correctly - Get-ChildItem "$env:USERPROFILE\.vite-plus-dev\packages\typescript\" - Get-ChildItem "$env:USERPROFILE\.vite-plus-dev\bin\" + Get-ChildItem "$env:USERPROFILE\.vite-plus\packages\typescript\" + Get-ChildItem "$env:USERPROFILE\.vite-plus\bin\" # Test 3: Uninstall vp uninstall -g typescript # Test 4: Verify uninstall removed shim Write-Host "Checking bin dir after uninstall:" - Get-ChildItem "$env:USERPROFILE\.vite-plus-dev\bin\" - $shimPath = "$env:USERPROFILE\.vite-plus-dev\bin\tsc.cmd" + Get-ChildItem "$env:USERPROFILE\.vite-plus\bin\" + $shimPath = "$env:USERPROFILE\.vite-plus\bin\tsc.cmd" if (Test-Path $shimPath) { Write-Error "tsc shim file still exists at $shimPath" exit 1 @@ -285,23 +285,23 @@ jobs: where.exe tsc REM Test 2: Verify the package was installed correctly - dir "%USERPROFILE%\.vite-plus-dev\packages\typescript\" - dir "%USERPROFILE%\.vite-plus-dev\bin\" + dir "%USERPROFILE%\.vite-plus\packages\typescript\" + dir "%USERPROFILE%\.vite-plus\bin\" REM Test 3: Uninstall vp uninstall -g typescript REM Test 4: Verify uninstall removed shim (.cmd wrapper) echo Checking bin dir after uninstall: - dir "%USERPROFILE%\.vite-plus-dev\bin\" - if exist "%USERPROFILE%\.vite-plus-dev\bin\tsc.cmd" ( + dir "%USERPROFILE%\.vite-plus\bin\" + if exist "%USERPROFILE%\.vite-plus\bin\tsc.cmd" ( echo Error: tsc.cmd shim file still exists exit /b 1 ) echo tsc.cmd shim removed successfully REM Test 5: Verify shell script was also removed (for Git Bash) - if exist "%USERPROFILE%\.vite-plus-dev\bin\tsc" ( + if exist "%USERPROFILE%\.vite-plus\bin\tsc" ( echo Error: tsc shell script still exists exit /b 1 ) @@ -317,8 +317,8 @@ jobs: - name: Test global package install (bash) run: | echo "PATH: $PATH" - ls -la ~/.vite-plus-dev/ - ls -la ~/.vite-plus-dev/bin/ + ls -la ~/.vite-plus/ + ls -la ~/.vite-plus/bin/ which node which npm which npx @@ -331,17 +331,17 @@ jobs: which tsc # Test 2: Verify the package was installed correctly - ls -la ~/.vite-plus-dev/packages/typescript/ - ls -la ~/.vite-plus-dev/bin/ + ls -la ~/.vite-plus/packages/typescript/ + ls -la ~/.vite-plus/bin/ # Test 3: Uninstall vp uninstall -g typescript # Test 4: Verify uninstall removed shim echo "Checking bin dir after uninstall:" - ls -la ~/.vite-plus-dev/bin/ - if [ -f ~/.vite-plus-dev/bin/tsc ]; then - echo "Error: tsc shim file still exists at ~/.vite-plus-dev/bin/tsc" + ls -la ~/.vite-plus/bin/ + if [ -f ~/.vite-plus/bin/tsc ]; then + echo "Error: tsc shim file still exists at ~/.vite-plus/bin/tsc" exit 1 fi echo "tsc shim removed successfully" @@ -408,9 +408,9 @@ jobs: run: | pnpm bootstrap-cli:ci if [[ "$RUNNER_OS" == "Windows" ]]; then - echo "$USERPROFILE\.vite-plus-dev\bin" >> $GITHUB_PATH + echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH else - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH fi - name: Verify CLI installation @@ -422,7 +422,7 @@ jobs: run: | # Helper to read the installed CLI version from package.json get_cli_version() { - node -p "require(require('path').resolve(process.env.USERPROFILE || process.env.HOME, '.vite-plus-dev', 'current', 'package.json')).version" + node -p "require(require('path').resolve(process.env.USERPROFILE || process.env.HOME, '.vite-plus', 'current', 'package.json')).version" } # Save initial (dev build) version @@ -497,7 +497,7 @@ jobs: - name: Build CLI run: | pnpm bootstrap-cli:ci - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH - name: Run local CLI `vite install` run: | diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 30464c94d7..d632b2c6f6 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -267,7 +267,7 @@ jobs: shell: bash run: | node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts vp --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-cli-0.0.0.tgz - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH - name: Migrate in ${{ matrix.project.name }} working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46dfa799f2..4c1f5d9dfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ vp-dev --version This installs the CLI to `~/.vite-plus-dev` (separate from the release version at `~/.vite-plus`) and creates a `vp-dev` wrapper script that sets the correct `VITE_PLUS_HOME` environment variable. -Note: In CI, `pnpm bootstrap-cli:ci` installs `vp` (without the wrapper) to the same `~/.vite-plus-dev` directory. +Note: In CI, `pnpm bootstrap-cli:ci` installs `vp` to `~/.vite-plus`, matching the real user environment. ## Workflow for build and test diff --git a/Cargo.lock b/Cargo.lock index 380708e8b2..79a267c093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2713,6 +2713,16 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "junction" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642883fdc81cf2da15ee8183fa1d2c7da452414dd41541a0f3e1428069345447" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + [[package]] name = "konst" version = "0.2.19" @@ -7133,6 +7143,7 @@ dependencies = [ "chrono", "clap", "flate2", + "junction", "node-semver", "owo-colors", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2bc69320cd..280ba3f215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ itoa = "1.0.15" json-escape-simd = "3" json-strip-comments = "3" jsonschema = { version = "0.38.0", default-features = false } +junction = "1.4.1" memchr = "2.7.4" mimalloc-safe = "0.1.52" mime = "0.3.17" diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index 89c7803ff1..375ffe4e89 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -34,6 +34,9 @@ vite_str = { workspace = true } vite_workspace = { workspace = true } which = { workspace = true } +[target.'cfg(windows)'.dependencies] +junction = { workspace = true } + [dev-dependencies] serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 6b0a7ff2ab..9e1eea0d22 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -590,7 +590,7 @@ pub enum Commands { Env(EnvArgs), // ========================================================================= - // Category E: Self-Management + // Self-Management // ========================================================================= /// Update vp itself to the latest version #[command(name = "self-update", visible_alias = "upgrade")] @@ -1546,7 +1546,7 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result commands::env::execute(cwd, args).await, - // Category E: Self-Management + // Self-Management Commands::SelfUpdate { version, tag, check, rollback, force, silent, registry } => { commands::self_update::execute(commands::self_update::SelfUpdateOptions { version, diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index fbb7980125..6d6bf0ad46 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -101,7 +101,7 @@ pub mod version; // Category D: Environment Management pub mod env; -// Category E: Self-Management +// Self-Management pub mod self_update; // Category C: Local CLI Delegation diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs index 58c5beb4d9..66ba9025f4 100644 --- a/crates/vite_global_cli/src/commands/self_update/install.rs +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -272,31 +272,16 @@ pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Res #[cfg(windows)] { // Windows: junction swap (not atomic) - use std::process::Command; - - // Remove existing junction (use symlink_metadata to detect broken junctions too) - if std::fs::symlink_metadata(current_link.as_path()).is_ok() { - let status = Command::new("cmd") - .args(["/c", "rmdir", ¤t_link.as_path().display().to_string()]) - .status()?; - if !status.success() { - return Err(Error::SelfUpdate("Failed to remove existing junction".into())); - } + // Uses the `junction` crate instead of cmd.exe to avoid path parsing issues + // where Unix-style paths like /c/Users/... get interpreted as switches. + if junction::exists(¤t_link).unwrap_or(false) { + junction::delete(¤t_link).map_err(|e| { + Error::SelfUpdate(format!("Failed to remove existing junction: {e}").into()) + })?; } - // Create new junction - let status = Command::new("cmd") - .args([ - "/c", - "mklink", - "/J", - ¤t_link.as_path().display().to_string(), - &version_dir.as_path().display().to_string(), - ]) - .status()?; - if !status.success() { - return Err(Error::SelfUpdate("Failed to create junction".into())); - } + junction::create(&version_dir, ¤t_link) + .map_err(|e| Error::SelfUpdate(format!("Failed to create junction: {e}").into()))?; } tracing::debug!("Swapped current → {}", version); diff --git a/packages/global/snap-tests/command-env-which/snap.txt b/packages/global/snap-tests/command-env-which/snap.txt index ad7c991a88..4e79846d11 100644 --- a/packages/global/snap-tests/command-env-which/snap.txt +++ b/packages/global/snap-tests/command-env-which/snap.txt @@ -2,17 +2,17 @@ v20.18.0 > vp env which node # Core tool - shows resolved Node.js binary path -/.vite-plus-dev/js_runtime/node//bin/node +/js_runtime/node//bin/node Version:  20.18.0 Source:  .node-version > vp env which npm # Core tool - shows resolved npm binary path -/.vite-plus-dev/js_runtime/node//bin/npm +/js_runtime/node//bin/npm Version:  20.18.0 Source:  .node-version > vp env which npx # Core tool - shows resolved npx binary path -/.vite-plus-dev/js_runtime/node//bin/npx +/js_runtime/node//bin/npx Version:  20.18.0 Source:  .node-version @@ -28,7 +28,7 @@ added 41 packages in ms Binaries: cowsay, cowthink > vp env which cowsay # Global package - shows binary path with metadata -/.vite-plus-dev/packages/cowsay/lib/node_modules/cowsay/./cli.js +/packages/cowsay/lib/node_modules/cowsay/./cli.js Package:  cowsay@ Binaries:  cowsay, cowthink Node:  20.18.0 diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index 77e00a1d57..3a2d8fcfbe 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -115,6 +115,17 @@ exports[`replaceUnstableOutput() > replace unstable vite-plus hash version 1`] = "vite-plus-core": "^0.0.0-"" `; +exports[`replaceUnstableOutput() > replace vite-plus home paths (.vite-plus and .vite-plus-dev) 1`] = ` +"/js_runtime/node/v/bin/node +/js_runtime/node/v/bin/node +/packages/cowsay/lib/node_modules/cowsay/./cli.js +/packages/cowsay/lib/node_modules/cowsay/./cli.js + + +/bin +/bin" +`; + exports[`replaceUnstableOutput() > replace yarn YN0000: └ Completed with duration to empty string 1`] = ` "➤ YN0000: └ Completed ➤ YN0000: └ Completed diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index 86cec5a334..dae928d13c 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, test } from '@voidzero-dev/vite-plus-test'; @@ -200,6 +200,21 @@ line 3 expect(replaceUnstableOutput(output.trim())).toMatchSnapshot(); }); + test('replace vite-plus home paths (.vite-plus and .vite-plus-dev)', () => { + const home = homedir(); + const output = [ + `${home}/.vite-plus-dev/js_runtime/node/v20.18.0/bin/node`, + `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, + `${home}/.vite-plus-dev/packages/cowsay/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus/packages/cowsay/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus-dev`, + `${home}/.vite-plus`, + `${home}/.vite-plus-dev/bin`, + `${home}/.vite-plus/bin`, + ].join('\n'); + expect(replaceUnstableOutput(output)).toMatchSnapshot(); + }); + test('replace ignore npm warn exec The following package was not found and will be installed: cowsay@ warning log', () => { const output = ` npm warn exec The following package was not found and will be installed: cowsay@ diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index b72130eec8..0c734f862e 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -77,8 +77,9 @@ export function installGlobalCli() { try { // Set up environment for install script - // Both vp and vp-dev use ~/.vite-plus-dev to avoid conflicting with release version - const installDir = path.join(os.homedir(), '.vite-plus-dev'); + // vp uses ~/.vite-plus (matches real user environment, used in CI) + // vp-dev uses ~/.vite-plus-dev (avoids conflicting with release version during local dev) + const installDir = path.join(os.homedir(), binName === 'vp' ? '.vite-plus' : '.vite-plus-dev'); const env: Record = { ...(process.env as Record), @@ -122,9 +123,9 @@ export function installGlobalCli() { } } // For 'vp', bin/vp.cmd is already correct from install.ps1 - } else { - // Unix: Rename vp -> vp-raw, then create a wrapper at vp - // The wrapper sets VITE_PLUS_HOME and VITE_PLUS_SHIM_TOOL for shim detection + } else if (binName === 'vp-dev') { + // Unix vp-dev: Rename vp -> vp-raw, then create a wrapper at vp + // The wrapper sets VITE_PLUS_HOME to ~/.vite-plus-dev (overriding the default) const vpBinary = path.join(currentBinDir, 'vp'); const vpRawBinary = path.join(currentBinDir, 'vp-raw'); @@ -150,24 +151,21 @@ exec "$VITE_PLUS_HOME/current/bin/vp-raw" "$@" chmodSync(vpWrapperPath, 0o755); console.log(`Created wrapper: ${vpWrapperPath}`); - // On Unix, create shell script wrappers - if (binName === 'vp-dev') { - // Remove the vp symlink to avoid confusion - rmSync(path.join(binDir, 'vp'), { force: true }); + // Remove the vp symlink in bin/ to avoid confusion + rmSync(path.join(binDir, 'vp'), { force: true }); - // Create vp-dev wrapper that points to current/bin/vp (the wrapper) - const wrapperPath = path.join(binDir, 'vp-dev'); - const wrapperContent = `#!/bin/sh + // Create vp-dev wrapper that points to current/bin/vp (the wrapper) + const wrapperPath = path.join(binDir, 'vp-dev'); + const wrapperContent = `#!/bin/sh export VITE_PLUS_HOME="${installDir}" exec "$VITE_PLUS_HOME/current/bin/vp" "$@" `; - writeFileSync(wrapperPath, wrapperContent); - chmodSync(wrapperPath, 0o755); - console.log(`\nCreated wrapper script: ${wrapperPath}`); - } - // For 'vp' on Unix, install.sh already creates the symlink to ../current/bin/vp - // which now points to the wrapper script (which calls vp-raw) + writeFileSync(wrapperPath, wrapperContent); + chmodSync(wrapperPath, 0o755); + console.log(`\nCreated wrapper script: ${wrapperPath}`); } + // For 'vp' on Unix, install.sh already creates the symlink to ../current/bin/vp + // which points directly to the real binary (no wrapper needed) // Patch env files for vp-dev: the shell function wrappers created by `vp env setup` // define vp() but in dev mode the binary is vp-dev, so we rename the functions diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index 9ee3e36bab..e2354647fe 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -85,9 +85,16 @@ export async function snapTest() { } } + const vitePlusHome = path.join(homedir(), process.env.CI ? '.vite-plus' : '.vite-plus-dev'); + + // Remove .previous-version so command-self-update-rollback snap test is stable + const previousVersionPath = path.join(vitePlusHome, '.previous-version'); + if (fs.existsSync(previousVersionPath)) { + fs.rmSync(previousVersionPath); + } + // Ensure shim mode is "managed" so snap tests use vite-plus managed Node.js // instead of the system Node.js (equivalent to running `vp env on`). - const vitePlusHome = path.join(homedir(), '.vite-plus-dev'); const configPath = path.join(vitePlusHome, 'config.json'); if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); @@ -181,8 +188,8 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string) { NO_COLOR: 'true', // set CI=true make sure snap-tests are stable on GitHub Actions CI: 'true', - // Use the dev installation, same as vp-dev - VITE_PLUS_HOME: path.join(homedir(), '.vite-plus-dev'), + // Use the dev installation (vp-dev) locally, or the real installation (vp) in CI + VITE_PLUS_HOME: path.join(homedir(), process.env.CI ? '.vite-plus' : '.vite-plus-dev'), // A test case can override/unset environment variables above. // For example, VITE_PLUS_CLI_TEST/CI can be unset to test the real-world outputs. diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index fd44ba13c0..337724e3cf 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -108,6 +108,8 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/"integrity": "(\w+)-.+?"/g, '"integrity": "$1-"') // replace homedir; e.g.: /Users/foo/Library/pnpm/global/5/node_modules/testnpm2 => /Library/pnpm/global/5/node_modules/testnpm2 .replaceAll(homedir(), '') + // normalize both .vite-plus-dev (local dev) and .vite-plus (CI) to a stable placeholder + .replaceAll(/\/\.vite-plus(-dev)?/g, '') // replace npm log file path with timestamp // e.g.: /.npm/_logs/T07_38_18_387Z-debug-0.log => /.npm/_logs/-debug.log .replaceAll( From bad03c36cf359e655041daa2f8e7269b50e302bf Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 20:06:29 +0800 Subject: [PATCH 09/13] refactor(cli): replace `base64` crate with `base64-simd` for integrity verification Consolidate on `base64-simd` which is already used elsewhere in the codebase, removing the `base64` crate dependency entirely. --- Cargo.lock | 2 +- Cargo.toml | 1 - crates/vite_global_cli/Cargo.toml | 2 +- .../src/commands/self_update/integrity.rs | 5 ++- packages/tools/src/__tests__/utils.spec.ts | 31 ++++++++++--------- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 79a267c093..5009180072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7139,7 +7139,7 @@ dependencies = [ name = "vite_global_cli" version = "0.0.0" dependencies = [ - "base64 0.22.1", + "base64-simd", "chrono", "clap", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 280ba3f215..440930b763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,6 @@ async-channel = "2.3.1" async-scoped = "0.9.0" async-trait = "0.1.89" backon = "1.3.0" -base64 = "0.22.1" base-encode = "0.3.1" base64-simd = "0.8.0" bincode = "2.0.1" diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index 375ffe4e89..35d6a3a48a 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -12,7 +12,7 @@ name = "vp" path = "src/main.rs" [dependencies] -base64 = { workspace = true } +base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } flate2 = { workspace = true } diff --git a/crates/vite_global_cli/src/commands/self_update/integrity.rs b/crates/vite_global_cli/src/commands/self_update/integrity.rs index 5dd0811164..0f31021848 100644 --- a/crates/vite_global_cli/src/commands/self_update/integrity.rs +++ b/crates/vite_global_cli/src/commands/self_update/integrity.rs @@ -3,7 +3,6 @@ //! Verifies SHA-512 integrity using the Subresource Integrity (SRI) format //! that npm registries provide: `sha512-{base64}`. -use base64::{Engine as _, engine::general_purpose::STANDARD}; use sha2::{Digest, Sha512}; use crate::error::Error; @@ -19,7 +18,7 @@ pub fn verify_integrity(data: &[u8], expected_sri: &str) -> Result<(), Error> { let mut hasher = Sha512::new(); hasher.update(data); - let actual_b64 = STANDARD.encode(hasher.finalize()); + let actual_b64 = base64_simd::STANDARD.encode_to_string(hasher.finalize()); if actual_b64 != expected_b64 { return Err(Error::IntegrityMismatch { @@ -41,7 +40,7 @@ mod tests { let data = b"Hello, World!"; let mut hasher = Sha512::new(); hasher.update(data); - let hash = STANDARD.encode(hasher.finalize()); + let hash = base64_simd::STANDARD.encode_to_string(hasher.finalize()); let sri = format!("sha512-{hash}"); assert!(verify_integrity(data, &sri).is_ok()); diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index dae928d13c..ae836cfa12 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -200,20 +200,23 @@ line 3 expect(replaceUnstableOutput(output.trim())).toMatchSnapshot(); }); - test('replace vite-plus home paths (.vite-plus and .vite-plus-dev)', () => { - const home = homedir(); - const output = [ - `${home}/.vite-plus-dev/js_runtime/node/v20.18.0/bin/node`, - `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, - `${home}/.vite-plus-dev/packages/cowsay/lib/node_modules/cowsay/./cli.js`, - `${home}/.vite-plus/packages/cowsay/lib/node_modules/cowsay/./cli.js`, - `${home}/.vite-plus-dev`, - `${home}/.vite-plus`, - `${home}/.vite-plus-dev/bin`, - `${home}/.vite-plus/bin`, - ].join('\n'); - expect(replaceUnstableOutput(output)).toMatchSnapshot(); - }); + test.skipIf(process.platform === 'win32')( + 'replace vite-plus home paths (.vite-plus and .vite-plus-dev)', + () => { + const home = homedir(); + const output = [ + `${home}/.vite-plus-dev/js_runtime/node/v20.18.0/bin/node`, + `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, + `${home}/.vite-plus-dev/packages/cowsay/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus/packages/cowsay/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus-dev`, + `${home}/.vite-plus`, + `${home}/.vite-plus-dev/bin`, + `${home}/.vite-plus/bin`, + ].join('\n'); + expect(replaceUnstableOutput(output)).toMatchSnapshot(); + }, + ); test('replace ignore npm warn exec The following package was not found and will be installed: cowsay@ warning log', () => { const output = ` From 73e8c486491e5a7a7db4534e48b7b0fc7b738613 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 20:49:00 +0800 Subject: [PATCH 10/13] fix(cli): suppress sub-process output during self-update Capture stdout/stderr from `vp env setup --refresh` and `vp install --silent` sub-processes instead of inheriting stdio. This prevents ~20 lines of setup instructions and package manager warnings from leaking into the self-update progress output. On failure, captured stderr is included in error messages for diagnostics. Also improve Windows junction error message to include the path and recovery hint. --- .../src/commands/self_update/install.rs | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs index 66ba9025f4..194b3ed71a 100644 --- a/crates/vite_global_cli/src/commands/self_update/install.rs +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -200,18 +200,20 @@ pub async fn install_production_deps(version_dir: &AbsolutePath) -> Result<(), E tracing::debug!("Running vp install in {}", version_dir.as_path().display()); - let status = tokio::process::Command::new(vp_binary.as_path()) + let output = tokio::process::Command::new(vp_binary.as_path()) .args(["install", "--silent"]) .current_dir(version_dir) .env("CI", "true") - .status() + .output() .await?; - if !status.success() { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); return Err(Error::SelfUpdate( format!( - "Failed to install production dependencies (exit code: {})", - status.code().unwrap_or(-1) + "Failed to install production dependencies (exit code: {})\n{}", + output.status.code().unwrap_or(-1), + stderr.trim() ) .into(), )); @@ -280,8 +282,15 @@ pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Res })?; } - junction::create(&version_dir, ¤t_link) - .map_err(|e| Error::SelfUpdate(format!("Failed to create junction: {e}").into()))?; + junction::create(&version_dir, ¤t_link).map_err(|e| { + Error::SelfUpdate( + format!( + "Failed to create junction at {}: {e}\nTry removing it manually and run again.", + current_link.as_path().display() + ) + .into(), + ) + })?; } tracing::debug!("Swapped current → {}", version); @@ -303,15 +312,17 @@ pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { tracing::debug!("Refreshing shims..."); - let status = tokio::process::Command::new(vp_binary.as_path()) + let output = tokio::process::Command::new(vp_binary.as_path()) .args(["env", "setup", "--refresh"]) - .status() + .output() .await?; - if !status.success() { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!( - "Shim refresh exited with code {}, continuing anyway", - status.code().unwrap_or(-1) + "Shim refresh exited with code {}, continuing anyway\n{}", + output.status.code().unwrap_or(-1), + stderr.trim() ); } From 4b275e5c58249132c62edfb91d403f1c48d28019 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 6 Feb 2026 21:02:02 +0800 Subject: [PATCH 11/13] fix(cli): use robust junction removal in self-update on Windows Replace junction::exists() check with std::fs::remove_dir() fallback chain when removing the `current` junction during self-update. The junction crate may not detect junctions created by cmd /c mklink /J (used by install.ps1), causing OS error 183 on re-creation. Now unconditionally attempts removal via std::fs::remove_dir (safe for junctions/symlinks) with fallback to junction::delete(). --- .github/workflows/ci.yml | 3 +++ .github/workflows/test-standalone-install.yml | 1 + .../src/commands/self_update/install.rs | 23 ++++++++++++++----- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae23685581..084bccf772 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -419,6 +419,7 @@ jobs: vp --version - name: Test self-update + shell: bash run: | # Helper to read the installed CLI version from package.json get_cli_version() { @@ -439,6 +440,8 @@ jobs: vp --version vp env doctor + ls -la ~/.vite-plus/ + # Verify version changed after update UPDATED_VERSION=$(get_cli_version) echo "Updated version: $UPDATED_VERSION" diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 754bedf6de..0523f3943b 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -264,6 +264,7 @@ jobs: working-directory: ${{ runner.temp }} run: | echo PATH: %PATH% + dir "%USERPROFILE%\.vite-plus" dir "%USERPROFILE%\.vite-plus\bin" REM test new command diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs index 194b3ed71a..f6bdea5fa5 100644 --- a/crates/vite_global_cli/src/commands/self_update/install.rs +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -274,12 +274,23 @@ pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Res #[cfg(windows)] { // Windows: junction swap (not atomic) - // Uses the `junction` crate instead of cmd.exe to avoid path parsing issues - // where Unix-style paths like /c/Users/... get interpreted as switches. - if junction::exists(¤t_link).unwrap_or(false) { - junction::delete(¤t_link).map_err(|e| { - Error::SelfUpdate(format!("Failed to remove existing junction: {e}").into()) - })?; + // Remove whatever exists at current_link — could be a junction, symlink, or directory. + // We don't rely on junction::exists() since it may not detect junctions created by + // cmd /c mklink /J (used by install.ps1). + if current_link.as_path().exists() { + // std::fs::remove_dir works on junctions/symlinks without removing target contents + if let Err(e) = std::fs::remove_dir(¤t_link) { + tracing::debug!("remove_dir failed ({}), trying junction::delete", e); + junction::delete(¤t_link).map_err(|e| { + Error::SelfUpdate( + format!( + "Failed to remove existing junction at {}: {e}", + current_link.as_path().display() + ) + .into(), + ) + })?; + } } junction::create(&version_dir, ¤t_link).map_err(|e| { From fd93927ff33d79bc65684c19dc18905bebdb43b3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 7 Feb 2026 14:24:20 +0800 Subject: [PATCH 12/13] test(ci): add CMD and PowerShell self-update tests on Windows Validate the junction swap fix from all three shell types on Windows by adding pwsh and cmd test steps alongside the existing bash test. --- .github/workflows/ci.yml | 89 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 084bccf772..bc8f0db5f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -418,7 +418,7 @@ jobs: which vp vp --version - - name: Test self-update + - name: Test self-update (bash) shell: bash run: | # Helper to read the installed CLI version from package.json @@ -463,6 +463,93 @@ jobs: exit 1 fi + - name: Test self-update (powershell) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + # Helper to read the installed CLI version from package.json + function Get-CliVersion { + node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version" + } + + # Save initial (dev build) version + $initialVersion = Get-CliVersion + Write-Host "Initial version: $initialVersion" + + # --check queries npm registry and prints update status + vp self-update --check + # upgrade alias should also work + vp upgrade --check + + # full self-update: download, extract, swap + vp self-update --tag test --force + vp --version + vp env doctor + + Get-ChildItem "$env:USERPROFILE\.vite-plus\" + + # Verify version changed after update + $updatedVersion = Get-CliVersion + Write-Host "Updated version: $updatedVersion" + if ($updatedVersion -eq $initialVersion) { + Write-Error "Error: version should have changed after self-update (still $initialVersion)" + exit 1 + } + + # rollback to the previous version + vp self-update --rollback + vp --version + vp env doctor + + # Verify version restored after rollback + $rollbackVersion = Get-CliVersion + Write-Host "Rollback version: $rollbackVersion" + if ($rollbackVersion -ne $initialVersion) { + Write-Error "Error: version should have been restored after rollback (expected $initialVersion, got $rollbackVersion)" + exit 1 + } + + - name: Test self-update (cmd) + if: matrix.os == 'windows-latest' + shell: cmd + run: | + REM Save initial (dev build) version + for /f "usebackq delims=" %%v in (`node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version"`) do set INITIAL_VERSION=%%v + echo Initial version: %INITIAL_VERSION% + + REM --check queries npm registry and prints update status + vp self-update --check + REM upgrade alias should also work + vp upgrade --check + + REM full self-update: download, extract, swap + vp self-update --tag test --force + vp --version + vp env doctor + + dir "%USERPROFILE%\.vite-plus\" + + REM Verify version changed after update + for /f "usebackq delims=" %%v in (`node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version"`) do set UPDATED_VERSION=%%v + echo Updated version: %UPDATED_VERSION% + if "%UPDATED_VERSION%"=="%INITIAL_VERSION%" ( + echo Error: version should have changed after self-update, still %INITIAL_VERSION% + exit /b 1 + ) + + REM rollback to the previous version + vp self-update --rollback + vp --version + vp env doctor + + REM Verify version restored after rollback + for /f "usebackq delims=" %%v in (`node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version"`) do set ROLLBACK_VERSION=%%v + echo Rollback version: %ROLLBACK_VERSION% + if not "%ROLLBACK_VERSION%"=="%INITIAL_VERSION%" ( + echo Error: version should have been restored after rollback, expected %INITIAL_VERSION%, got %ROLLBACK_VERSION% + exit /b 1 + ) + install-e2e-test: name: Local CLI `vite install` E2E test needs: From 4b5143d78590df508acc3347c53de3b32c616605 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 7 Feb 2026 16:10:05 +0800 Subject: [PATCH 13/13] refactor(cli): remove vp-dev command and unify dev/CI installation Both local development and CI now install to ~/.vite-plus using the same `vp` binary, eliminating the separate vp-dev/vp-raw wrapper scripts and ~/.vite-plus-dev directory that caused confusion between dev and release. --- .github/workflows/ci.yml | 2 + .github/workflows/e2e-test.yml | 2 +- .github/workflows/test-standalone-install.yml | 13 +- CONTRIBUTING.md | 6 +- ecosystem-ci/patch-project.ts | 2 +- package.json | 5 +- .../command-env-use-shell-wrapper/snap.txt | 18 +-- .../command-env-use-shell-wrapper/steps.json | 8 +- .../__snapshots__/utils.spec.ts.snap | 6 +- packages/tools/src/__tests__/utils.spec.ts | 27 ++-- packages/tools/src/install-global-cli.ts | 127 +----------------- packages/tools/src/snap-test.ts | 5 +- packages/tools/src/utils.ts | 3 +- 13 files changed, 44 insertions(+), 180 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc8f0db5f2..0559954d02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -467,6 +467,8 @@ jobs: if: matrix.os == 'windows-latest' shell: pwsh run: | + Get-ChildItem "$env:USERPROFILE\.vite-plus\" + # Helper to read the installed CLI version from package.json function Get-CliVersion { node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version" diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index d632b2c6f6..4cdb3310df 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -266,7 +266,7 @@ jobs: - name: Install vp CLI shell: bash run: | - node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts vp --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-cli-0.0.0.tgz + node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-cli-0.0.0.tgz echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH - name: Migrate in ${{ matrix.project.name }} diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 0523f3943b..7cf3e6ba99 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -107,10 +107,7 @@ jobs: run: | # --check queries npm registry and prints update status vp self-update --check - # upgrade alias should also work - vp upgrade --check - # full self-update: download, extract, swap symlink - vp self-update --tag test --force + vp self-update 0.0.0-b356849c.20260207-0631 vp --version # rollback to the previous version (should succeed after a real update) vp self-update --rollback @@ -170,8 +167,7 @@ jobs: # Verify self-update vp self-update --check - vp upgrade --check - vp self-update --tag test --force + vp self-update 0.0.0-b356849c.20260207-0631 vp --version vp self-update --rollback vp --version @@ -206,10 +202,7 @@ jobs: run: | # --check queries npm registry and prints update status vp self-update --check - # upgrade alias should also work - vp upgrade --check - # full self-update: download, extract, swap symlink - vp self-update --tag test --force + vp self-update 0.0.0-b356849c.20260207-0631 vp --version # rollback to the previous version (should succeed after a real update) vp self-update --rollback diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c1f5d9dfc..329bc6f8e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,12 +33,10 @@ just build ``` pnpm bootstrap-cli -vp-dev --version +vp --version ``` -This installs the CLI to `~/.vite-plus-dev` (separate from the release version at `~/.vite-plus`) and creates a `vp-dev` wrapper script that sets the correct `VITE_PLUS_HOME` environment variable. - -Note: In CI, `pnpm bootstrap-cli:ci` installs `vp` to `~/.vite-plus`, matching the real user environment. +This installs the CLI to `~/.vite-plus` and creates the `vp` binary. ## Workflow for build and test diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index caeb1eb3e7..a43a84b7b0 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -19,7 +19,7 @@ async function migrateProject(project: string) { const directory = 'directory' in repoConfig ? repoConfig.directory : undefined; const cwd = directory ? join(repoRoot, directory) : repoRoot; // run vp migrate - const cli = process.env.VITE_PLUS_CLI_BIN ?? (process.env.CI ? 'vp' : 'vp-dev'); + const cli = process.env.VITE_PLUS_CLI_BIN ?? 'vp'; execSync(`${cli} migrate --no-agent --no-interactive`, { cwd, stdio: 'inherit', diff --git a/package.json b/package.json index e8897a2183..5c0fbbd1fe 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,10 @@ "scripts": { "build": "pnpm -F @voidzero-dev/* -F vite-plus build && pnpm -F vite-plus-cli build", "bootstrap-cli": "pnpm build && cargo build -p vite_global_cli --release && pnpm copy-vp-binary && pnpm install-global-cli", - "bootstrap-cli:ci": "pnpm install-global-cli:ci", + "bootstrap-cli:ci": "pnpm install-global-cli", "copy-vp-binary": "rm -f packages/global/bin/vp packages/global/bin/vp.exe && (cp target/release/vp packages/global/bin/vp || cp target/release/vp.exe packages/global/bin/vp.exe)", "copy-cli-binding": "pnpm --filter=vite-plus-cli copy-binding", - "install-global-cli": "pnpm copy-cli-binding && tool install-global-cli vp-dev && vp-dev --version", - "install-global-cli:ci": "pnpm copy-cli-binding && tool install-global-cli vp", + "install-global-cli": "pnpm copy-cli-binding && tool install-global-cli", "tsgo": "tsgo -b tsconfig.json", "lint": "vite lint --type-aware --threads 4", "test": "vite test run && pnpm -r snap-test", diff --git a/packages/global/snap-tests-todo/command-env-use-shell-wrapper/snap.txt b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/snap.txt index f6b765416a..ce16411e96 100644 --- a/packages/global/snap-tests-todo/command-env-use-shell-wrapper/snap.txt +++ b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/snap.txt @@ -1,22 +1,22 @@ -> bash -c '. $VITE_PLUS_HOME/env && type vp-dev' # should show vp-dev is a shell function -vp-dev is a function -vp-dev () +> bash -c '. $VITE_PLUS_HOME/env && type vp' # should show vp is a shell function +vp is a function +vp () { if [ "$1" = "env" ] && [ "$2" = "use" ]; then case " $* " in *" -h "* | *" --help "*) - command vp-dev "$@"; + command vp "$@"; return ;; esac; - __vp_out="$(command vp-dev "$@")" || return $?; + __vp_out="$(command vp "$@")" || return $?; eval "$__vp_out"; else - command vp-dev "$@"; + command vp "$@"; fi } -> bash -c '. $VITE_PLUS_HOME/env && vp-dev env use -h' # should show help via shell wrapper +> bash -c '. $VITE_PLUS_HOME/env && vp env use -h' # should show help via shell wrapper Use a specific Node.js version for this shell session Usage: vp env use [OPTIONS] [VERSION] @@ -30,7 +30,7 @@ Options: --silent-if-unchanged Suppress output if version is already active -h, --help Print help -> bash -c '. $VITE_PLUS_HOME/env && vp-dev env use --help' # should show help via shell wrapper +> bash -c '. $VITE_PLUS_HOME/env && vp env use --help' # should show help via shell wrapper Use a specific Node.js version for this shell session Usage: vp env use [OPTIONS] [VERSION] @@ -44,6 +44,6 @@ Options: --silent-if-unchanged Suppress output if version is already active -h, --help Print help -> bash -c '. $VITE_PLUS_HOME/env && vp-dev env use 20.18.0 --no-install && echo VITE_PLUS_NODE_VERSION=$VITE_PLUS_NODE_VERSION' # should set env var via eval +> bash -c '. $VITE_PLUS_HOME/env && vp env use 20.18.0 --no-install && echo VITE_PLUS_NODE_VERSION=$VITE_PLUS_NODE_VERSION' # should set env var via eval Using Node.js v (resolved from ) VITE_PLUS_NODE_VERSION=20.18.0 diff --git a/packages/global/snap-tests-todo/command-env-use-shell-wrapper/steps.json b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/steps.json index aa1d0e8b57..4035b61ce4 100644 --- a/packages/global/snap-tests-todo/command-env-use-shell-wrapper/steps.json +++ b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/steps.json @@ -2,9 +2,9 @@ "env": {}, "ignoredPlatforms": ["win32"], "commands": [ - "bash -c '. $VITE_PLUS_HOME/env && type vp-dev' # should show vp-dev is a shell function", - "bash -c '. $VITE_PLUS_HOME/env && vp-dev env use -h' # should show help via shell wrapper", - "bash -c '. $VITE_PLUS_HOME/env && vp-dev env use --help' # should show help via shell wrapper", - "bash -c '. $VITE_PLUS_HOME/env && vp-dev env use 20.18.0 --no-install && echo VITE_PLUS_NODE_VERSION=$VITE_PLUS_NODE_VERSION' # should set env var via eval" + "bash -c '. $VITE_PLUS_HOME/env && type vp' # should show vp is a shell function", + "bash -c '. $VITE_PLUS_HOME/env && vp env use -h' # should show help via shell wrapper", + "bash -c '. $VITE_PLUS_HOME/env && vp env use --help' # should show help via shell wrapper", + "bash -c '. $VITE_PLUS_HOME/env && vp env use 20.18.0 --no-install && echo VITE_PLUS_NODE_VERSION=$VITE_PLUS_NODE_VERSION' # should set env var via eval" ] } diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index 3a2d8fcfbe..82766eba15 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -115,14 +115,10 @@ exports[`replaceUnstableOutput() > replace unstable vite-plus hash version 1`] = "vite-plus-core": "^0.0.0-"" `; -exports[`replaceUnstableOutput() > replace vite-plus home paths (.vite-plus and .vite-plus-dev) 1`] = ` +exports[`replaceUnstableOutput() > replace vite-plus home paths 1`] = ` "/js_runtime/node/v/bin/node -/js_runtime/node/v/bin/node /packages/cowsay/lib/node_modules/cowsay/./cli.js -/packages/cowsay/lib/node_modules/cowsay/./cli.js - -/bin /bin" `; diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index ae836cfa12..6b7a41faa3 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -200,23 +200,16 @@ line 3 expect(replaceUnstableOutput(output.trim())).toMatchSnapshot(); }); - test.skipIf(process.platform === 'win32')( - 'replace vite-plus home paths (.vite-plus and .vite-plus-dev)', - () => { - const home = homedir(); - const output = [ - `${home}/.vite-plus-dev/js_runtime/node/v20.18.0/bin/node`, - `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, - `${home}/.vite-plus-dev/packages/cowsay/lib/node_modules/cowsay/./cli.js`, - `${home}/.vite-plus/packages/cowsay/lib/node_modules/cowsay/./cli.js`, - `${home}/.vite-plus-dev`, - `${home}/.vite-plus`, - `${home}/.vite-plus-dev/bin`, - `${home}/.vite-plus/bin`, - ].join('\n'); - expect(replaceUnstableOutput(output)).toMatchSnapshot(); - }, - ); + test.skipIf(process.platform === 'win32')('replace vite-plus home paths', () => { + const home = homedir(); + const output = [ + `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, + `${home}/.vite-plus/packages/cowsay/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus`, + `${home}/.vite-plus/bin`, + ].join('\n'); + expect(replaceUnstableOutput(output)).toMatchSnapshot(); + }); test('replace ignore npm warn exec The following package was not found and will be installed: cowsay@ warning log', () => { const output = ` diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index 0c734f862e..1d431a75a7 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -1,14 +1,5 @@ import { execSync } from 'node:child_process'; -import { - chmodSync, - existsSync, - mkdtempSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,8 +16,8 @@ export function installGlobalCli() { const isDirectInvocation = process.argv[1]?.endsWith('install-global-cli.ts'); const args = process.argv.slice(isDirectInvocation ? 2 : 3); - const { positionals, values } = parseArgs({ - allowPositionals: true, + const { values } = parseArgs({ + allowPositionals: false, args, options: { tgz: { @@ -36,13 +27,7 @@ export function installGlobalCli() { }, }); - const binName = positionals[0]; - if (!binName || !['vp', 'vp-dev'].includes(binName)) { - console.error('Usage: tool install-global-cli [--tgz ]'); - process.exit(1); - } - - console.log(`Installing global CLI with bin name: ${binName}`); + console.log('Installing global CLI: vp'); let tempDir: string | undefined; let tgzPath: string; @@ -76,10 +61,7 @@ export function installGlobalCli() { } try { - // Set up environment for install script - // vp uses ~/.vite-plus (matches real user environment, used in CI) - // vp-dev uses ~/.vite-plus-dev (avoids conflicting with release version during local dev) - const installDir = path.join(os.homedir(), binName === 'vp' ? '.vite-plus' : '.vite-plus-dev'); + const installDir = path.join(os.homedir(), '.vite-plus'); const env: Record = { ...(process.env as Record), @@ -105,104 +87,7 @@ export function installGlobalCli() { env, }); } - - // Create wrapper scripts - const binDir = path.join(installDir, 'bin'); - const currentBinDir = path.join(installDir, 'current', 'bin'); - - // Create wrapper scripts to ensure VITE_PLUS_HOME is always set - if (isWindows) { - // On Windows, install.ps1 already creates bin/vp.cmd with VITE_PLUS_HOME set. - // For 'vp-dev', we need to rename it to vp-dev.cmd. - if (binName === 'vp-dev') { - const vpCmd = path.join(binDir, 'vp.cmd'); - const vpDevCmd = path.join(binDir, 'vp-dev.cmd'); - if (existsSync(vpCmd)) { - renameSync(vpCmd, vpDevCmd); - console.log(`\nRenamed ${vpCmd} -> ${vpDevCmd}`); - } - } - // For 'vp', bin/vp.cmd is already correct from install.ps1 - } else if (binName === 'vp-dev') { - // Unix vp-dev: Rename vp -> vp-raw, then create a wrapper at vp - // The wrapper sets VITE_PLUS_HOME to ~/.vite-plus-dev (overriding the default) - const vpBinary = path.join(currentBinDir, 'vp'); - const vpRawBinary = path.join(currentBinDir, 'vp-raw'); - - // Rename vp -> vp-raw (always replace to ensure latest binary) - if (existsSync(vpBinary)) { - if (existsSync(vpRawBinary)) { - rmSync(vpRawBinary); - } - renameSync(vpBinary, vpRawBinary); - console.log(`Renamed ${vpBinary} -> ${vpRawBinary}`); - } - - // Create vp wrapper in current/bin/ that sets VITE_PLUS_HOME and calls vp-raw - // Uses VITE_PLUS_SHIM_TOOL env var for shim detection (more portable than exec -a) - const vpWrapperPath = path.join(currentBinDir, 'vp'); - const vpWrapperContent = `#!/bin/sh -VITE_PLUS_SHIM_TOOL="$(basename "$0")" -export VITE_PLUS_SHIM_TOOL -export VITE_PLUS_HOME="${installDir}" -exec "$VITE_PLUS_HOME/current/bin/vp-raw" "$@" -`; - writeFileSync(vpWrapperPath, vpWrapperContent); - chmodSync(vpWrapperPath, 0o755); - console.log(`Created wrapper: ${vpWrapperPath}`); - - // Remove the vp symlink in bin/ to avoid confusion - rmSync(path.join(binDir, 'vp'), { force: true }); - - // Create vp-dev wrapper that points to current/bin/vp (the wrapper) - const wrapperPath = path.join(binDir, 'vp-dev'); - const wrapperContent = `#!/bin/sh -export VITE_PLUS_HOME="${installDir}" -exec "$VITE_PLUS_HOME/current/bin/vp" "$@" -`; - writeFileSync(wrapperPath, wrapperContent); - chmodSync(wrapperPath, 0o755); - console.log(`\nCreated wrapper script: ${wrapperPath}`); - } - // For 'vp' on Unix, install.sh already creates the symlink to ../current/bin/vp - // which points directly to the real binary (no wrapper needed) - - // Patch env files for vp-dev: the shell function wrappers created by `vp env setup` - // define vp() but in dev mode the binary is vp-dev, so we rename the functions - if (binName === 'vp-dev') { - const envPatches: Array<{ file: string; replacements: [string, string][] }> = [ - { - file: 'env', - replacements: [ - ['vp() {', 'vp-dev() {'], - ['command vp ', 'command vp-dev '], - ], - }, - { - file: 'env.fish', - replacements: [ - ['function vp\n', 'function vp-dev\n'], - ['command vp ', 'command vp-dev '], - ], - }, - { - file: 'env.ps1', - replacements: [['function vp {', 'function vp-dev {']], - }, - ]; - - for (const { file, replacements } of envPatches) { - const filePath = path.join(installDir, file); - if (existsSync(filePath)) { - let content = readFileSync(filePath, 'utf-8'); - for (const [from, to] of replacements) { - content = content.replaceAll(from, to); - } - writeFileSync(filePath, content); - console.log(`Patched ${filePath} for vp-dev`); - } - } - } + // install.sh/install.ps1 already creates the correct symlinks and wrappers for vp } finally { // Cleanup temp dir only if we created it if (tempDir) { diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index e2354647fe..62c55c02c1 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -85,7 +85,7 @@ export async function snapTest() { } } - const vitePlusHome = path.join(homedir(), process.env.CI ? '.vite-plus' : '.vite-plus-dev'); + const vitePlusHome = path.join(homedir(), '.vite-plus'); // Remove .previous-version so command-self-update-rollback snap test is stable const previousVersionPath = path.join(vitePlusHome, '.previous-version'); @@ -188,8 +188,7 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string) { NO_COLOR: 'true', // set CI=true make sure snap-tests are stable on GitHub Actions CI: 'true', - // Use the dev installation (vp-dev) locally, or the real installation (vp) in CI - VITE_PLUS_HOME: path.join(homedir(), process.env.CI ? '.vite-plus' : '.vite-plus-dev'), + VITE_PLUS_HOME: path.join(homedir(), '.vite-plus'), // A test case can override/unset environment variables above. // For example, VITE_PLUS_CLI_TEST/CI can be unset to test the real-world outputs. diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 337724e3cf..4fb8a9832a 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -108,8 +108,7 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/"integrity": "(\w+)-.+?"/g, '"integrity": "$1-"') // replace homedir; e.g.: /Users/foo/Library/pnpm/global/5/node_modules/testnpm2 => /Library/pnpm/global/5/node_modules/testnpm2 .replaceAll(homedir(), '') - // normalize both .vite-plus-dev (local dev) and .vite-plus (CI) to a stable placeholder - .replaceAll(/\/\.vite-plus(-dev)?/g, '') + .replaceAll(/\/\.vite-plus/g, '') // replace npm log file path with timestamp // e.g.: /.npm/_logs/T07_38_18_387Z-debug-0.log => /.npm/_logs/-debug.log .replaceAll(