From 171bf7735c7b13e00c507e387948b1e60b522599 Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 23 Jul 2026 10:53:01 +0800 Subject: [PATCH 1/6] refactor(global): nest immutable package installs --- .../snapshots/command_env_install_conflict.md | 10 +- .../command_env_install_node_version.md | 33 +--- .../snapshots/command_env_which.md | 2 +- .../snapshots/command_outdated_global.md | 2 +- .../check-stale-packages.js | 20 ++- .../src/commands/env/package_metadata.rs | 44 +++-- .../src/commands/global/install.rs | 153 +++++++++++++++--- 7 files changed, 176 insertions(+), 88 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_conflict/snapshots/command_env_install_conflict.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_conflict/snapshots/command_env_install_conflict.md index 4993aa2237..ef3858f9af 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_conflict/snapshots/command_env_install_conflict.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_conflict/snapshots/command_env_install_conflict.md @@ -25,21 +25,19 @@ Uninstalled conflict-pkg Install with specific Node.js version -**Exit code:** 1 - ``` VITE+ - The Unified Toolchain for the Web info: Installing 1 global package with Node.js -error: Failed to install conflict-pkg: Package was not installed correctly, package.json not found at /.vite-plus/packages/conflict-pkg#/lib/node_modules/conflict-pkg/package.json +warn: Package 'conflict-pkg' provides 'node' binary, but it conflicts with a built-in shim. Skipping. +✓ Installed conflict-pkg 1.0.0 + Bins: conflict-cli ``` ## `vp remove -g conflict-pkg` Cleanup -**Exit code:** 1 - ``` -Failed to uninstall conflict-pkg: Package conflict-pkg is not installed +Uninstalled conflict-pkg ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_node_version/snapshots/command_env_install_node_version.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_node_version/snapshots/command_env_install_node_version.md index 5b20dde9e3..6a10bde4d1 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_node_version/snapshots/command_env_install_node_version.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_node_version/snapshots/command_env_install_node_version.md @@ -32,51 +32,26 @@ Uninstalled command-env-install-node-version-pkg Install with Node.js 20 -**Exit code:** 1 - ``` VITE+ - The Unified Toolchain for the Web info: Installing 1 global package with Node.js -error: Failed to install command-env-install-node-version-pkg: Package was not installed correctly, package.json not found at /.vite-plus/packages/command-env-install-node-version-pkg#/lib/node_modules/command-env-install-node-version-pkg/package.json +✓ Installed command-env-install-node-version-pkg 1.0.0 + Bins: command-env-install-node-version-pkg-cli ``` ## `node -e 'const d=JSON.parse(require('\''fs'\'').readFileSync(process.env.VP_HOME+'\''/bins/command-env-install-node-version-pkg-cli.json'\'','\''utf8'\'')); console.log('\''Node major:'\'', d.nodeVersion.split('\''.'\'')[0])'` Verify Node 20 -**Exit code:** 1 - ``` -node:fs:441 - return binding.readFileUtf8(path, stringToFlags(options.flag)); - ^ - -Error: ENOENT: no such file or directory, open '/.vite-plus/bins/command-env-install-node-version-pkg-cli.json' - at Object.readFileSync (node:fs:441:20) - at [eval]:1:34 - at runScriptInThisContext (node:internal/vm:219:10) - at node:internal/process/execution:451:12 - at [eval]-wrapper:6:24 - at runScriptInContext (node:internal/process/execution:449:60) - at evalFunction (node:internal/process/execution:283:30) - at evalTypeScript (node:internal/process/execution:295:3) - at node:internal/main/eval_string:71:3 { - errno: -2, - code: 'ENOENT', - syscall: 'open', - path: '/.vite-plus/bins/command-env-install-node-version-pkg-cli.json' -} - -Node.js +Node major: 20 ``` ## `vp remove -g command-env-install-node-version-pkg` Cleanup -**Exit code:** 1 - ``` -Failed to uninstall command-env-install-node-version-pkg: Package command-env-install-node-version-pkg is not installed +Uninstalled command-env-install-node-version-pkg ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md index 461da946a5..e498576334 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md @@ -83,7 +83,7 @@ Global package - shows binary path with metadata ``` VITE+ - The Unified Toolchain for the Web -/.vite-plus/packages/cowsay#/lib/node_modules/cowsay/./cli.js +/.vite-plus/packages/cowsay//lib/node_modules/cowsay/./cli.js Package: cowsay@1.6.0 Binaries: cowsay, cowthink Node: diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md index e083cce7b7..9df649c7ab 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md @@ -26,7 +26,7 @@ should support global json output "wanted": "1.0.1", "latest": "1.0.1", "dependent": "global", - "location": "/.vite-plus/packages/testnpm2#/lib/node_modules/testnpm2" + "location": "/.vite-plus/packages/testnpm2//lib/node_modules/testnpm2" } } ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/check-stale-packages.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/check-stale-packages.js index 05343d9654..fa06c8b448 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/check-stale-packages.js +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/env_install_interrupt/check-stale-packages.js @@ -5,26 +5,24 @@ const packageBase = 'long-time-install-package'; const scopeDir = path.join(process.env.VP_HOME, 'packages', '@scope'); const metadataPath = path.join(scopeDir, `${packageBase}.json`); const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); -const activeDir = `${packageBase}${metadata.installId}`; +const packageDir = path.join(scopeDir, packageBase); +const activeDir = metadata.installId; const expectStale = process.argv.includes('--expect-stale'); const packageDirs = fs - .readdirSync(scopeDir, { withFileTypes: true }) + .readdirSync(packageDir, { withFileTypes: true }) .filter((entry) => { - if (!entry.isDirectory()) { - return false; - } - if (entry.name === packageBase) { - return true; - } - return /^long-time-install-package#[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( - entry.name, + return ( + entry.isDirectory() + && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + entry.name, + ) ); }) .map((entry) => entry.name) .sort(); -const hasIdentifiedStale = packageDirs.some((name) => name !== packageBase && name !== activeDir); +const hasIdentifiedStale = packageDirs.some((name) => name !== activeDir); console.log( hasIdentifiedStale ? 'interrupted stale package exists' : 'interrupted stale package removed', 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 af979b06f8..386b520169 100644 --- a/crates/vite_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vite_global_cli/src/commands/env/package_metadata.rs @@ -10,19 +10,20 @@ 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 = 37; +const LEGACY_INSTALL_ID_PREFIX: char = '#'; +const INSTALL_ID_LENGTH: usize = 36; -pub(crate) fn is_install_id(value: &str) -> bool { +pub(crate) fn is_nested_install_id(value: &str) -> bool { value.len() == INSTALL_ID_LENGTH - && value - .strip_prefix(INSTALL_ID_PREFIX) - .and_then(|uuid| Uuid::parse_str(uuid).ok()) + && Uuid::parse_str(value) + .ok() .is_some_and(|uuid| uuid.get_version() == Some(Version::Random)) } +pub(crate) fn is_legacy_install_id(value: &str) -> bool { + value.strip_prefix(LEGACY_INSTALL_ID_PREFIX).is_some_and(is_nested_install_id) +} + /// Metadata for a globally installed package. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -31,7 +32,7 @@ pub struct PackageMetadata { pub name: String, /// Package version pub version: String, - /// Directory identifier for this installation. Empty for legacy installs. + /// Directory identifier for this installation. Empty or `#`-prefixed for legacy installs. #[serde(default)] pub install_id: String, /// Platform versions used during installation @@ -96,15 +97,18 @@ impl PackageMetadata { Self::installation_dir_for(&self.name, &self.install_id) } - /// Resolve an installation prefix, including the legacy empty-ID layout. + /// Resolve an installation prefix, including both legacy layouts. 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(packages_dir.join(package_name)) - } else if is_install_id(install_id) { + Ok(package_dir) + } else if is_nested_install_id(install_id) { + Ok(package_dir.join(install_id)) + } else if is_legacy_install_id(install_id) { Ok(packages_dir.join(format!("{package_name}{install_id}"))) } else { Err(Error::ConfigError( @@ -241,7 +245,7 @@ mod tests { } #[test] - fn test_installation_dir_uses_install_id() { + fn test_installation_dir_supports_current_and_legacy_layouts() { use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); @@ -250,18 +254,28 @@ mod tests { ); let legacy = PackageMetadata::installation_dir_for("@scope/pkg", "").unwrap(); - let identified = PackageMetadata::installation_dir_for( + let legacy_identified = PackageMetadata::installation_dir_for( "@scope/pkg", "#123e4567-e89b-42d3-a456-426614174000", ) .unwrap(); + let identified = PackageMetadata::installation_dir_for( + "@scope/pkg", + "987e6543-e21b-42d3-a456-426614174000", + ) + .unwrap(); assert!(legacy.as_path().ends_with("packages/@scope/pkg")); assert!( - identified + legacy_identified .as_path() .ends_with("packages/@scope/pkg#123e4567-e89b-42d3-a456-426614174000") ); + assert!( + identified + .as_path() + .ends_with("packages/@scope/pkg/987e6543-e21b-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 d2618c5314..670eb6139e 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -18,14 +18,12 @@ 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::{ bin_config::BinConfig, config::{get_bin_dir, get_node_modules_dir, resolve_version, resolve_version_alias}, - package_metadata::{INSTALL_ID_PREFIX, PackageMetadata, is_install_id}, + package_metadata::{PackageMetadata, is_legacy_install_id, is_nested_install_id}, }, global::{CORE_SHIMS, is_local_package_spec, parse_package_spec}, }, @@ -609,7 +607,7 @@ async fn install_one( } fn new_install_id() -> String { - format!("{INSTALL_ID_PREFIX}{}", Uuid::new_v4()) + Uuid::new_v4().to_string() } async fn restore_package_metadata(package_name: &str, previous_metadata: Option<&PackageMetadata>) { @@ -685,6 +683,14 @@ async fn remove_dir_all_if_exists(path: &AbsolutePathBuf) -> Result<(), Error> { } } +async fn remove_file_if_exists(path: &AbsolutePathBuf) -> Result<(), Error> { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + fn install_dir_lock_path(install_dir: &AbsolutePathBuf) -> Result { let parent = install_dir.as_path().parent().ok_or_else(|| { Error::ConfigError( @@ -788,26 +794,93 @@ async fn cleanup_stale_installations(package_name: &str, current_install_id: &st ); } } + + if let Err(error) = cleanup_legacy_installation_contents(package_name, current_install_id).await + { + tracing::warn!( + "Failed to clean legacy global package installation for {package_name}: {error}" + ); + } +} + +async fn cleanup_legacy_installation_contents( + package_name: &str, + current_install_id: &str, +) -> Result<(), Error> { + if !is_nested_install_id(current_install_id) { + return Ok(()); + } + + let package_dir = PackageMetadata::installation_dir_for(package_name, "")?; + let package_json = get_node_modules_dir(&package_dir, package_name).join("package.json"); + if !tokio::fs::try_exists(&package_json).await.unwrap_or(false) { + return Ok(()); + } + + let Some((lock_path, lock_file)) = try_lock_install_dir(&package_dir)? else { + return Ok(()); + }; + // Preserve nested installs while removing npm contents from the legacy prefix. + let mut entries = tokio::fs::read_dir(&package_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { continue }; + if is_nested_install_entry(file_name) { + continue; + } + + if entry.file_type().await?.is_dir() { + tokio::fs::remove_dir_all(entry.path()).await?; + } else { + tokio::fs::remove_file(entry.path()).await?; + } + } + + drop(lock_file); + remove_file_if_exists(&lock_path).await +} + +fn is_nested_install_entry(file_name: &str) -> bool { + is_nested_install_id(file_name) + || file_name.strip_suffix(".lock").is_some_and(is_nested_install_id) } async fn stale_installation_dirs( package_name: &str, current_install_id: &str, ) -> Result, Error> { - let legacy_install_dir = PackageMetadata::installation_dir_for(package_name, "")?; - let Some(parent) = legacy_install_dir.as_path().parent() else { - return Ok(Vec::new()); - }; - if !tokio::fs::try_exists(parent).await.unwrap_or(false) { - return Ok(Vec::new()); + let package_dir = PackageMetadata::installation_dir_for(package_name, "")?; + let mut stale_dirs = Vec::new(); + // Current installs are UUID children of the package directory. + if tokio::fs::try_exists(&package_dir).await.unwrap_or(false) { + let mut entries = tokio::fs::read_dir(&package_dir).await?; + while let Some(entry) = entries.next_entry().await? { + if !entry.file_type().await?.is_dir() { + continue; + } + + let file_name = entry.file_name(); + let Some(install_id) = file_name.to_str().filter(|name| is_nested_install_id(name)) + else { + continue; + }; + if install_id != current_install_id + && let Some(path) = AbsolutePathBuf::new(entry.path()) + { + stale_dirs.push(path); + } + } } - let Some(base_name) = legacy_install_dir.as_path().file_name().and_then(|name| name.to_str()) - else { - return Ok(Vec::new()); + let Some(parent) = package_dir.as_path().parent() else { return Ok(stale_dirs) }; + let Some(base_name) = package_dir.as_path().file_name().and_then(|name| name.to_str()) else { + return Ok(stale_dirs); }; + if !tokio::fs::try_exists(parent).await.unwrap_or(false) { + return Ok(stale_dirs); + } - let mut stale_dirs = Vec::new(); + // Vite+ 0.2.2 through 0.2.5 stored immutable installs as `#` siblings. let mut entries = tokio::fs::read_dir(parent).await?; while let Some(entry) = entries.next_entry().await? { if !entry.file_type().await?.is_dir() { @@ -818,7 +891,7 @@ async fn stale_installation_dirs( let Some(file_name) = file_name.to_str() else { continue; }; - let Some(install_id) = stale_install_id(base_name, file_name) else { + let Some(install_id) = legacy_install_id(base_name, file_name) else { continue; }; if install_id == current_install_id { @@ -832,13 +905,9 @@ async fn stale_installation_dirs( Ok(stale_dirs) } -fn stale_install_id<'a>(base_name: &str, file_name: &'a str) -> Option<&'a str> { - if file_name == base_name { - return Some(""); - } - +fn legacy_install_id<'a>(base_name: &str, file_name: &'a str) -> Option<&'a str> { let install_id = file_name.strip_prefix(base_name)?; - if is_install_id(install_id) { Some(install_id) } else { None } + if is_legacy_install_id(install_id) { Some(install_id) } else { None } } async fn stale_bin_names_for_package( @@ -1478,13 +1547,47 @@ mod tests { } } + #[tokio::test] + async fn test_cleanup_stale_installations_supports_all_layouts() { + 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 package_name = "@scope/test-package"; + let current_id = "123e4567-e89b-42d3-a456-426614174000"; + let stale_id = "987e6543-e21b-42d3-a456-426614174000"; + let legacy_id = "#2753e02c-9319-456f-bc0f-23d6d7b6fba5"; + let package_dir = PackageMetadata::installation_dir_for(package_name, "").unwrap(); + let current_dir = PackageMetadata::installation_dir_for(package_name, current_id).unwrap(); + let stale_dir = PackageMetadata::installation_dir_for(package_name, stale_id).unwrap(); + let legacy_dir = PackageMetadata::installation_dir_for(package_name, legacy_id).unwrap(); + + tokio::fs::create_dir_all(¤t_dir).await.unwrap(); + tokio::fs::create_dir_all(&stale_dir).await.unwrap(); + tokio::fs::create_dir_all(&legacy_dir).await.unwrap(); + tokio::fs::write(install_dir_lock_path(¤t_dir).unwrap(), "").await.unwrap(); + let legacy_package_json = + get_node_modules_dir(&package_dir, package_name).join("package.json"); + tokio::fs::create_dir_all(legacy_package_json.parent().unwrap()).await.unwrap(); + tokio::fs::write(&legacy_package_json, "{}").await.unwrap(); + + cleanup_stale_installations(package_name, current_id).await; + + assert!(current_dir.as_path().is_dir()); + assert!(install_dir_lock_path(¤t_dir).unwrap().as_path().is_file()); + assert!(!stale_dir.as_path().exists()); + assert!(!legacy_dir.as_path().exists()); + assert!(!legacy_package_json.as_path().exists()); + assert!(!install_dir_lock_path(&package_dir).unwrap().as_path().exists()); + } + #[test] - fn test_new_install_id_has_fixed_reserved_shape() { + fn test_new_install_id_is_uuid() { 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)); + assert!(is_nested_install_id(&install_id)); } #[test] From 8e9eac3bd6c2628ed90b99d1edaafe7e89c9e522 Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 23 Jul 2026 10:53:11 +0800 Subject: [PATCH 2/6] test(global): cover dynamic imports from managed packages --- .../global_install_dynamic_import/bin.js | 7 +++++++ .../global_install_dynamic_import/loaded.js | 1 + .../package.json | 8 ++++++++ .../snapshots.toml | 11 +++++++++++ .../global_install_dynamic_import.md | 19 +++++++++++++++++++ 5 files changed, 46 insertions(+) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/loaded.js create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots/global_install_dynamic_import.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js new file mode 100644 index 0000000000..d38ffe1bb1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const loadedModule = join(dirname(fileURLToPath(import.meta.url)), 'loaded.js'); +await import(loadedModule); diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/loaded.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/loaded.js new file mode 100644 index 0000000000..16018e0981 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/loaded.js @@ -0,0 +1 @@ +console.log('global dynamic import loaded'); diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/package.json new file mode 100644 index 0000000000..c9dc8db6e1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/package.json @@ -0,0 +1,8 @@ +{ + "name": "global-install-dynamic-import", + "version": "0.0.0", + "type": "module", + "bin": { + "global-install-dynamic-import": "./bin.js" + } +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml new file mode 100644 index 0000000000..b0771dd9c3 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml @@ -0,0 +1,11 @@ +[[case]] +name = "global_install_dynamic_import" +vp = "global" +comment = "Regression test for #2220: globally installed packages can dynamically import an absolute path inside their installation." +steps = [ + { argv = ["vp", "install", "-g", "."], continue-on-failure = true }, + { argv = ["global-install-dynamic-import"], continue-on-failure = true }, +] +after = [ + { argv = ["vp", "remove", "-g", "global-install-dynamic-import"], continue-on-failure = true }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots/global_install_dynamic_import.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots/global_install_dynamic_import.md new file mode 100644 index 0000000000..2ba806fd5d --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots/global_install_dynamic_import.md @@ -0,0 +1,19 @@ +# global_install_dynamic_import + +Regression test for #2220: globally installed packages can dynamically import an absolute path inside their installation. + +## `vp install -g .` + +``` +VITE+ - The Unified Toolchain for the Web + +info: Installing 1 global package with Node.js +✓ Installed global-install-dynamic-import 0.0.0 + Bins: global-install-dynamic-import +``` + +## `global-install-dynamic-import` + +``` +global dynamic import loaded +``` From 92ef65d85864a2b085fa5057f18c54de7a7ce620 Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 23 Jul 2026 12:10:57 +0800 Subject: [PATCH 3/6] test(global): make dynamic import fixture cross-platform --- .../fixtures/global_install_dynamic_import/bin.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js index d38ffe1bb1..79c7ab2f12 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js @@ -1,7 +1,9 @@ #!/usr/bin/env node import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; const loadedModule = join(dirname(fileURLToPath(import.meta.url)), 'loaded.js'); -await import(loadedModule); +// Keep `#` unescaped to reproduce the old install layout's URL-fragment failure. +const loadedModuleUrl = pathToFileURL(loadedModule).href.replaceAll('%23', '#'); +await import(loadedModuleUrl); From 4b4a0ce8d00a6e333a0ddd7921494035c4a1e702 Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 23 Jul 2026 17:39:07 +0800 Subject: [PATCH 4/6] wip --- .../fixtures/global_install_dynamic_import/bin.js | 0 .../fixtures/global_install_dynamic_import/snapshots.toml | 4 ++-- crates/vite_global_cli/src/commands/env/package_metadata.rs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) mode change 100644 => 100755 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/bin.js old mode 100644 new mode 100755 diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml index b0771dd9c3..402dcece03 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/global_install_dynamic_import/snapshots.toml @@ -3,8 +3,8 @@ name = "global_install_dynamic_import" vp = "global" comment = "Regression test for #2220: globally installed packages can dynamically import an absolute path inside their installation." steps = [ - { argv = ["vp", "install", "-g", "."], continue-on-failure = true }, - { argv = ["global-install-dynamic-import"], continue-on-failure = true }, + { argv = ["vp", "install", "-g", "."] }, + { argv = ["global-install-dynamic-import"] }, ] after = [ { argv = ["vp", "remove", "-g", "global-install-dynamic-import"], continue-on-failure = true }, 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 386b520169..35c8c7da10 100644 --- a/crates/vite_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vite_global_cli/src/commands/env/package_metadata.rs @@ -10,6 +10,7 @@ use vite_path::AbsolutePathBuf; use super::config::get_packages_dir; use crate::error::Error; +// This is legacy, for old Vite+ version's compatibility const LEGACY_INSTALL_ID_PREFIX: char = '#'; const INSTALL_ID_LENGTH: usize = 36; From 27fc02f7b5398df3b3da730783ab1288951d8d2c Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 23 Jul 2026 17:47:52 +0800 Subject: [PATCH 5/6] handling --- .../src/commands/global/install.rs | 77 ++++++++++++++++++- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index 670eb6139e..a1b573322a 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -820,8 +820,16 @@ async fn cleanup_legacy_installation_contents( let Some((lock_path, lock_file)) = try_lock_install_dir(&package_dir)? else { return Ok(()); }; + + remove_legacy_installation_contents(&package_dir).await?; + + drop(lock_file); + remove_file_if_exists(&lock_path).await +} + +async fn remove_legacy_installation_contents(package_dir: &AbsolutePathBuf) -> Result<(), Error> { // Preserve nested installs while removing npm contents from the legacy prefix. - let mut entries = tokio::fs::read_dir(&package_dir).await?; + let mut entries = tokio::fs::read_dir(package_dir).await?; while let Some(entry) = entries.next_entry().await? { let file_name = entry.file_name(); let Some(file_name) = file_name.to_str() else { continue }; @@ -836,8 +844,18 @@ async fn cleanup_legacy_installation_contents( } } - drop(lock_file); - remove_file_if_exists(&lock_path).await + match tokio::fs::remove_dir(package_dir).await { + Ok(()) => Ok(()), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => + { + Ok(()) + } + Err(error) => Err(error.into()), + } } fn is_nested_install_entry(file_name: &str) -> bool { @@ -998,7 +1016,11 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { 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?; + if metadata.as_ref().is_none_or(|metadata| metadata.install_id.is_empty()) { + remove_legacy_installation_contents(&package_dir).await?; + } else { + tokio::fs::remove_dir_all(&package_dir).await?; + } } // Remove metadata file @@ -1471,6 +1493,53 @@ mod tests { assert!(!package_dir.as_path().exists(), "identified package directory should be removed"); } + #[tokio::test] + async fn test_uninstall_legacy_install_preserves_in_progress_nested_install() { + 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 package_name = "test-package"; + + let metadata = PackageMetadata::new( + package_name.to_string(), + "1.0.0".to_string(), + "20.0.0".to_string(), + None, + Vec::new(), + HashSet::new(), + "npm".to_string(), + ); + metadata.save().await.unwrap(); + + let legacy_dir = metadata.installation_dir().unwrap(); + let legacy_package_json = + get_node_modules_dir(&legacy_dir, package_name).join("package.json"); + tokio::fs::create_dir_all(legacy_package_json.parent().unwrap()).await.unwrap(); + tokio::fs::write(&legacy_package_json, "{}").await.unwrap(); + + // Model uninstall starting after npm populated the replacement but before metadata changed. + let replacement_dir = PackageMetadata::installation_dir_for( + package_name, + "123e4567-e89b-42d3-a456-426614174000", + ) + .unwrap(); + let replacement_package_json = + get_node_modules_dir(&replacement_dir, package_name).join("package.json"); + let replacement_lock = lock_install_dir(&replacement_dir).unwrap(); + tokio::fs::create_dir_all(replacement_package_json.parent().unwrap()).await.unwrap(); + tokio::fs::write(&replacement_package_json, "{}").await.unwrap(); + + uninstall(package_name, false).await.unwrap(); + + assert!(!legacy_package_json.as_path().exists()); + assert!(replacement_package_json.as_path().exists()); + assert!(install_dir_lock_path(&replacement_dir).unwrap().as_path().exists()); + drop(replacement_lock); + } + #[tokio::test] #[cfg_attr(windows, serial_test::serial)] async fn test_restore_previous_install_state_removes_partial_new_bins() { From 898d153f6629558d89adee20fce56011db774fa1 Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 23 Jul 2026 18:27:24 +0800 Subject: [PATCH 6/6] simplify --- .../src/commands/global/install.rs | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index a1b573322a..f40217ab51 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -683,14 +683,6 @@ async fn remove_dir_all_if_exists(path: &AbsolutePathBuf) -> Result<(), Error> { } } -async fn remove_file_if_exists(path: &AbsolutePathBuf) -> Result<(), Error> { - match tokio::fs::remove_file(path).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - fn install_dir_lock_path(install_dir: &AbsolutePathBuf) -> Result { let parent = install_dir.as_path().parent().ok_or_else(|| { Error::ConfigError( @@ -795,36 +787,36 @@ async fn cleanup_stale_installations(package_name: &str, current_install_id: &st } } - if let Err(error) = cleanup_legacy_installation_contents(package_name, current_install_id).await - { - tracing::warn!( - "Failed to clean legacy global package installation for {package_name}: {error}" - ); - } -} - -async fn cleanup_legacy_installation_contents( - package_name: &str, - current_install_id: &str, -) -> Result<(), Error> { if !is_nested_install_id(current_install_id) { - return Ok(()); + return; } - let package_dir = PackageMetadata::installation_dir_for(package_name, "")?; - let package_json = get_node_modules_dir(&package_dir, package_name).join("package.json"); - if !tokio::fs::try_exists(&package_json).await.unwrap_or(false) { - return Ok(()); - } + let result: Result<(), Error> = async { + let package_dir = PackageMetadata::installation_dir_for(package_name, "")?; + let package_json = get_node_modules_dir(&package_dir, package_name).join("package.json"); + if !tokio::fs::try_exists(&package_json).await.unwrap_or(false) { + return Ok(()); + } - let Some((lock_path, lock_file)) = try_lock_install_dir(&package_dir)? else { - return Ok(()); - }; + let Some((lock_path, lock_file)) = try_lock_install_dir(&package_dir)? else { + return Ok(()); + }; - remove_legacy_installation_contents(&package_dir).await?; + remove_legacy_installation_contents(&package_dir).await?; - drop(lock_file); - remove_file_if_exists(&lock_path).await + drop(lock_file); + match tokio::fs::remove_file(&lock_path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + .await; + if let Err(error) = result { + tracing::warn!( + "Failed to clean legacy global package installation for {package_name}: {error}" + ); + } } async fn remove_legacy_installation_contents(package_dir: &AbsolutePathBuf) -> Result<(), Error> { @@ -833,7 +825,9 @@ async fn remove_legacy_installation_contents(package_dir: &AbsolutePathBuf) -> R while let Some(entry) = entries.next_entry().await? { let file_name = entry.file_name(); let Some(file_name) = file_name.to_str() else { continue }; - if is_nested_install_entry(file_name) { + if is_nested_install_id(file_name) + || file_name.strip_suffix(".lock").is_some_and(is_nested_install_id) + { continue; } @@ -858,11 +852,6 @@ async fn remove_legacy_installation_contents(package_dir: &AbsolutePathBuf) -> R } } -fn is_nested_install_entry(file_name: &str) -> bool { - is_nested_install_id(file_name) - || file_name.strip_suffix(".lock").is_some_and(is_nested_install_id) -} - async fn stale_installation_dirs( package_name: &str, current_install_id: &str, @@ -909,7 +898,9 @@ async fn stale_installation_dirs( let Some(file_name) = file_name.to_str() else { continue; }; - let Some(install_id) = legacy_install_id(base_name, file_name) else { + let Some(install_id) = + file_name.strip_prefix(base_name).filter(|install_id| is_legacy_install_id(install_id)) + else { continue; }; if install_id == current_install_id { @@ -922,12 +913,6 @@ async fn stale_installation_dirs( Ok(stale_dirs) } - -fn legacy_install_id<'a>(base_name: &str, file_name: &'a str) -> Option<&'a str> { - let install_id = file_name.strip_prefix(base_name)?; - if is_legacy_install_id(install_id) { Some(install_id) } else { None } -} - async fn stale_bin_names_for_package( previous_metadata: Option<&PackageMetadata>, package_name: &str,