From 8c04da960586bb3f0ad697d7ce131b05d4334430 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 22 Jun 2026 06:46:21 +0800 Subject: [PATCH 1/7] fix(global): activate immutable package installs --- Cargo.lock | 1 + crates/vite_global_cli/Cargo.toml | 1 + .../src/commands/env/config.rs | 5 - .../src/commands/env/package_metadata.rs | 52 ++++ .../vite_global_cli/src/commands/env/which.rs | 15 +- .../src/commands/global/install.rs | 259 ++++++++---------- .../src/commands/global/outdated.rs | 11 +- crates/vite_global_cli/src/commands/vpx.rs | 2 +- crates/vite_global_cli/src/shim/dispatch.rs | 8 +- .../command-env-which/snap.txt | 2 +- .../command-outdated-global/snap.txt | 2 +- .../.node-version | 0 .../env-install-id/env-install-id-pkg/cli.js | 2 + .../env-install-id-pkg/package.json | 7 + .../snap-tests-global/env-install-id/snap.txt | 24 ++ .../env-install-id/steps.json | 12 + .../env-install-stale-backup-pkg/cli.js | 2 - .../env-install-stale-backup-pkg/package.json | 7 - .../env-install-stale-backup/snap.txt | 26 -- .../env-install-stale-backup/steps.json | 13 - .../__snapshots__/utils.spec.ts.snap | 2 +- packages/tools/src/__tests__/utils.spec.ts | 2 +- packages/tools/src/utils.ts | 2 + 23 files changed, 233 insertions(+), 224 deletions(-) rename packages/cli/snap-tests-global/{env-install-stale-backup => env-install-id}/.node-version (100%) create mode 100644 packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js create mode 100644 packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json create mode 100644 packages/cli/snap-tests-global/env-install-id/snap.txt create mode 100644 packages/cli/snap-tests-global/env-install-id/steps.json delete mode 100644 packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/cli.js delete mode 100644 packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/package.json delete mode 100644 packages/cli/snap-tests-global/env-install-stale-backup/snap.txt delete mode 100644 packages/cli/snap-tests-global/env-install-stale-backup/steps.json diff --git a/Cargo.lock b/Cargo.lock index 040c3f8f5d..8446f14c8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7797,6 +7797,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "uuid", "vite_command", "vite_error", "vite_install", diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index b2498496b2..321f7ee45b 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -41,6 +41,7 @@ vite_setup = { workspace = true } vite_shared = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index 84b07acdd0..940a9cc7c3 100644 --- a/crates/vite_global_cli/src/commands/env/config.rs +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -77,11 +77,6 @@ pub fn get_packages_dir() -> Result { Ok(get_vp_home()?.join("packages")) } -/// Get the tmp directory path for staging (~/.vite-plus/tmp/). -pub fn get_tmp_dir() -> Result { - Ok(get_vp_home()?.join("tmp")) -} - /// Get the node_modules directory path for a package. /// /// npm uses different layouts on Unix vs Windows: diff --git a/crates/vite_global_cli/src/commands/env/package_metadata.rs b/crates/vite_global_cli/src/commands/env/package_metadata.rs index c1a665b640..4d61973e88 100644 --- a/crates/vite_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vite_global_cli/src/commands/env/package_metadata.rs @@ -17,6 +17,9 @@ pub struct PackageMetadata { pub name: String, /// Package version pub version: String, + /// Directory identifier for this installation. Empty for legacy installs. + #[serde(default)] + pub install_id: String, /// Platform versions used during installation pub platform: Platform, /// Binary names provided by this package @@ -59,6 +62,7 @@ impl PackageMetadata { Self { name, version, + install_id: String::new(), platform: Platform { node: node_version, npm: npm_version }, bins, js_bins, @@ -73,6 +77,21 @@ impl PackageMetadata { self.js_bins.contains(bin_name) } + /// Get the package installation prefix. + pub fn installation_dir(&self) -> Result { + Self::installation_dir_for(&self.name, &self.install_id) + } + + /// Resolve an installation prefix, including the legacy empty-ID layout. + pub fn installation_dir_for( + package_name: &str, + install_id: &str, + ) -> Result { + let packages_dir = get_packages_dir()?; + let package_dir = packages_dir.join(package_name); + if install_id.is_empty() { Ok(package_dir) } else { Ok(package_dir.join(install_id)) } + } + /// Get the metadata file path for a package. pub fn metadata_path(package_name: &str) -> Result { let packages_dir = get_packages_dir()?; @@ -202,6 +221,39 @@ mod tests { ); } + #[test] + fn test_legacy_metadata_defaults_to_empty_install_id() { + let metadata: PackageMetadata = serde_json::from_str( + r#"{ + "name": "typescript", + "version": "5.9.3", + "platform": { "node": "22.0.0" }, + "bins": ["tsc"], + "manager": "npm", + "installedAt": "2026-01-01T00:00:00Z" + }"#, + ) + .unwrap(); + + assert!(metadata.install_id.is_empty()); + } + + #[test] + fn test_installation_dir_uses_install_id() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let _guard = vite_shared::EnvConfig::test_guard( + vite_shared::EnvConfig::for_test_with_home(temp_dir.path()), + ); + + let legacy = PackageMetadata::installation_dir_for("@scope/pkg", "").unwrap(); + let identified = PackageMetadata::installation_dir_for("@scope/pkg", "install-id").unwrap(); + + assert!(legacy.as_path().ends_with("packages/@scope/pkg")); + assert!(identified.as_path().ends_with("packages/@scope/pkg/install-id")); + } + #[tokio::test] async fn test_save_scoped_package_metadata() { use tempfile::TempDir; diff --git a/crates/vite_global_cli/src/commands/env/which.rs b/crates/vite_global_cli/src/commands/env/which.rs index 9601a7e34e..fc0492c000 100644 --- a/crates/vite_global_cli/src/commands/env/which.rs +++ b/crates/vite_global_cli/src/commands/env/which.rs @@ -18,7 +18,7 @@ use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_shared::output; use super::{ - config::{VERSION_ENV_VAR, get_node_modules_dir, get_packages_dir, resolve_version}, + config::{VERSION_ENV_VAR, get_node_modules_dir, resolve_version}, package_metadata::PackageMetadata, }; use crate::error::Error; @@ -43,7 +43,7 @@ pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result match locate_package_binary(&metadata.name, tool) { + Ok(Some(metadata)) => match locate_package_binary(&metadata, tool) { Ok(_) => return execute_package_binary(tool, &metadata).await, Err(e) => warn_unusable_managed_corepack(&e.to_string()), }, @@ -188,7 +188,7 @@ async fn execute_package_binary( metadata: &PackageMetadata, ) -> Result { // Locate the binary path - let binary_path = locate_package_binary(&metadata.name, tool)?; + let binary_path = locate_package_binary(metadata, tool)?; // Check if binary exists if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { @@ -219,9 +219,12 @@ async fn execute_package_binary( } /// Locate a binary within a package's installation directory. -fn locate_package_binary(package_name: &str, binary_name: &str) -> Result { - let packages_dir = get_packages_dir()?; - let package_dir = packages_dir.join(package_name); +fn locate_package_binary( + metadata: &PackageMetadata, + binary_name: &str, +) -> Result { + let package_dir = metadata.installation_dir()?; + let package_name = &metadata.name; // The binary is referenced in package.json's bin field // npm uses different layouts: Unix=lib/node_modules, Windows=node_modules diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index da7816f08d..e9bb593f59 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -12,6 +12,7 @@ use indexmap::IndexMap; use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use owo_colors::OwoColorize; use tokio::process::Command; +use uuid::Uuid; use vite_js_runtime::NodeProvider; use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_shared::{format_path_prepended, output}; @@ -21,7 +22,7 @@ use crate::{ env::{ bin_config::BinConfig, config::{ - get_bin_dir, get_node_modules_dir, get_packages_dir, get_tmp_dir, resolve_version, + get_bin_dir, get_node_modules_dir, get_packages_dir, resolve_version, resolve_version_alias, }, package_metadata::PackageMetadata, @@ -40,12 +41,8 @@ struct InstalledPackage { installed_version: String, bin_names: Vec, js_bins: HashSet, - backup: Option, -} - -struct PackageBackup { - package_dir: AbsolutePathBuf, - backup_dir: AbsolutePathBuf, + install_id: String, + install_dir: AbsolutePathBuf, } fn package_error(package_name: &str, error: impl Into) -> (Option, Error) { @@ -237,8 +234,13 @@ pub async fn install( // 4. Finalize installed packages. let mut bin_owners = HashMap::::new(); for (index, (package_name, Package { spec: _, install })) in packages.into_iter().enumerate() { - let Some(InstalledPackage { installed_version, mut bin_names, mut js_bins, mut backup }) = - install + let Some(InstalledPackage { + installed_version, + mut bin_names, + mut js_bins, + install_id, + install_dir, + }) = install else { continue; }; @@ -248,7 +250,7 @@ pub async fn install( let previous_metadata = match PackageMetadata::load(&package_name).await { Ok(metadata) => metadata, Err(error) => { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(package_error(&package_name, error)); } @@ -299,7 +301,7 @@ pub async fn install( { Ok(bin_names) => bin_names, Err(error) => { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(package_error(&package_name, error)); } @@ -327,7 +329,7 @@ pub async fn install( } Ok(None) => {} Err(error) => { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(package_error(&package_name, error)); } @@ -352,7 +354,7 @@ pub async fn install( pkg, package_name )); if let Err(error) = Box::pin(uninstall(&pkg, false)).await { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(package_error(&package_name, error)); } @@ -364,7 +366,7 @@ pub async fn install( continue; } } else { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(( Some(package_name.clone()), @@ -383,7 +385,7 @@ pub async fn install( let bin_dir = match get_bin_dir().map_err(|error| package_error(&package_name, error)) { Ok(bin_dir) => bin_dir, Err(error) => { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(error); } @@ -400,11 +402,12 @@ pub async fn install( js_bins, "npm".to_string(), ); + metadata.install_id = install_id.clone(); metadata.bins_restricted = bins_restricted; if let Err(error) = metadata.save().await.map_err(|error| package_error(&package_name, error)) { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(error); } @@ -444,7 +447,8 @@ pub async fn install( } if !finalized { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + restore_package_metadata(&package_name, previous_metadata.as_ref()).await; + let _ = cleanup_failed_install(&install_dir).await; continue; } @@ -458,7 +462,8 @@ pub async fn install( .await; if let Err(error) = result.map_err(|error| package_error(&package_name, error)) { - let _ = cleanup_failed_install(&package_name, backup.take()).await; + restore_package_metadata(&package_name, previous_metadata.as_ref()).await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(error); } @@ -471,10 +476,8 @@ pub async fn install( continue; } - // 4.6 Commit the install by discarding the backup and reporting the installed bins. - if let Some(backup) = backup { - backup.discard().await; - } + // 4.6 Remove every inactive install after metadata and shims point at the new one. + cleanup_old_installations(&package_name, &install_id).await; // 4.7 Print success message output::success(&format!( @@ -500,24 +503,23 @@ pub async fn install( if let Some(error) = first_error { Err(error) } else { Ok(()) } } -/// Install one package into its final prefix. +/// Install one package into a unique final prefix. async fn install_one( package_name: &str, package_spec: &str, npm_path: &AbsolutePathBuf, node_bin_dir: &AbsolutePathBuf, ) -> Result { - // 1. Backup a installed package, create directories - let packages_dir = get_packages_dir()?; - let package_dir = packages_dir.join(package_name); - let backup = PackageBackup::create(package_name, &package_dir).await?; - tokio::fs::create_dir_all(&package_dir).await?; + // 1. Create an immutable install directory. + let install_id = new_install_id(); + let install_dir = PackageMetadata::installation_dir_for(package_name, &install_id)?; + tokio::fs::create_dir_all(&install_dir).await?; - // 2. Run npm install with prefix set to the final package directory + // 2. Run npm install with prefix set to the final installation directory. // Pipe stdout/stderr so npm output is hidden on success, shown on failure let output = Command::new(npm_path.as_path()) .args(["install", "-g", "--no-fund", &package_spec]) - .env("npm_config_prefix", package_dir.as_path()) + .env("npm_config_prefix", install_dir.as_path()) .env("PATH", format_path_prepended(node_bin_dir.as_path())) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -534,17 +536,17 @@ async fn install_one( let _ = std::io::stdout().write_all(&output.stdout); } let _ = std::io::stderr().write_all(&output.stderr); - cleanup_failed_install(package_name, backup).await?; + cleanup_failed_install(&install_dir).await?; return Err(Error::ConfigError( format!("npm install failed with exit code: {:?}", output.status.code()).into(), )); } - let node_modules_dir = get_node_modules_dir(&package_dir, package_name); + let node_modules_dir = get_node_modules_dir(&install_dir, package_name); let package_json_path = node_modules_dir.join("package.json"); if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { - cleanup_failed_install(package_name, backup).await?; + cleanup_failed_install(&install_dir).await?; return Err(Error::ConfigError( format!( "Package was not installed correctly, package.json not found at {}", @@ -557,14 +559,14 @@ async fn install_one( let package_json_content = match tokio::fs::read_to_string(&package_json_path).await { Ok(content) => content, Err(error) => { - cleanup_failed_install(package_name, backup).await?; + cleanup_failed_install(&install_dir).await?; return Err(error.into()); } }; let package_json: serde_json::Value = match serde_json::from_str(&package_json_content) { Ok(package_json) => package_json, Err(error) => { - cleanup_failed_install(package_name, backup).await?; + cleanup_failed_install(&install_dir).await?; return Err(Error::ConfigError( format!("Failed to parse package.json: {error}").into(), )); @@ -584,85 +586,69 @@ async fn install_one( } } - Ok(InstalledPackage { installed_version, bin_names, js_bins, backup }) + Ok(InstalledPackage { installed_version, bin_names, js_bins, install_id, install_dir }) } -impl PackageBackup { - async fn create( - package_name: &str, - package_dir: &AbsolutePathBuf, - ) -> Result, Error> { - if !tokio::fs::try_exists(package_dir).await.unwrap_or(false) { - return Ok(None); - } - - let backup_dir = unique_backup_dir(package_name)?; - if let Some(parent) = backup_dir.parent() { - tokio::fs::create_dir_all(parent).await?; - } - if let Some(parent) = package_dir.parent() { - tokio::fs::create_dir_all(parent).await?; - } +fn new_install_id() -> String { + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); + format!("{timestamp}-{}-{}", process::id(), Uuid::new_v4().simple()) +} - match tokio::fs::rename(package_dir, &backup_dir).await { - Ok(()) => Ok(Some(Self { package_dir: package_dir.clone(), backup_dir })), - // The package dir vanished between the existence check and the - // rename (a concurrent install/uninstall of the same package): - // treat it as no previous install. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e.into()), - } +async fn restore_package_metadata(package_name: &str, previous_metadata: Option<&PackageMetadata>) { + let result = match previous_metadata { + Some(metadata) => metadata.save().await, + None => PackageMetadata::delete(package_name).await, + }; + if let Err(error) = result { + tracing::warn!("Failed to restore global package metadata for {package_name}: {error}"); } +} - async fn restore(self) -> Result<(), Error> { - remove_dir_all_if_exists(&self.package_dir).await?; - if tokio::fs::try_exists(&self.backup_dir).await.unwrap_or(false) { - if let Some(parent) = self.package_dir.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::rename(&self.backup_dir, &self.package_dir).await?; - } - - Ok(()) - } +async fn cleanup_failed_install(install_dir: &AbsolutePathBuf) -> Result<(), Error> { + remove_dir_all_if_exists(install_dir).await +} - async fn discard(self) { - if let Err(error) = remove_dir_all_if_exists(&self.backup_dir).await { +async fn cleanup_old_installations(package_name: &str, current_install_id: &str) { + match PackageMetadata::load(package_name).await { + Ok(Some(metadata)) if metadata.install_id == current_install_id => {} + Ok(_) => return, + Err(error) => { tracing::warn!( - "Failed to remove old global package backup at {}: {}", - self.backup_dir.as_path().display(), - error + "Failed to verify the active global package installation for {package_name}: \ + {error}" ); + return; } } -} - -fn unique_backup_dir(package_name: &str) -> Result { - let base = get_tmp_dir()?.join("packages").join(package_name); - let package_dir_name = - base.as_path().file_name().and_then(|name| name.to_str()).unwrap_or("package"); - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); - let backup_name = format!("{package_dir_name}.{}.{}.old", process::id(), timestamp); - let mut backup_path = base.as_path().to_path_buf(); - backup_path.set_file_name(backup_name); + let Ok(package_dir) = PackageMetadata::installation_dir_for(package_name, "") else { + return; + }; + let Ok(mut entries) = tokio::fs::read_dir(&package_dir).await else { + return; + }; - AbsolutePathBuf::new(backup_path) - .ok_or_else(|| Error::ConfigError("Invalid global package backup path".into())) -} + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.file_name() == current_install_id { + continue; + } -async fn cleanup_failed_install( - package_name: &str, - backup: Option, -) -> Result<(), Error> { - match backup { - Some(backup) => { - remove_dir_all_if_exists(&backup.package_dir).await?; - backup.restore().await?; + let path = entry.path(); + let result = match entry.file_type().await { + Ok(file_type) if file_type.is_dir() && !file_type.is_symlink() => { + tokio::fs::remove_dir_all(&path).await + } + Ok(_) => tokio::fs::remove_file(&path).await, + Err(error) => Err(error), + }; + if let Err(error) = result { + tracing::warn!( + "Failed to remove old global package installation at {}: {}", + path.display(), + error + ); } - None => cleanup_installed_package(package_name).await?, } - Ok(()) } async fn remove_dir_all_if_exists(path: &AbsolutePathBuf) -> Result<(), Error> { @@ -673,30 +659,6 @@ async fn remove_dir_all_if_exists(path: &AbsolutePathBuf) -> Result<(), Error> { } } -async fn cleanup_installed_package(package_name: &str) -> Result<(), Error> { - let bin_dir = get_bin_dir()?; - if let Some(metadata) = PackageMetadata::load(package_name).await? { - for bin_name in metadata.bins { - remove_package_shim(&bin_dir, &bin_name).await?; - BinConfig::delete(&bin_name).await?; - } - } - - for bin_name in BinConfig::find_by_package(package_name).await? { - remove_package_shim(&bin_dir, &bin_name).await?; - BinConfig::delete(&bin_name).await?; - } - - let packages_dir = get_packages_dir()?; - let package_dir = packages_dir.join(package_name); - if tokio::fs::try_exists(&package_dir).await.unwrap_or(false) { - tokio::fs::remove_dir_all(&package_dir).await?; - } - PackageMetadata::delete(package_name).await?; - - Ok(()) -} - async fn stale_bin_names_for_package( previous_metadata: Option<&PackageMetadata>, package_name: &str, @@ -1248,7 +1210,7 @@ mod tests { } #[tokio::test] - async fn test_package_backup_uses_unique_tmp_dir_for_scoped_package() { + async fn test_cleanup_old_installations_keeps_only_current_install() { use tempfile::TempDir; use vite_path::AbsolutePathBuf; @@ -1260,33 +1222,30 @@ mod tests { let package_dir = AbsolutePathBuf::new(temp_path.join("packages").join("@scope").join("pkg")).unwrap(); - tokio::fs::create_dir_all(&package_dir).await.unwrap(); - tokio::fs::write(package_dir.join("marker").as_path(), "current").await.unwrap(); - - let stale_backup = - AbsolutePathBuf::new(temp_path.join("tmp").join("packages").join("@scope").join("pkg")) - .unwrap(); - tokio::fs::create_dir_all(&stale_backup).await.unwrap(); - tokio::fs::write(stale_backup.join("stale").as_path(), "locked").await.unwrap(); - - let backup = PackageBackup::create("@scope/pkg", &package_dir) - .await - .unwrap() - .expect("existing package should be backed up"); - - assert_ne!(backup.backup_dir.as_path(), stale_backup.as_path()); - assert!( - stale_backup.join("stale").as_path().exists(), - "stale fixed backup should be left untouched" - ); - assert!( - backup.backup_dir.join("marker").as_path().exists(), - "current package should be moved into the unique backup" - ); - assert!( - !package_dir.as_path().exists(), - "original package directory should be moved out before reinstall" + let current_install = package_dir.join("current-id"); + let old_install = package_dir.join("old-id"); + tokio::fs::create_dir_all(¤t_install).await.unwrap(); + tokio::fs::create_dir_all(&old_install).await.unwrap(); + tokio::fs::create_dir_all(package_dir.join("lib")).await.unwrap(); + tokio::fs::write(current_install.join("marker").as_path(), "current").await.unwrap(); + + let mut metadata = PackageMetadata::new( + "@scope/pkg".to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + None, + vec![], + HashSet::new(), + "npm".to_string(), ); + metadata.install_id = "current-id".to_string(); + metadata.save().await.unwrap(); + + cleanup_old_installations("@scope/pkg", "current-id").await; + + assert!(current_install.join("marker").as_path().exists()); + assert!(!old_install.as_path().exists()); + assert!(!package_dir.join("lib").as_path().exists()); } #[test] diff --git a/crates/vite_global_cli/src/commands/global/outdated.rs b/crates/vite_global_cli/src/commands/global/outdated.rs index e8b65d83a7..47f14f01c0 100644 --- a/crates/vite_global_cli/src/commands/global/outdated.rs +++ b/crates/vite_global_cli/src/commands/global/outdated.rs @@ -11,10 +11,7 @@ use vite_install::commands::outdated::Format; use super::{latest_package_versions, parse_package_spec}; use crate::{ - commands::env::{ - config::{get_node_modules_dir, get_packages_dir}, - package_metadata::PackageMetadata, - }, + commands::env::{config::get_node_modules_dir, package_metadata::PackageMetadata}, error::Error, }; @@ -24,6 +21,7 @@ pub struct OutdatedPackage { pub current: String, pub latest: String, pub spec: Option, + install_id: String, node: String, bins: Vec, } @@ -108,6 +106,7 @@ pub async fn get_outdated_packages( current: package.version, latest: version, spec, + install_id: package.install_id, node: package.platform.node, bins: package.bins, }); @@ -154,11 +153,11 @@ pub async fn execute( } fn print_json(packages: &[OutdatedPackage]) -> Result<(), Error> { - let packages_dir = get_packages_dir()?; let mut output = BTreeMap::new(); for package in packages { - let package_dir = packages_dir.join(&package.name); + let package_dir = + PackageMetadata::installation_dir_for(&package.name, &package.install_id)?; let location = get_node_modules_dir(&package_dir, &package.name); output.insert( diff --git a/crates/vite_global_cli/src/commands/vpx.rs b/crates/vite_global_cli/src/commands/vpx.rs index bacf518a36..53f840d0ea 100644 --- a/crates/vite_global_cli/src/commands/vpx.rs +++ b/crates/vite_global_cli/src/commands/vpx.rs @@ -137,7 +137,7 @@ async fn find_global_binary(cmd: &str) -> Option { _ => return None, }; - let path = match dispatch::locate_package_binary(&metadata.name, cmd) { + let path = match dispatch::locate_package_binary(&metadata, cmd) { Ok(p) => p, Err(_) => return None, }; diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index c4c1e7f606..c062c02bef 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -1060,7 +1060,7 @@ pub(crate) async fn package_binary_invocation( .map_err(|e| format!("Failed to install Node {node_version}: {e}"))?; // Locate the actual binary in the package directory - let binary_path = locate_package_binary(&metadata.name, tool) + let binary_path = locate_package_binary(metadata, tool) .map_err(|e| format!("Binary '{tool}' not found: {e}"))?; // Prepare environment for recursive invocations @@ -1095,11 +1095,11 @@ pub(crate) async fn find_package_for_binary( /// Locate a binary within a package's installation directory. pub(crate) fn locate_package_binary( - package_name: &str, + metadata: &PackageMetadata, binary_name: &str, ) -> Result { - let packages_dir = config::get_packages_dir().map_err(|e| format!("{e}"))?; - let package_dir = packages_dir.join(package_name); + let package_dir = metadata.installation_dir().map_err(|e| format!("{e}"))?; + let package_name = &metadata.name; // The binary is referenced in package.json's bin field // npm uses different layouts: Unix=lib/node_modules, Windows=node_modules diff --git a/packages/cli/snap-tests-global/command-env-which/snap.txt b/packages/cli/snap-tests-global/command-env-which/snap.txt index 96b61ab200..930bf5ca17 100644 --- a/packages/cli/snap-tests-global/command-env-which/snap.txt +++ b/packages/cli/snap-tests-global/command-env-which/snap.txt @@ -28,7 +28,7 @@ info: Installing 1 global package with Node.js Bins: cowsay, cowthink > vp env which cowsay # Global package - shows binary path with metadata -/packages/cowsay/lib/node_modules/cowsay/./cli.js +/packages/cowsay//lib/node_modules/cowsay/./cli.js Package: cowsay@ Binaries: cowsay, cowthink Node: diff --git a/packages/cli/snap-tests-global/command-outdated-global/snap.txt b/packages/cli/snap-tests-global/command-outdated-global/snap.txt index 2e610fa165..c858eaff08 100644 --- a/packages/cli/snap-tests-global/command-outdated-global/snap.txt +++ b/packages/cli/snap-tests-global/command-outdated-global/snap.txt @@ -9,7 +9,7 @@ "wanted": "1.0.1", "latest": "1.0.1", "dependent": "global", - "location": "/packages/testnpm2/lib/node_modules/testnpm2" + "location": "/packages/testnpm2//lib/node_modules/testnpm2" } } diff --git a/packages/cli/snap-tests-global/env-install-stale-backup/.node-version b/packages/cli/snap-tests-global/env-install-id/.node-version similarity index 100% rename from packages/cli/snap-tests-global/env-install-stale-backup/.node-version rename to packages/cli/snap-tests-global/env-install-id/.node-version diff --git a/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js b/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js new file mode 100644 index 0000000000..9ae97ed745 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('env-install-id-cli'); diff --git a/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json b/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json new file mode 100644 index 0000000000..057e369b14 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json @@ -0,0 +1,7 @@ +{ + "name": "@scope/env-install-id-pkg", + "version": "1.0.0", + "bin": { + "env-install-id-cli": "./cli.js" + } +} diff --git a/packages/cli/snap-tests-global/env-install-id/snap.txt b/packages/cli/snap-tests-global/env-install-id/snap.txt new file mode 100644 index 0000000000..8b8bae9db1 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-id/snap.txt @@ -0,0 +1,24 @@ +> vp install -g ./env-install-id-pkg # Install scoped package globally +info: Installing 1 global package with Node.js +✓ Installed @scope/env-install-id-pkg + Bins: env-install-id-cli + +> env-install-id-cli # Installed bin should run +env-install-id-cli + +> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId) throw new Error('missing install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');" # Record the first install and seed a legacy-layout file +identified install exists + +> vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory +info: Installing 1 global package with Node.js +✓ Installed @scope/env-install-id-pkg + Bins: env-install-id-cli + +> env-install-id-cli # Reinstalled bin should run +env-install-id-cli + +> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('new install directory missing'); if (fs.existsSync(path.join(packageDir, previous)) || fs.existsSync(path.join(packageDir, 'legacy-marker'))) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');" # Metadata should activate only the new install +install switched and old installs removed + +> vp remove -g @scope/env-install-id-pkg # Cleanup +Uninstalled @scope/env-install-id-pkg diff --git a/packages/cli/snap-tests-global/env-install-id/steps.json b/packages/cli/snap-tests-global/env-install-id/steps.json new file mode 100644 index 0000000000..88839d2fdc --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-id/steps.json @@ -0,0 +1,12 @@ +{ + "env": {}, + "commands": [ + "vp install -g ./env-install-id-pkg # Install scoped package globally", + "env-install-id-cli # Installed bin should run", + "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId) throw new Error('missing install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');\" # Record the first install and seed a legacy-layout file", + "vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory", + "env-install-id-cli # Reinstalled bin should run", + "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('new install directory missing'); if (fs.existsSync(path.join(packageDir, previous)) || fs.existsSync(path.join(packageDir, 'legacy-marker'))) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');\" # Metadata should activate only the new install", + "vp remove -g @scope/env-install-id-pkg # Cleanup" + ] +} diff --git a/packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/cli.js b/packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/cli.js deleted file mode 100644 index e801213849..0000000000 --- a/packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/cli.js +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -console.log('env-install-stale-backup-cli'); diff --git a/packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/package.json b/packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/package.json deleted file mode 100644 index 766eb36c7c..0000000000 --- a/packages/cli/snap-tests-global/env-install-stale-backup/env-install-stale-backup-pkg/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@scope/env-install-stale-backup-pkg", - "version": "1.0.0", - "bin": { - "env-install-stale-backup-cli": "./cli.js" - } -} diff --git a/packages/cli/snap-tests-global/env-install-stale-backup/snap.txt b/packages/cli/snap-tests-global/env-install-stale-backup/snap.txt deleted file mode 100644 index d017b22867..0000000000 --- a/packages/cli/snap-tests-global/env-install-stale-backup/snap.txt +++ /dev/null @@ -1,26 +0,0 @@ -> vp install -g ./env-install-stale-backup-pkg # Install scoped package globally -info: Installing 1 global package with Node.js -✓ Installed @scope/env-install-stale-backup-pkg - Bins: env-install-stale-backup-cli - -> node -e "const fs = require('fs'); const path = require('path'); const cp = require('child_process'); const dir = path.join(process.env.VP_HOME, 'tmp/packages/@scope/env-install-stale-backup-pkg'); fs.mkdirSync(dir, { recursive: true }); const exe = path.join(dir, 'locked-node.exe'); fs.copyFileSync(process.execPath, exe); fs.writeFileSync(path.join(dir, 'stale.txt'), 'stale'); const child = cp.spawn(exe, ['-e', 'setTimeout(() => {}, 60000)'], { detached: true, stdio: 'ignore' }); child.unref(); fs.writeFileSync(path.join(dir, 'pid.txt'), String(child.pid));" # Seed stale locked fixed backup path -> vp install -g ./env-install-stale-backup-pkg # Reinstall should ignore stale backup -info: Installing 1 global package with Node.js -✓ Installed @scope/env-install-stale-backup-pkg - Bins: env-install-stale-backup-cli - -> node -e "const fs = require('fs'); const path = require('path'); console.log(fs.readFileSync(path.join(process.env.VP_HOME, 'tmp/packages/@scope/env-install-stale-backup-pkg/stale.txt'), 'utf8'));" # Stale backup should be untouched -stale - -> node -e "const fs = require('fs'); const path = require('path'); console.log(fs.readFileSync(path.join(process.env.VP_HOME, 'bins/env-install-stale-backup-cli.json'), 'utf8'));" # Bin config should still point to package -{ - "name": "env-install-stale-backup-cli", - "package": "@scope/env-install-stale-backup-pkg", - "version": "1.0.0", - "nodeVersion": "22.22.0", - "source": "vp" -} - -> node -e "const fs = require('fs'); const path = require('path'); const pidPath = path.join(process.env.VP_HOME, 'tmp/packages/@scope/env-install-stale-backup-pkg/pid.txt'); try { process.kill(Number(fs.readFileSync(pidPath, 'utf8')), 'SIGTERM'); } catch {}" # Stop stale backup lock process -> vp remove -g @scope/env-install-stale-backup-pkg # Cleanup -Uninstalled @scope/env-install-stale-backup-pkg diff --git a/packages/cli/snap-tests-global/env-install-stale-backup/steps.json b/packages/cli/snap-tests-global/env-install-stale-backup/steps.json deleted file mode 100644 index d947e688d8..0000000000 --- a/packages/cli/snap-tests-global/env-install-stale-backup/steps.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "env": {}, - "ignoredPlatforms": ["linux", "darwin"], - "commands": [ - "vp install -g ./env-install-stale-backup-pkg # Install scoped package globally", - "node -e \"const fs = require('fs'); const path = require('path'); const cp = require('child_process'); const dir = path.join(process.env.VP_HOME, 'tmp/packages/@scope/env-install-stale-backup-pkg'); fs.mkdirSync(dir, { recursive: true }); const exe = path.join(dir, 'locked-node.exe'); fs.copyFileSync(process.execPath, exe); fs.writeFileSync(path.join(dir, 'stale.txt'), 'stale'); const child = cp.spawn(exe, ['-e', 'setTimeout(() => {}, 60000)'], { detached: true, stdio: 'ignore' }); child.unref(); fs.writeFileSync(path.join(dir, 'pid.txt'), String(child.pid));\" # Seed stale locked fixed backup path", - "vp install -g ./env-install-stale-backup-pkg # Reinstall should ignore stale backup", - "node -e \"const fs = require('fs'); const path = require('path'); console.log(fs.readFileSync(path.join(process.env.VP_HOME, 'tmp/packages/@scope/env-install-stale-backup-pkg/stale.txt'), 'utf8'));\" # Stale backup should be untouched", - "node -e \"const fs = require('fs'); const path = require('path'); console.log(fs.readFileSync(path.join(process.env.VP_HOME, 'bins/env-install-stale-backup-cli.json'), 'utf8'));\" # Bin config should still point to package", - "node -e \"const fs = require('fs'); const path = require('path'); const pidPath = path.join(process.env.VP_HOME, 'tmp/packages/@scope/env-install-stale-backup-pkg/pid.txt'); try { process.kill(Number(fs.readFileSync(pidPath, 'utf8')), 'SIGTERM'); } catch {}\" # Stop stale backup lock process", - "vp remove -g @scope/env-install-stale-backup-pkg # Cleanup" - ] -} diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index 1e5b974487..a7319d4b31 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -121,7 +121,7 @@ exports[`replaceUnstableOutput() > replace unstable vite-plus hash version 1`] = exports[`replaceUnstableOutput() > replace vite-plus home paths 1`] = ` "/js_runtime/node/v/bin/node -/packages/cowsay/lib/node_modules/cowsay/./cli.js +/packages/cowsay//lib/node_modules/cowsay/./cli.js /bin" `; diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index df1d07ef95..c7ccaff072 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -350,7 +350,7 @@ line 3 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/packages/cowsay/1782100800123-12345-0123456789abcdef0123456789abcdef/lib/node_modules/cowsay/./cli.js`, `${home}/.vite-plus`, `${home}/.vite-plus/bin`, ].join('\n'); diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 597b47aaf7..abebaf0c36 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -94,6 +94,8 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/\d{4}-\d{2}-\d{2}/g, '') // time only (HH:MM:SS) .replaceAll(/\d{2}:\d{2}:\d{2}/g, '') + // managed global package install ID: timestamp-pid-random UUID + .replaceAll(/\b\d{13,}-\d+-[0-9a-f]{32}\b/g, '') // duration .replaceAll(/\d+(?:\.\d+)?(?:s|ms|µs|ns)/g, 'ms') // parenthesized thread counts in CLI summaries From 26bbb7cf03e47bccc915e8083898d9f780d0ee0d Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 22 Jun 2026 06:58:54 +0800 Subject: [PATCH 2/7] refactor(global): use sibling install prefixes --- .../src/commands/env/package_metadata.rs | 35 +++++++-- .../src/commands/global/install.rs | 71 +++++++++++++++---- .../command-env-which/snap.txt | 2 +- .../command-outdated-global/snap.txt | 2 +- .../snap-tests-global/env-install-id/snap.txt | 4 +- .../env-install-id/steps.json | 4 +- .../__snapshots__/utils.spec.ts.snap | 2 +- packages/tools/src/__tests__/utils.spec.ts | 2 +- packages/tools/src/utils.ts | 4 +- 9 files changed, 99 insertions(+), 27 deletions(-) diff --git a/crates/vite_global_cli/src/commands/env/package_metadata.rs b/crates/vite_global_cli/src/commands/env/package_metadata.rs index 4d61973e88..1e7606f3fd 100644 --- a/crates/vite_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vite_global_cli/src/commands/env/package_metadata.rs @@ -9,6 +9,17 @@ use vite_path::AbsolutePathBuf; use super::config::get_packages_dir; use crate::error::Error; +// `#` is filesystem-safe but invalid in npm package names, so sibling installs cannot collide. +pub(crate) const INSTALL_ID_PREFIX: char = '#'; +// Keeps npm's 214-byte maximum package name within the common 255-byte filename limit. +pub(crate) const INSTALL_ID_LENGTH: usize = 41; + +pub(crate) fn is_install_id(value: &str) -> bool { + value.len() == INSTALL_ID_LENGTH + && value.starts_with(INSTALL_ID_PREFIX) + && value[INSTALL_ID_PREFIX.len_utf8()..].bytes().all(|byte| byte.is_ascii_hexdigit()) +} + /// Metadata for a globally installed package. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -88,8 +99,15 @@ impl PackageMetadata { install_id: &str, ) -> Result { let packages_dir = get_packages_dir()?; - let package_dir = packages_dir.join(package_name); - if install_id.is_empty() { Ok(package_dir) } else { Ok(package_dir.join(install_id)) } + if install_id.is_empty() { + Ok(packages_dir.join(package_name)) + } else if is_install_id(install_id) { + Ok(packages_dir.join(format!("{package_name}{install_id}"))) + } else { + Err(Error::ConfigError( + format!("Invalid global package install ID: {install_id}").into(), + )) + } } /// Get the metadata file path for a package. @@ -248,10 +266,19 @@ mod tests { ); let legacy = PackageMetadata::installation_dir_for("@scope/pkg", "").unwrap(); - let identified = PackageMetadata::installation_dir_for("@scope/pkg", "install-id").unwrap(); + let identified = PackageMetadata::installation_dir_for( + "@scope/pkg", + "#0000000000000001000000010000000000000001", + ) + .unwrap(); assert!(legacy.as_path().ends_with("packages/@scope/pkg")); - assert!(identified.as_path().ends_with("packages/@scope/pkg/install-id")); + assert!( + identified + .as_path() + .ends_with("packages/@scope/pkg#0000000000000001000000010000000000000001") + ); + assert!(PackageMetadata::installation_dir_for("@scope/pkg", "invalid").is_err()); } #[tokio::test] diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index e9bb593f59..2ace116fbe 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -17,6 +17,8 @@ use vite_js_runtime::NodeProvider; use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_shared::{format_path_prepended, output}; +#[cfg(test)] +use crate::commands::env::package_metadata::INSTALL_ID_LENGTH; use crate::{ commands::{ env::{ @@ -25,7 +27,7 @@ use crate::{ get_bin_dir, get_node_modules_dir, get_packages_dir, resolve_version, resolve_version_alias, }, - package_metadata::PackageMetadata, + package_metadata::{INSTALL_ID_PREFIX, PackageMetadata, is_install_id}, }, global::{CORE_SHIMS, is_local_package_spec, parse_package_spec}, }, @@ -590,8 +592,10 @@ async fn install_one( } fn new_install_id() -> String { - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); - format!("{timestamp}-{}-{}", process::id(), Uuid::new_v4().simple()) + let timestamp = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64; + let random = Uuid::new_v4().as_u128() as u64; + format!("{INSTALL_ID_PREFIX}{timestamp:016x}{:08x}{random:016x}", process::id()) } async fn restore_package_metadata(package_name: &str, previous_metadata: Option<&PackageMetadata>) { @@ -621,15 +625,33 @@ async fn cleanup_old_installations(package_name: &str, current_install_id: &str) } } - let Ok(package_dir) = PackageMetadata::installation_dir_for(package_name, "") else { + let Ok(legacy_package_dir) = PackageMetadata::installation_dir_for(package_name, "") else { + return; + }; + let Some(parent_dir) = legacy_package_dir.parent() else { + return; + }; + let Some(package_dir_name) = + legacy_package_dir.as_path().file_name().and_then(|name| name.to_str()) + else { return; }; - let Ok(mut entries) = tokio::fs::read_dir(&package_dir).await else { + let current_dir_name = format!("{package_dir_name}{current_install_id}"); + let Ok(mut entries) = tokio::fs::read_dir(parent_dir).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { - if entry.file_name() == current_install_id { + let entry_name = entry.file_name(); + let Some(entry_name) = entry_name.to_str() else { + continue; + }; + if entry_name == current_dir_name { + continue; + } + let is_legacy = entry_name == package_dir_name; + let is_old_install = entry_name.strip_prefix(package_dir_name).is_some_and(is_install_id); + if !is_legacy && !is_old_install { continue; } @@ -1220,13 +1242,26 @@ mod tests { vite_shared::EnvConfig::for_test_with_home(&temp_path), ); - let package_dir = + let legacy_package_dir = AbsolutePathBuf::new(temp_path.join("packages").join("@scope").join("pkg")).unwrap(); - let current_install = package_dir.join("current-id"); - let old_install = package_dir.join("old-id"); + let current_install_id = "#0000000000000001000000010000000000000001"; + let old_install_id = "#0000000000000002000000020000000000000002"; + let current_install = AbsolutePathBuf::new( + legacy_package_dir.as_path().with_file_name(format!("pkg{current_install_id}")), + ) + .unwrap(); + let old_install = AbsolutePathBuf::new( + legacy_package_dir.as_path().with_file_name(format!("pkg{old_install_id}")), + ) + .unwrap(); + let unrelated_install = AbsolutePathBuf::new( + legacy_package_dir.as_path().with_file_name(format!("other{old_install_id}")), + ) + .unwrap(); tokio::fs::create_dir_all(¤t_install).await.unwrap(); tokio::fs::create_dir_all(&old_install).await.unwrap(); - tokio::fs::create_dir_all(package_dir.join("lib")).await.unwrap(); + tokio::fs::create_dir_all(&legacy_package_dir).await.unwrap(); + tokio::fs::create_dir_all(&unrelated_install).await.unwrap(); tokio::fs::write(current_install.join("marker").as_path(), "current").await.unwrap(); let mut metadata = PackageMetadata::new( @@ -1238,14 +1273,24 @@ mod tests { HashSet::new(), "npm".to_string(), ); - metadata.install_id = "current-id".to_string(); + metadata.install_id = current_install_id.to_string(); metadata.save().await.unwrap(); - cleanup_old_installations("@scope/pkg", "current-id").await; + cleanup_old_installations("@scope/pkg", current_install_id).await; assert!(current_install.join("marker").as_path().exists()); assert!(!old_install.as_path().exists()); - assert!(!package_dir.join("lib").as_path().exists()); + assert!(!legacy_package_dir.as_path().exists()); + assert!(unrelated_install.as_path().exists()); + } + + #[test] + fn test_new_install_id_has_fixed_reserved_shape() { + let install_id = new_install_id(); + + assert!(is_install_id(&install_id)); + assert_eq!(install_id.len(), INSTALL_ID_LENGTH); + assert!(install_id.starts_with(INSTALL_ID_PREFIX)); } #[test] diff --git a/packages/cli/snap-tests-global/command-env-which/snap.txt b/packages/cli/snap-tests-global/command-env-which/snap.txt index 930bf5ca17..0507c9ea19 100644 --- a/packages/cli/snap-tests-global/command-env-which/snap.txt +++ b/packages/cli/snap-tests-global/command-env-which/snap.txt @@ -28,7 +28,7 @@ info: Installing 1 global package with Node.js Bins: cowsay, cowthink > vp env which cowsay # Global package - shows binary path with metadata -/packages/cowsay//lib/node_modules/cowsay/./cli.js +/packages/cowsay/lib/node_modules/cowsay/./cli.js Package: cowsay@ Binaries: cowsay, cowthink Node: diff --git a/packages/cli/snap-tests-global/command-outdated-global/snap.txt b/packages/cli/snap-tests-global/command-outdated-global/snap.txt index c858eaff08..b4e10dd3be 100644 --- a/packages/cli/snap-tests-global/command-outdated-global/snap.txt +++ b/packages/cli/snap-tests-global/command-outdated-global/snap.txt @@ -9,7 +9,7 @@ "wanted": "1.0.1", "latest": "1.0.1", "dependent": "global", - "location": "/packages/testnpm2//lib/node_modules/testnpm2" + "location": "/packages/testnpm2/lib/node_modules/testnpm2" } } diff --git a/packages/cli/snap-tests-global/env-install-id/snap.txt b/packages/cli/snap-tests-global/env-install-id/snap.txt index 8b8bae9db1..dc64445ba9 100644 --- a/packages/cli/snap-tests-global/env-install-id/snap.txt +++ b/packages/cli/snap-tests-global/env-install-id/snap.txt @@ -6,7 +6,7 @@ info: Installing 1 global package with Node.js > env-install-id-cli # Installed bin should run env-install-id-cli -> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId) throw new Error('missing install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');" # Record the first install and seed a legacy-layout file +> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId.startsWith('#') || metadata.installId.length !== 41 || [...metadata.installId.slice(1)].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');" # Record the first install and seed a legacy-layout directory identified install exists > vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory @@ -17,7 +17,7 @@ info: Installing 1 global package with Node.js > env-install-id-cli # Reinstalled bin should run env-install-id-cli -> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('new install directory missing'); if (fs.existsSync(path.join(packageDir, previous)) || fs.existsSync(path.join(packageDir, 'legacy-marker'))) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');" # Metadata should activate only the new install +> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('new install directory missing'); if (fs.existsSync(packageDir + previous) || fs.existsSync(packageDir)) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');" # Metadata should activate only the new sibling install install switched and old installs removed > vp remove -g @scope/env-install-id-pkg # Cleanup diff --git a/packages/cli/snap-tests-global/env-install-id/steps.json b/packages/cli/snap-tests-global/env-install-id/steps.json index 88839d2fdc..946ba3e11d 100644 --- a/packages/cli/snap-tests-global/env-install-id/steps.json +++ b/packages/cli/snap-tests-global/env-install-id/steps.json @@ -3,10 +3,10 @@ "commands": [ "vp install -g ./env-install-id-pkg # Install scoped package globally", "env-install-id-cli # Installed bin should run", - "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId) throw new Error('missing install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');\" # Record the first install and seed a legacy-layout file", + "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId.startsWith('#') || metadata.installId.length !== 41 || [...metadata.installId.slice(1)].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');\" # Record the first install and seed a legacy-layout directory", "vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory", "env-install-id-cli # Reinstalled bin should run", - "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(path.join(packageDir, metadata.installId))) throw new Error('new install directory missing'); if (fs.existsSync(path.join(packageDir, previous)) || fs.existsSync(path.join(packageDir, 'legacy-marker'))) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');\" # Metadata should activate only the new install", + "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('new install directory missing'); if (fs.existsSync(packageDir + previous) || fs.existsSync(packageDir)) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');\" # Metadata should activate only the new sibling install", "vp remove -g @scope/env-install-id-pkg # Cleanup" ] } diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index a7319d4b31..29545b8696 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -121,7 +121,7 @@ exports[`replaceUnstableOutput() > replace unstable vite-plus hash version 1`] = exports[`replaceUnstableOutput() > replace vite-plus home paths 1`] = ` "/js_runtime/node/v/bin/node -/packages/cowsay//lib/node_modules/cowsay/./cli.js +/packages/cowsay/lib/node_modules/cowsay/./cli.js /bin" `; diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index c7ccaff072..2ead529772 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -350,7 +350,7 @@ line 3 const home = homedir(); const output = [ `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, - `${home}/.vite-plus/packages/cowsay/1782100800123-12345-0123456789abcdef0123456789abcdef/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus/packages/cowsay#0000000000000001000000010000000000000001/lib/node_modules/cowsay/./cli.js`, `${home}/.vite-plus`, `${home}/.vite-plus/bin`, ].join('\n'); diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index abebaf0c36..373cbd6a8c 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -94,8 +94,8 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/\d{4}-\d{2}-\d{2}/g, '') // time only (HH:MM:SS) .replaceAll(/\d{2}:\d{2}:\d{2}/g, '') - // managed global package install ID: timestamp-pid-random UUID - .replaceAll(/\b\d{13,}-\d+-[0-9a-f]{32}\b/g, '') + // managed global package install ID: reserved prefix + timestamp + pid + random UUID + .replaceAll(/#[0-9a-f]{40}\b/g, '') // duration .replaceAll(/\d+(?:\.\d+)?(?:s|ms|µs|ns)/g, 'ms') // parenthesized thread counts in CLI summaries From 6d4c7e4216c1f78c6a8abee5e446aa4e7e760225 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 22 Jun 2026 07:36:35 +0800 Subject: [PATCH 3/7] refactor(global): use UUID install IDs --- .../src/commands/env/package_metadata.rs | 13 ++++++++----- .../vite_global_cli/src/commands/global/install.rs | 13 +++++-------- .../cli/snap-tests-global/env-install-id/snap.txt | 2 +- .../cli/snap-tests-global/env-install-id/steps.json | 2 +- packages/tools/src/__tests__/utils.spec.ts | 2 +- packages/tools/src/utils.ts | 7 +++++-- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/crates/vite_global_cli/src/commands/env/package_metadata.rs b/crates/vite_global_cli/src/commands/env/package_metadata.rs index 1e7606f3fd..68c47b5e7d 100644 --- a/crates/vite_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vite_global_cli/src/commands/env/package_metadata.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::{Uuid, Version}; use vite_path::AbsolutePathBuf; use super::config::get_packages_dir; @@ -12,12 +13,14 @@ use crate::error::Error; // `#` is filesystem-safe but invalid in npm package names, so sibling installs cannot collide. pub(crate) const INSTALL_ID_PREFIX: char = '#'; // Keeps npm's 214-byte maximum package name within the common 255-byte filename limit. -pub(crate) const INSTALL_ID_LENGTH: usize = 41; +pub(crate) const INSTALL_ID_LENGTH: usize = 37; pub(crate) fn is_install_id(value: &str) -> bool { value.len() == INSTALL_ID_LENGTH - && value.starts_with(INSTALL_ID_PREFIX) - && value[INSTALL_ID_PREFIX.len_utf8()..].bytes().all(|byte| byte.is_ascii_hexdigit()) + && value + .strip_prefix(INSTALL_ID_PREFIX) + .and_then(|uuid| Uuid::parse_str(uuid).ok()) + .is_some_and(|uuid| uuid.get_version() == Some(Version::Random)) } /// Metadata for a globally installed package. @@ -268,7 +271,7 @@ mod tests { let legacy = PackageMetadata::installation_dir_for("@scope/pkg", "").unwrap(); let identified = PackageMetadata::installation_dir_for( "@scope/pkg", - "#0000000000000001000000010000000000000001", + "#123e4567-e89b-42d3-a456-426614174000", ) .unwrap(); @@ -276,7 +279,7 @@ mod tests { assert!( identified .as_path() - .ends_with("packages/@scope/pkg#0000000000000001000000010000000000000001") + .ends_with("packages/@scope/pkg#123e4567-e89b-42d3-a456-426614174000") ); assert!(PackageMetadata::installation_dir_for("@scope/pkg", "invalid").is_err()); } diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index 2ace116fbe..9a5a8d50f3 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -3,8 +3,8 @@ use std::{ collections::{HashMap, HashSet}, io::{IsTerminal, Read, Write}, - process::{self, Stdio}, - time::{Duration, SystemTime, UNIX_EPOCH}, + process::Stdio, + time::Duration, }; use futures::{StreamExt, stream::FuturesUnordered}; @@ -592,10 +592,7 @@ async fn install_one( } fn new_install_id() -> String { - let timestamp = - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64; - let random = Uuid::new_v4().as_u128() as u64; - format!("{INSTALL_ID_PREFIX}{timestamp:016x}{:08x}{random:016x}", process::id()) + format!("{INSTALL_ID_PREFIX}{}", Uuid::new_v4()) } async fn restore_package_metadata(package_name: &str, previous_metadata: Option<&PackageMetadata>) { @@ -1244,8 +1241,8 @@ mod tests { let legacy_package_dir = AbsolutePathBuf::new(temp_path.join("packages").join("@scope").join("pkg")).unwrap(); - let current_install_id = "#0000000000000001000000010000000000000001"; - let old_install_id = "#0000000000000002000000020000000000000002"; + let current_install_id = "#123e4567-e89b-42d3-a456-426614174000"; + let old_install_id = "#987e6543-e21b-42d3-a456-426614174000"; let current_install = AbsolutePathBuf::new( legacy_package_dir.as_path().with_file_name(format!("pkg{current_install_id}")), ) diff --git a/packages/cli/snap-tests-global/env-install-id/snap.txt b/packages/cli/snap-tests-global/env-install-id/snap.txt index dc64445ba9..3a32651de4 100644 --- a/packages/cli/snap-tests-global/env-install-id/snap.txt +++ b/packages/cli/snap-tests-global/env-install-id/snap.txt @@ -6,7 +6,7 @@ info: Installing 1 global package with Node.js > env-install-id-cli # Installed bin should run env-install-id-cli -> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId.startsWith('#') || metadata.installId.length !== 41 || [...metadata.installId.slice(1)].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');" # Record the first install and seed a legacy-layout directory +> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const id = metadata.installId; const compact = id.slice(1).replaceAll('-', ''); if (!id.startsWith('#') || id.length !== 37 || id[15] !== '4' || !'89ab'.includes(id[20]) || compact.length !== 32 || [...compact].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + id)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), id); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');" # Record the first install and seed a legacy-layout directory identified install exists > vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory diff --git a/packages/cli/snap-tests-global/env-install-id/steps.json b/packages/cli/snap-tests-global/env-install-id/steps.json index 946ba3e11d..678dc1b645 100644 --- a/packages/cli/snap-tests-global/env-install-id/steps.json +++ b/packages/cli/snap-tests-global/env-install-id/steps.json @@ -3,7 +3,7 @@ "commands": [ "vp install -g ./env-install-id-pkg # Install scoped package globally", "env-install-id-cli # Installed bin should run", - "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); if (!metadata.installId.startsWith('#') || metadata.installId.length !== 41 || [...metadata.installId.slice(1)].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), metadata.installId); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');\" # Record the first install and seed a legacy-layout directory", + "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const id = metadata.installId; const compact = id.slice(1).replaceAll('-', ''); if (!id.startsWith('#') || id.length !== 37 || id[15] !== '4' || !'89ab'.includes(id[20]) || compact.length !== 32 || [...compact].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + id)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), id); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');\" # Record the first install and seed a legacy-layout directory", "vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory", "env-install-id-cli # Reinstalled bin should run", "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('new install directory missing'); if (fs.existsSync(packageDir + previous) || fs.existsSync(packageDir)) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');\" # Metadata should activate only the new sibling install", diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index 2ead529772..f5cb16b8f4 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -350,7 +350,7 @@ line 3 const home = homedir(); const output = [ `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, - `${home}/.vite-plus/packages/cowsay#0000000000000001000000010000000000000001/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus/packages/cowsay#123e4567-e89b-42d3-a456-426614174000/lib/node_modules/cowsay/./cli.js`, `${home}/.vite-plus`, `${home}/.vite-plus/bin`, ].join('\n'); diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 373cbd6a8c..328b826061 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -94,8 +94,11 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/\d{4}-\d{2}-\d{2}/g, '') // time only (HH:MM:SS) .replaceAll(/\d{2}:\d{2}:\d{2}/g, '') - // managed global package install ID: reserved prefix + timestamp + pid + random UUID - .replaceAll(/#[0-9a-f]{40}\b/g, '') + // managed global package install ID + .replaceAll( + /#[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/g, + '', + ) // duration .replaceAll(/\d+(?:\.\d+)?(?:s|ms|µs|ns)/g, 'ms') // parenthesized thread counts in CLI summaries From 2569ede40f5091392fb01aedfe5bf9092aa2efca Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 22 Jun 2026 08:49:21 +0800 Subject: [PATCH 4/7] better snap tests --- .../env-install-id/env-install-id-pkg/cli.js | 2 -- .../env-install-id-pkg/package.json | 7 ------ .../snap-tests-global/env-install-id/snap.txt | 24 ------------------- .../env-install-id/steps.json | 12 ---------- .../.node-version | 0 .../long-time-install-package/cli.js | 2 ++ .../long-time-install-package/package.json | 10 ++++++++ .../long-time-install-package/postinstall.js | 7 ++++++ .../env-install-interrupt/snap.txt | 13 ++++++++++ .../env-install-interrupt/steps.json | 10 ++++++++ .../test-reinstall-interrupt.js | 16 +++++++++++++ 11 files changed, 58 insertions(+), 45 deletions(-) delete mode 100644 packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js delete mode 100644 packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json delete mode 100644 packages/cli/snap-tests-global/env-install-id/snap.txt delete mode 100644 packages/cli/snap-tests-global/env-install-id/steps.json rename packages/cli/snap-tests-global/{env-install-id => env-install-interrupt}/.node-version (100%) create mode 100644 packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/cli.js create mode 100644 packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/package.json create mode 100644 packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/postinstall.js create mode 100644 packages/cli/snap-tests-global/env-install-interrupt/snap.txt create mode 100644 packages/cli/snap-tests-global/env-install-interrupt/steps.json create mode 100644 packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js diff --git a/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js b/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js deleted file mode 100644 index 9ae97ed745..0000000000 --- a/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/cli.js +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -console.log('env-install-id-cli'); diff --git a/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json b/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json deleted file mode 100644 index 057e369b14..0000000000 --- a/packages/cli/snap-tests-global/env-install-id/env-install-id-pkg/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "@scope/env-install-id-pkg", - "version": "1.0.0", - "bin": { - "env-install-id-cli": "./cli.js" - } -} diff --git a/packages/cli/snap-tests-global/env-install-id/snap.txt b/packages/cli/snap-tests-global/env-install-id/snap.txt deleted file mode 100644 index 3a32651de4..0000000000 --- a/packages/cli/snap-tests-global/env-install-id/snap.txt +++ /dev/null @@ -1,24 +0,0 @@ -> vp install -g ./env-install-id-pkg # Install scoped package globally -info: Installing 1 global package with Node.js -✓ Installed @scope/env-install-id-pkg - Bins: env-install-id-cli - -> env-install-id-cli # Installed bin should run -env-install-id-cli - -> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const id = metadata.installId; const compact = id.slice(1).replaceAll('-', ''); if (!id.startsWith('#') || id.length !== 37 || id[15] !== '4' || !'89ab'.includes(id[20]) || compact.length !== 32 || [...compact].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + id)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), id); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');" # Record the first install and seed a legacy-layout directory -identified install exists - -> vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory -info: Installing 1 global package with Node.js -✓ Installed @scope/env-install-id-pkg - Bins: env-install-id-cli - -> env-install-id-cli # Reinstalled bin should run -env-install-id-cli - -> node -e "const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('new install directory missing'); if (fs.existsSync(packageDir + previous) || fs.existsSync(packageDir)) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');" # Metadata should activate only the new sibling install -install switched and old installs removed - -> vp remove -g @scope/env-install-id-pkg # Cleanup -Uninstalled @scope/env-install-id-pkg diff --git a/packages/cli/snap-tests-global/env-install-id/steps.json b/packages/cli/snap-tests-global/env-install-id/steps.json deleted file mode 100644 index 678dc1b645..0000000000 --- a/packages/cli/snap-tests-global/env-install-id/steps.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "env": {}, - "commands": [ - "vp install -g ./env-install-id-pkg # Install scoped package globally", - "env-install-id-cli # Installed bin should run", - "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const id = metadata.installId; const compact = id.slice(1).replaceAll('-', ''); if (!id.startsWith('#') || id.length !== 37 || id[15] !== '4' || !'89ab'.includes(id[20]) || compact.length !== 32 || [...compact].some(c => !'0123456789abcdef'.includes(c))) throw new Error('invalid install id'); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); if (!fs.existsSync(packageDir + id)) throw new Error('install directory missing'); fs.writeFileSync(path.join(process.env.VP_HOME, 'first-install-id'), id); fs.mkdirSync(packageDir); fs.writeFileSync(path.join(packageDir, 'legacy-marker'), 'legacy'); console.log('identified install exists');\" # Record the first install and seed a legacy-layout directory", - "vp install -g ./env-install-id-pkg # Reinstall into a new immutable directory", - "env-install-id-cli # Reinstalled bin should run", - "node -e \"const fs = require('fs'); const path = require('path'); const metadata = JSON.parse(fs.readFileSync(path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg.json'), 'utf8')); const packageDir = path.join(process.env.VP_HOME, 'packages/@scope/env-install-id-pkg'); const previous = fs.readFileSync(path.join(process.env.VP_HOME, 'first-install-id'), 'utf8'); if (!metadata.installId || metadata.installId === previous) throw new Error('install id did not change'); if (!fs.existsSync(packageDir + metadata.installId)) throw new Error('new install directory missing'); if (fs.existsSync(packageDir + previous) || fs.existsSync(packageDir)) throw new Error('old install was not removed'); fs.rmSync(path.join(process.env.VP_HOME, 'first-install-id')); console.log('install switched and old installs removed');\" # Metadata should activate only the new sibling install", - "vp remove -g @scope/env-install-id-pkg # Cleanup" - ] -} diff --git a/packages/cli/snap-tests-global/env-install-id/.node-version b/packages/cli/snap-tests-global/env-install-interrupt/.node-version similarity index 100% rename from packages/cli/snap-tests-global/env-install-id/.node-version rename to packages/cli/snap-tests-global/env-install-interrupt/.node-version diff --git a/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/cli.js b/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/cli.js new file mode 100644 index 0000000000..34aed46427 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('long-time-install-package'); diff --git a/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/package.json b/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/package.json new file mode 100644 index 0000000000..8aefa0c93e --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/package.json @@ -0,0 +1,10 @@ +{ + "name": "@scope/long-time-install-package", + "version": "0.0.0", + "bin": { + "long-time-install-package": "./cli.js" + }, + "scripts": { + "postinstall": "node postinstall.js" + } +} diff --git a/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/postinstall.js b/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/postinstall.js new file mode 100644 index 0000000000..237bed9494 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-interrupt/long-time-install-package/postinstall.js @@ -0,0 +1,7 @@ +const { promisify } = require('util'); + +const sleep = promisify(setTimeout); + +(async () => { + await sleep(200); +})(); diff --git a/packages/cli/snap-tests-global/env-install-interrupt/snap.txt b/packages/cli/snap-tests-global/env-install-interrupt/snap.txt new file mode 100644 index 0000000000..ee9e7d0415 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-interrupt/snap.txt @@ -0,0 +1,13 @@ +> vp install -g ./long-time-install-package +info: Installing 1 global package with Node.js +✓ Installed @scope/long-time-install-package + Bins: long-time-install-package + +> long-time-install-package +long-time-install-package + +> node test-reinstall-interrupt.js # Reinstall but interrupt +info: Installing 1 global package with Node.js + +> long-time-install-package # Original package should be still runnable +long-time-install-package diff --git a/packages/cli/snap-tests-global/env-install-interrupt/steps.json b/packages/cli/snap-tests-global/env-install-interrupt/steps.json new file mode 100644 index 0000000000..24ad83f2b3 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-interrupt/steps.json @@ -0,0 +1,10 @@ +{ + "env": {}, + "commands": [ + "vp install -g ./long-time-install-package", + "long-time-install-package", + "node test-reinstall-interrupt.js # Reinstall but interrupt", + "long-time-install-package # Original package should be still runnable" + ], + "after": ["vp remove -g @scope/long-time-install-package"] +} diff --git a/packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js b/packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js new file mode 100644 index 0000000000..0f358a0857 --- /dev/null +++ b/packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js @@ -0,0 +1,16 @@ +const { spawn } = require('child_process'); + +const child = spawn('vp', ['install', '-g', './long-time-install-package'], { + stdio: 'inherit', + shell: true, +}); + +setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } +}, 100); + +child.on('close', (code) => { + process.exit(code); +}); From 401a892dbeb9325b2be1651fc2529f74c03886d1 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 22 Jun 2026 09:09:26 +0800 Subject: [PATCH 5/7] fix ci --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a79ccc1bd4..0af866bde4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,7 +294,6 @@ jobs: Get-Command tsc # Test 2: Verify the package was installed correctly - Get-ChildItem (Join-Path $vpHome "packages/typescript") Get-ChildItem $vpBin # Test 3: Uninstall @@ -386,7 +385,6 @@ jobs: which tsc # Test 2: Verify the package was installed correctly - ls -la ~/.vite-plus/packages/typescript/ ls -la ~/.vite-plus/bin/ # Test 3: Uninstall From 1311ca417f2217188daddc83b4be6d38dc87c138 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 22 Jun 2026 17:30:56 +0800 Subject: [PATCH 6/7] fix(global): make install activation recoverable --- .../src/commands/global/install.rs | 345 ++++++++++++------ 1 file changed, 226 insertions(+), 119 deletions(-) diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index 9a5a8d50f3..48c112b8d8 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -18,16 +18,13 @@ use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_shared::{format_path_prepended, output}; #[cfg(test)] -use crate::commands::env::package_metadata::INSTALL_ID_LENGTH; +use crate::commands::env::package_metadata::{INSTALL_ID_LENGTH, is_install_id}; use crate::{ commands::{ env::{ bin_config::BinConfig, - config::{ - get_bin_dir, get_node_modules_dir, get_packages_dir, resolve_version, - resolve_version_alias, - }, - package_metadata::{INSTALL_ID_PREFIX, PackageMetadata, is_install_id}, + config::{get_bin_dir, get_node_modules_dir, resolve_version, resolve_version_alias}, + package_metadata::{INSTALL_ID_PREFIX, PackageMetadata}, }, global::{CORE_SHIMS, is_local_package_spec, parse_package_spec}, }, @@ -383,7 +380,7 @@ pub async fn install( } } - // 4.3 Persist package-level metadata for uninstall, list, and dispatch. + // 4.3 Prepare metadata and remove binaries that the new install no longer provides. let bin_dir = match get_bin_dir().map_err(|error| package_error(&package_name, error)) { Ok(bin_dir) => bin_dir, Err(error) => { @@ -406,65 +403,78 @@ pub async fn install( ); metadata.install_id = install_id.clone(); metadata.bins_restricted = bins_restricted; - if let Err(error) = - metadata.save().await.map_err(|error| package_error(&package_name, error)) - { - let _ = cleanup_failed_install(&install_dir).await; - if first_error.is_none() { - first_error = Some(error); - } - continue; - } - // 4.4 Expose each binary by creating shims and per-binary ownership config. let mut finalized = true; - for bin_name in &bin_names { - if let Err(error) = create_package_shim(&bin_dir, bin_name, &package_name) - .await - .map_err(|error| package_error(&package_name, error)) - { - finalized = false; - if first_error.is_none() { - first_error = Some(error); - } - break; + for bin_name in &stale_bin_names { + let result = async { + remove_package_shim(&bin_dir, bin_name).await?; + BinConfig::delete(bin_name).await?; + Ok::<(), Error>(()) } + .await; - let bin_config = BinConfig::new( - bin_name.clone(), - package_name.clone(), - installed_version.clone(), - node_version.clone(), - ); - if let Err(error) = - bin_config.save().await.map_err(|error| package_error(&package_name, error)) - { - finalized = false; + if let Err(error) = result.map_err(|error| package_error(&package_name, error)) { + restore_previous_install_state( + &bin_dir, + &package_name, + previous_metadata.as_ref(), + &bin_names, + ) + .await; + let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(error); } + finalized = false; break; } - bin_owners.insert(bin_name.clone(), package_name.clone()); } if !finalized { - restore_package_metadata(&package_name, previous_metadata.as_ref()).await; + continue; + } + + // 4.4 Activate the new installation through metadata. + if let Err(error) = + metadata.save().await.map_err(|error| package_error(&package_name, error)) + { + restore_previous_install_state( + &bin_dir, + &package_name, + previous_metadata.as_ref(), + &bin_names, + ) + .await; let _ = cleanup_failed_install(&install_dir).await; + if first_error.is_none() { + first_error = Some(error); + } continue; } - // 4.5 Remove shims for binaries the package used to expose but no longer declares. - for bin_name in stale_bin_names { + // 4.5 Expose each binary and record its ownership. + for bin_name in &bin_names { let result = async { - remove_package_shim(&bin_dir, &bin_name).await?; - BinConfig::delete(&bin_name).await?; - Ok::<(), Error>(()) + create_package_shim(&bin_dir, bin_name, &package_name).await?; + BinConfig::new( + bin_name.clone(), + package_name.clone(), + installed_version.clone(), + node_version.clone(), + ) + .save() + .await } .await; if let Err(error) = result.map_err(|error| package_error(&package_name, error)) { - restore_package_metadata(&package_name, previous_metadata.as_ref()).await; + restore_previous_install_state( + &bin_dir, + &package_name, + previous_metadata.as_ref(), + &bin_names, + ) + .await; let _ = cleanup_failed_install(&install_dir).await; if first_error.is_none() { first_error = Some(error); @@ -478,8 +488,12 @@ pub async fn install( continue; } - // 4.6 Remove every inactive install after metadata and shims point at the new one. - cleanup_old_installations(&package_name, &install_id).await; + for bin_name in &bin_names { + bin_owners.insert(bin_name.clone(), package_name.clone()); + } + + // 4.6 Remove only the installation that this operation replaced. + cleanup_previous_installation(previous_metadata.as_ref(), &install_id).await; // 4.7 Print success message output::success(&format!( @@ -605,68 +619,81 @@ async fn restore_package_metadata(package_name: &str, previous_metadata: Option< } } -async fn cleanup_failed_install(install_dir: &AbsolutePathBuf) -> Result<(), Error> { - remove_dir_all_if_exists(install_dir).await -} +async fn restore_previous_install_state( + bin_dir: &AbsolutePath, + package_name: &str, + previous_metadata: Option<&PackageMetadata>, + current_bin_names: &[String], +) { + restore_package_metadata(package_name, previous_metadata).await; + + let previous_bin_names = previous_metadata + .map(|metadata| metadata.bins.iter().cloned().collect::>()) + .unwrap_or_default(); + let bin_names = + current_bin_names.iter().chain(previous_bin_names.iter()).cloned().collect::>(); + + for bin_name in bin_names { + let result = if previous_bin_names.contains(&bin_name) { + async { + create_package_shim(bin_dir, &bin_name, package_name).await?; + if let Some(metadata) = previous_metadata { + BinConfig::new( + bin_name.clone(), + package_name.to_string(), + metadata.version.clone(), + metadata.platform.node.clone(), + ) + .save() + .await + } else { + Ok(()) + } + } + .await + } else { + async { + remove_package_shim(bin_dir, &bin_name).await?; + BinConfig::delete(&bin_name).await + } + .await + }; -async fn cleanup_old_installations(package_name: &str, current_install_id: &str) { - match PackageMetadata::load(package_name).await { - Ok(Some(metadata)) if metadata.install_id == current_install_id => {} - Ok(_) => return, - Err(error) => { + if let Err(error) = result { tracing::warn!( - "Failed to verify the active global package installation for {package_name}: \ - {error}" + "Failed to restore '{}' binary state for global package '{}': {}", + bin_name, + package_name, + error ); - return; } } +} - let Ok(legacy_package_dir) = PackageMetadata::installation_dir_for(package_name, "") else { - return; - }; - let Some(parent_dir) = legacy_package_dir.parent() else { +async fn cleanup_failed_install(install_dir: &AbsolutePathBuf) -> Result<(), Error> { + remove_dir_all_if_exists(install_dir).await +} + +async fn cleanup_previous_installation( + previous_metadata: Option<&PackageMetadata>, + current_install_id: &str, +) { + let Some(previous_metadata) = previous_metadata else { return; }; - let Some(package_dir_name) = - legacy_package_dir.as_path().file_name().and_then(|name| name.to_str()) - else { + if previous_metadata.install_id == current_install_id { return; - }; - let current_dir_name = format!("{package_dir_name}{current_install_id}"); - let Ok(mut entries) = tokio::fs::read_dir(parent_dir).await else { + } + + let Ok(previous_install_dir) = previous_metadata.installation_dir() else { return; }; - - while let Ok(Some(entry)) = entries.next_entry().await { - let entry_name = entry.file_name(); - let Some(entry_name) = entry_name.to_str() else { - continue; - }; - if entry_name == current_dir_name { - continue; - } - let is_legacy = entry_name == package_dir_name; - let is_old_install = entry_name.strip_prefix(package_dir_name).is_some_and(is_install_id); - if !is_legacy && !is_old_install { - continue; - } - - let path = entry.path(); - let result = match entry.file_type().await { - Ok(file_type) if file_type.is_dir() && !file_type.is_symlink() => { - tokio::fs::remove_dir_all(&path).await - } - Ok(_) => tokio::fs::remove_file(&path).await, - Err(error) => Err(error), - }; - if let Err(error) = result { - tracing::warn!( - "Failed to remove old global package installation at {}: {}", - path.display(), - error - ); - } + if let Err(error) = remove_dir_all_if_exists(&previous_install_dir).await { + tracing::warn!( + "Failed to remove replaced global package installation at {}: {}", + previous_install_dir.as_path().display(), + error + ); } } @@ -715,8 +742,9 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { let (package_name, _) = parse_package_spec(package_name).unwrap(); - // Phase 1: Try to use PackageMetadata for binary list - let bins = if let Some(metadata) = PackageMetadata::load(&package_name).await? { + // Phase 1: Try to use PackageMetadata for binary list and installation path. + let metadata = PackageMetadata::load(&package_name).await?; + let bins = if let Some(metadata) = &metadata { metadata.bins.clone() } else { // Phase 2: Fallback - scan BinConfig files for orphaned binaries @@ -731,8 +759,10 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { if dry_run { let bin_dir = get_bin_dir()?; - let packages_dir = get_packages_dir()?; - let package_dir = packages_dir.join(&package_name); + let package_dir = match &metadata { + Some(metadata) => metadata.installation_dir()?, + None => PackageMetadata::installation_dir_for(&package_name, "")?, + }; let metadata_path = PackageMetadata::metadata_path(&package_name)?; output::raw(&format!("Would uninstall {}:", package_name)); @@ -760,8 +790,10 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { } // Remove package directory - let packages_dir = get_packages_dir()?; - let package_dir = packages_dir.join(&package_name); + let package_dir = match &metadata { + Some(metadata) => metadata.installation_dir()?, + None => PackageMetadata::installation_dir_for(&package_name, "")?, + }; if tokio::fs::try_exists(&package_dir).await.unwrap_or(false) { tokio::fs::remove_dir_all(&package_dir).await?; } @@ -1184,7 +1216,7 @@ mod tests { } // Create metadata with bins - let metadata = PackageMetadata::new( + let mut metadata = PackageMetadata::new( "typescript".to_string(), "5.9.3".to_string(), "20.18.0".to_string(), @@ -1193,11 +1225,11 @@ mod tests { HashSet::from(["tsc".to_string(), "tsserver".to_string()]), "npm".to_string(), ); + metadata.install_id = "#123e4567-e89b-42d3-a456-426614174000".to_string(); metadata.save().await.unwrap(); - // Create package directory (needed for uninstall) - let packages_dir = AbsolutePathBuf::new(temp_path.join("packages")).unwrap(); - let package_dir = packages_dir.join("typescript"); + // Create identified package directory (needed for uninstall) + let package_dir = metadata.installation_dir().unwrap(); tokio::fs::create_dir_all(&package_dir).await.unwrap(); // Verify metadata was saved @@ -1226,10 +1258,11 @@ mod tests { "tsserver.exe shim should be removed" ); } + assert!(!package_dir.as_path().exists(), "identified package directory should be removed"); } #[tokio::test] - async fn test_cleanup_old_installations_keeps_only_current_install() { + async fn test_cleanup_previous_installation_removes_only_replaced_install() { use tempfile::TempDir; use vite_path::AbsolutePathBuf; @@ -1243,6 +1276,7 @@ mod tests { AbsolutePathBuf::new(temp_path.join("packages").join("@scope").join("pkg")).unwrap(); let current_install_id = "#123e4567-e89b-42d3-a456-426614174000"; let old_install_id = "#987e6543-e21b-42d3-a456-426614174000"; + let stale_install_id = "#987e6543-e21b-42d3-b456-426614174000"; let current_install = AbsolutePathBuf::new( legacy_package_dir.as_path().with_file_name(format!("pkg{current_install_id}")), ) @@ -1251,17 +1285,16 @@ mod tests { legacy_package_dir.as_path().with_file_name(format!("pkg{old_install_id}")), ) .unwrap(); - let unrelated_install = AbsolutePathBuf::new( - legacy_package_dir.as_path().with_file_name(format!("other{old_install_id}")), + let stale_install = AbsolutePathBuf::new( + legacy_package_dir.as_path().with_file_name(format!("pkg{stale_install_id}")), ) .unwrap(); tokio::fs::create_dir_all(¤t_install).await.unwrap(); tokio::fs::create_dir_all(&old_install).await.unwrap(); - tokio::fs::create_dir_all(&legacy_package_dir).await.unwrap(); - tokio::fs::create_dir_all(&unrelated_install).await.unwrap(); + tokio::fs::create_dir_all(&stale_install).await.unwrap(); tokio::fs::write(current_install.join("marker").as_path(), "current").await.unwrap(); - let mut metadata = PackageMetadata::new( + let mut previous_metadata = PackageMetadata::new( "@scope/pkg".to_string(), "1.0.0".to_string(), "22.0.0".to_string(), @@ -1270,15 +1303,89 @@ mod tests { HashSet::new(), "npm".to_string(), ); - metadata.install_id = current_install_id.to_string(); - metadata.save().await.unwrap(); + previous_metadata.install_id = old_install_id.to_string(); - cleanup_old_installations("@scope/pkg", current_install_id).await; + cleanup_previous_installation(Some(&previous_metadata), current_install_id).await; assert!(current_install.join("marker").as_path().exists()); assert!(!old_install.as_path().exists()); - assert!(!legacy_package_dir.as_path().exists()); - assert!(unrelated_install.as_path().exists()); + assert!(stale_install.as_path().exists()); + } + + #[tokio::test] + #[cfg_attr(windows, serial_test::serial)] + async fn test_restore_previous_install_state_removes_partial_new_bins() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path().to_path_buf(); + #[cfg(windows)] + let _trampoline_guard = FakeTrampolineGuard::new(&temp_path); + let _env_guard = vite_shared::EnvConfig::test_guard( + vite_shared::EnvConfig::for_test_with_home(&temp_path), + ); + let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); + + let mut previous_metadata = PackageMetadata::new( + "test-package".to_string(), + "1.0.0".to_string(), + "20.0.0".to_string(), + None, + vec!["keep".to_string(), "drop".to_string()], + HashSet::from(["keep".to_string(), "drop".to_string()]), + "npm".to_string(), + ); + previous_metadata.install_id = "#123e4567-e89b-42d3-a456-426614174000".to_string(); + + let mut new_metadata = PackageMetadata::new( + "test-package".to_string(), + "2.0.0".to_string(), + "22.0.0".to_string(), + None, + vec!["keep".to_string(), "new".to_string()], + HashSet::from(["keep".to_string(), "new".to_string()]), + "npm".to_string(), + ); + new_metadata.install_id = "#987e6543-e21b-42d3-a456-426614174000".to_string(); + new_metadata.save().await.unwrap(); + + for bin_name in ["keep", "new"] { + create_package_shim(&bin_dir, bin_name, "test-package").await.unwrap(); + BinConfig::new( + bin_name.to_string(), + "test-package".to_string(), + "2.0.0".to_string(), + "22.0.0".to_string(), + ) + .save() + .await + .unwrap(); + } + + restore_previous_install_state( + &bin_dir, + "test-package", + Some(&previous_metadata), + &new_metadata.bins, + ) + .await; + + let restored = PackageMetadata::load("test-package").await.unwrap().unwrap(); + assert_eq!(restored.install_id, previous_metadata.install_id); + assert_eq!(BinConfig::load("keep").await.unwrap().unwrap().version, "1.0.0"); + assert_eq!(BinConfig::load("drop").await.unwrap().unwrap().version, "1.0.0"); + assert!(BinConfig::load("new").await.unwrap().is_none()); + #[cfg(unix)] + { + assert!(std::fs::symlink_metadata(bin_dir.join("drop").as_path()).is_ok()); + assert!(std::fs::symlink_metadata(bin_dir.join("new").as_path()).is_err()); + } + #[cfg(windows)] + { + assert!(bin_dir.join("drop.exe").as_path().exists()); + assert!(!bin_dir.join("new.exe").as_path().exists()); + } } #[test] From afbcfee0f12d60c666be6c377894ddc126532957 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 22 Jun 2026 17:48:29 +0800 Subject: [PATCH 7/7] chore: remove shell --- .../env-install-interrupt/test-reinstall-interrupt.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js b/packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js index 0f358a0857..76f2c3fb31 100644 --- a/packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js +++ b/packages/cli/snap-tests-global/env-install-interrupt/test-reinstall-interrupt.js @@ -2,7 +2,6 @@ const { spawn } = require('child_process'); const child = spawn('vp', ['install', '-g', './long-time-install-package'], { stdio: 'inherit', - shell: true, }); setTimeout(() => {