diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index 45e955a797..bd75267b60 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/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index aa8a6639e8..a7069f852f 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -1,7 +1,7 @@ //! Global package installation handling. use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, io::{IsTerminal, Read, Write}, process::Stdio, time::Duration, @@ -21,7 +21,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, @@ -33,7 +33,13 @@ use crate::{ struct Package<'a> { spec: &'a str, - staging_dir: Option, + install: Option, +} + +struct InstalledPackage { + installed_version: String, + bin_names: Vec, + js_bins: HashSet, } fn package_error(package_name: &str, error: impl Into) -> (Option, Error) { @@ -110,7 +116,7 @@ pub async fn install( Ok(result) => result, Err(error) => return Err((Some(package_spec.clone()), error)), }; - packages.insert(package_name, Package { spec: package_spec, staging_dir: None }); + packages.insert(package_name, Package { spec: package_spec, install: None }); } let packages_count = packages.len(); @@ -140,8 +146,10 @@ pub async fn install( let mut package_names = package_names.iter(); let mut installs = FuturesUnordered::new(); + let mut first_error = None; + let mut stop_scheduling = false; loop { - while installs.len() < concurrency { + while !stop_scheduling && installs.len() < concurrency { let Some(package_name) = package_names.next() else { break }; let package = packages.get(package_name).unwrap(); @@ -158,146 +166,108 @@ pub async fn install( } match installs.next().await { - Some((package_name, Ok(staging_dir))) => { + Some((package_name, Ok(installed_package))) => { progress.inc(1); - packages.get_mut(&package_name).unwrap().staging_dir = Some(staging_dir) + packages.get_mut(&package_name).unwrap().install = Some(installed_package) } Some((package_name, Err(error))) => { - // Cancel all tasks - installs.clear(); - progress.finish_and_clear(); - - // Clear the installed packages - packages.iter().for_each(|(_, package)| { - if let Some(staging_dir) = package.staging_dir.as_ref() { - let _ = std::fs::remove_dir_all(staging_dir); - } - }); - - return Err((Some(package_name), error)); + stop_scheduling = true; + if first_error.is_none() { + first_error = Some((Some(package_name), error)); + } } None => break, } } progress.finish_and_clear(); - // 4. Check the installed packages, move to final location and create shims. - let mut result = Ok(()); - for (index, (package_name, Package { spec: _, staging_dir })) in - packages.into_iter().enumerate() - { - // Packages must have staging dir - let staging_dir = staging_dir.unwrap(); - - if result.is_err() { - let _ = std::fs::remove_dir_all(&staging_dir); - continue; - } - - let node_modules_dir = get_node_modules_dir(&staging_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) { - let _ = tokio::fs::remove_dir_all(&staging_dir).await; - result = Err(( - Some(package_name.clone()), - Error::ConfigError( - format!( - "Package was not installed correctly, package.json not found at {}", - package_json_path.as_path().display() - ) - .into(), - ), - )); + // 4. Check the installed packages and create shims. + let mut bin_owners = HashMap::::new(); + for (index, (package_name, Package { spec: _, install })) in packages.into_iter().enumerate() { + let Some(InstalledPackage { installed_version, bin_names, js_bins }) = install else { continue; - } - - let package_json_content = tokio::fs::read_to_string(&package_json_path) - .await - .map_err(|error| package_error(&package_name, error))?; - let package_json: serde_json::Value = match serde_json::from_str(&package_json_content) { - Ok(package_json) => package_json, - Err(error) => { - let _ = tokio::fs::remove_dir_all(&staging_dir).await; - result = Err(( - Some(package_name.clone()), - Error::ConfigError(format!("Failed to parse package.json: {}", error).into()), - )); - continue; - } }; - let installed_version = package_json["version"].as_str().unwrap_or("unknown").to_string(); - let binary_infos = extract_binaries(&package_json); - - let mut bin_names = Vec::new(); - let mut js_bins = HashSet::new(); - for info in &binary_infos { - bin_names.push(info.name.clone()); - let binary_path = node_modules_dir.join(&info.path); - if is_javascript_binary(&binary_path) { - js_bins.insert(info.name.clone()); - } - } - let mut conflicts = Vec::<(String, String)>::new(); + let mut finalize_blocked = false; for bin_name in &bin_names { - if let Some(config) = BinConfig::load(bin_name) - .await - .map_err(|error| package_error(&package_name, error))? + if let Some(owner) = bin_owners.get(bin_name) + && owner != &package_name { - if config.package != package_name { - conflicts.push((bin_name.clone(), config.package.clone())); + conflicts.push((bin_name.clone(), owner.clone())); + continue; + } + + match BinConfig::load(bin_name).await { + Ok(Some(config)) => { + if config.package != package_name { + conflicts.push((bin_name.clone(), config.package.clone())); + } + } + Ok(None) => {} + Err(error) => { + let _ = cleanup_installed_package(&package_name).await; + if first_error.is_none() { + first_error = Some(package_error(&package_name, error)); + } + finalize_blocked = true; + break; } } } + if finalize_blocked { + continue; + } if !conflicts.is_empty() { if force { let packages_to_remove: HashSet<_> = conflicts.iter().map(|(_, pkg)| pkg.clone()).collect(); + let mut uninstall_failed = false; for pkg in packages_to_remove { output::raw(&format!( "Uninstalling {} (conflicts with {})...", pkg, package_name )); - Box::pin(uninstall(&pkg, false)) - .await - .map_err(|error| package_error(&package_name, error))?; + if let Err(error) = Box::pin(uninstall(&pkg, false)).await { + let _ = cleanup_installed_package(&package_name).await; + if first_error.is_none() { + first_error = Some(package_error(&package_name, error)); + } + uninstall_failed = true; + break; + } + } + if uninstall_failed { + continue; } } else { - let _ = tokio::fs::remove_dir_all(&staging_dir).await; - result = Err(( - Some(package_name.clone()), - Error::BinaryConflict { - bin_name: conflicts[0].0.clone(), - existing_package: conflicts[0].1.clone(), - new_package: package_name.clone(), - }, - )); + let _ = cleanup_installed_package(&package_name).await; + if first_error.is_none() { + first_error = Some(( + Some(package_name.clone()), + Error::BinaryConflict { + bin_name: conflicts[0].0.clone(), + existing_package: conflicts[0].1.clone(), + new_package: package_name.clone(), + }, + )); + } continue; } } - let packages_dir = - get_packages_dir().map_err(|error| package_error(&package_name, error))?; - let final_dir = packages_dir.join(&package_name); - - if tokio::fs::try_exists(&final_dir).await.unwrap_or(false) { - tokio::fs::remove_dir_all(&final_dir) - .await - .map_err(|error| package_error(&package_name, error))?; - } - - if let Some(parent) = final_dir.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|error| package_error(&package_name, error))?; - } - tokio::fs::rename(&staging_dir, &final_dir) - .await - .map_err(|error| package_error(&package_name, error))?; + let bin_dir = match get_bin_dir().map_err(|error| package_error(&package_name, error)) { + Ok(bin_dir) => bin_dir, + Err(error) => { + let _ = cleanup_installed_package(&package_name).await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + }; let metadata = PackageMetadata::new( package_name.clone(), @@ -308,13 +278,28 @@ pub async fn install( js_bins, "npm".to_string(), ); - metadata.save().await.map_err(|error| package_error(&package_name, error))?; + if let Err(error) = + metadata.save().await.map_err(|error| package_error(&package_name, error)) + { + let _ = cleanup_installed_package(&package_name).await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } - let bin_dir = get_bin_dir().map_err(|error| package_error(&package_name, error))?; + let mut finalized = true; for bin_name in &bin_names { - create_package_shim(&bin_dir, bin_name, &package_name) + if let Err(error) = create_package_shim(&bin_dir, bin_name, &package_name) .await - .map_err(|error| package_error(&package_name, error))?; + .map_err(|error| package_error(&package_name, error)) + { + finalized = false; + if first_error.is_none() { + first_error = Some(error); + } + break; + } let bin_config = BinConfig::new( bin_name.clone(), @@ -322,7 +307,21 @@ pub async fn install( installed_version.clone(), node_version.clone(), ); - bin_config.save().await.map_err(|error| package_error(&package_name, error))?; + if let Err(error) = + bin_config.save().await.map_err(|error| package_error(&package_name, error)) + { + finalized = false; + if first_error.is_none() { + first_error = Some(error); + } + break; + } + bin_owners.insert(bin_name.clone(), package_name.clone()); + } + + if !finalized { + let _ = cleanup_installed_package(&package_name).await; + continue; } output::success(&format!( @@ -345,32 +344,28 @@ pub async fn install( } } - result + if let Some(error) = first_error { Err(error) } else { Ok(()) } } -/// Install one package on the stage directory -/// Return (package_name, installed_version, bin_names) +/// Install one package into its final prefix and write package metadata. async fn install_one( package_name: &str, package_spec: &str, npm_path: &AbsolutePathBuf, node_bin_dir: &AbsolutePathBuf, -) -> Result { - // 1. Create staging directory - let tmp_dir = get_tmp_dir()?; - let staging_dir = tmp_dir.join("packages").join(package_name); - - // Clean up any previous failed install - if tokio::fs::try_exists(&staging_dir).await.unwrap_or(false) { - tokio::fs::remove_dir_all(&staging_dir).await?; +) -> Result { + let packages_dir = get_packages_dir()?; + let package_dir = packages_dir.join(package_name); + if let Some(parent) = package_dir.parent() { + tokio::fs::create_dir_all(parent).await?; } - tokio::fs::create_dir_all(&staging_dir).await?; + tokio::fs::create_dir_all(&package_dir).await?; - // 4. Run npm install with prefix set to staging directory + // 2. Run npm install with prefix set to the final package 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", staging_dir.as_path()) + .env("npm_config_prefix", package_dir.as_path()) .env("PATH", format_path_prepended(node_bin_dir.as_path())) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -379,9 +374,6 @@ async fn install_one( .await?; if !output.status.success() { - // Clean up staging directory - let _ = tokio::fs::remove_dir_all(&staging_dir).await; - // Show captured output to help debug the failure let _ = std::io::stdout().write_all(&output.stdout); let _ = std::io::stderr().write_all(&output.stderr); @@ -390,7 +382,75 @@ async fn install_one( )); } - Ok(staging_dir) + let node_modules_dir = get_node_modules_dir(&package_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) { + let _ = cleanup_installed_package(package_name).await; + return Err(Error::ConfigError( + format!( + "Package was not installed correctly, package.json not found at {}", + package_json_path.as_path().display() + ) + .into(), + )); + } + + let package_json_content = match tokio::fs::read_to_string(&package_json_path).await { + Ok(content) => content, + Err(error) => { + let _ = cleanup_installed_package(package_name).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) => { + let _ = cleanup_installed_package(package_name).await; + return Err(Error::ConfigError( + format!("Failed to parse package.json: {error}").into(), + )); + } + }; + + let installed_version = package_json["version"].as_str().unwrap_or("unknown").to_string(); + let binary_infos = extract_binaries(&package_json); + + let mut bin_names = Vec::new(); + let mut js_bins = HashSet::new(); + for info in &binary_infos { + bin_names.push(info.name.clone()); + let binary_path = node_modules_dir.join(&info.path); + if is_javascript_binary(&binary_path) { + js_bins.insert(info.name.clone()); + } + } + + Ok(InstalledPackage { installed_version, bin_names, js_bins }) +} + +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(()) } /// Uninstall a global package. diff --git a/packages/cli/snap-tests-global/command-env-install-fail/bin.js b/packages/cli/snap-tests-global/command-env-install-fail/bin.js new file mode 100755 index 0000000000..09a50e9911 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-install-fail/bin.js @@ -0,0 +1 @@ +console.log('The package is installed successfully'); diff --git a/packages/cli/snap-tests-global/command-env-install-fail/package.json b/packages/cli/snap-tests-global/command-env-install-fail/package.json new file mode 100644 index 0000000000..723334d753 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-install-fail/package.json @@ -0,0 +1,7 @@ +{ + "name": "install-fail-local-package", + "version": "0.0.0", + "bin": { + "install-fail-local-package": "./bin.js" + } +} diff --git a/packages/cli/snap-tests-global/command-env-install-fail/snap.txt b/packages/cli/snap-tests-global/command-env-install-fail/snap.txt index 226d6a6775..8271b325d5 100644 --- a/packages/cli/snap-tests-global/command-env-install-fail/snap.txt +++ b/packages/cli/snap-tests-global/command-env-install-fail/snap.txt @@ -1,5 +1,5 @@ -[1]> vp install -g voidzero-nonexistent-pkg-xyz-12345 # Install non-existent package -info: Installing 1 global package with Node.js +[1]> vp install -g . voidzero-nonexistent-pkg-xyz-12345 +info: Installing 2 global packages with Node.js npm error code E404 npm error 404 Not Found - GET https://registry./voidzero-nonexistent-pkg-xyz-12345 - Not found npm error 404 @@ -8,4 +8,10 @@ npm error 404 npm error 404 Note that you can also install from a npm error 404 tarball, folder, http url, or git url. npm error A complete log of this run can be found in: /.npm/_logs/-debug.log +✓ Installed install-fail-local-package + Bins: install-fail-local-package + error: Failed to install voidzero-nonexistent-pkg-xyz-12345: Configuration error: npm install failed with exit code: Some(1) + +> install-fail-local-package +The package is installed successfully diff --git a/packages/cli/snap-tests-global/command-env-install-fail/steps.json b/packages/cli/snap-tests-global/command-env-install-fail/steps.json index 08bb3771c3..8d71c58ce7 100644 --- a/packages/cli/snap-tests-global/command-env-install-fail/steps.json +++ b/packages/cli/snap-tests-global/command-env-install-fail/steps.json @@ -1,5 +1,6 @@ { "env": {}, "ignoredPlatforms": ["win32"], - "commands": ["vp install -g voidzero-nonexistent-pkg-xyz-12345 # Install non-existent package"] + "commands": ["vp install -g . voidzero-nonexistent-pkg-xyz-12345", "install-fail-local-package"], + "after": ["vp remove -g install-fail-local-package"] } diff --git a/packages/cli/snap-tests-global/command-env-install-global-local/bin.js b/packages/cli/snap-tests-global/command-env-install-global-local/bin.js old mode 100644 new mode 100755 diff --git a/packages/cli/snap-tests-global/command-env-install-global-local/snap.txt b/packages/cli/snap-tests-global/command-env-install-global-local/snap.txt index 45da4b38d4..f188079159 100644 --- a/packages/cli/snap-tests-global/command-env-install-global-local/snap.txt +++ b/packages/cli/snap-tests-global/command-env-install-global-local/snap.txt @@ -3,11 +3,17 @@ info: Installing 1 global package with Node.js ✓ Installed just-a-normal-package Bins: just-a-normal-package +> just-a-normal-package +The package is installed successfully + > vp install -g ./another-package.tgz info: Installing 1 global package with Node.js ✓ Installed another-normal-package Bins: another-normal-package +> another-normal-package +Another package is installed successfully + > vp list -g just-a-normal-package Package Node version Binaries --- --- --- diff --git a/packages/cli/snap-tests-global/command-env-install-global-local/steps.json b/packages/cli/snap-tests-global/command-env-install-global-local/steps.json index 4377b56a05..a4a56dae8b 100644 --- a/packages/cli/snap-tests-global/command-env-install-global-local/steps.json +++ b/packages/cli/snap-tests-global/command-env-install-global-local/steps.json @@ -1,7 +1,9 @@ { "commands": [ "vp install -g .", + "just-a-normal-package", "vp install -g ./another-package.tgz", + "another-normal-package", "vp list -g just-a-normal-package", "vp list -g another-normal-package" ],