diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 15cad6342789f..4f967b7b92f98 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -9,7 +9,8 @@ use clap::{Args, Parser, Subcommand}; use distribution_types::{FlatIndexLocation, IndexUrl}; use uv_cache::CacheArgs; use uv_configuration::{ - ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, TargetTriple, + ConfigSettingEntry, IndexStrategy, KeyringProviderType, PackageNameSpecifier, + TargetTriple, }; use uv_normalize::{ExtraName, PackageName}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PreReleaseMode, ResolutionMode}; @@ -1889,6 +1890,12 @@ pub struct ToolInstallArgs { #[command(flatten)] pub refresh: RefreshArgs, + /// Force installation of the tool. + /// + /// Will replace any existing entrypoints with the same name in the executable directory. + #[arg(long)] + pub force: bool, + /// The Python interpreter to use to build the run environment. /// /// By default, `uv` uses the virtual environment in the current working directory or any parent diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 4dafd0ccc33d1..088de34610a94 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::fmt::Write; use std::str::FromStr; @@ -29,6 +30,7 @@ pub(crate) async fn install( python: Option, from: Option, with: Vec, + force: bool, settings: ResolverInstallerSettings, _isolated: bool, preview: PreviewMode, @@ -45,10 +47,14 @@ pub(crate) async fn install( let installed_tools = InstalledTools::from_settings()?; - // TODO(zanieb): Allow replacing an existing tool + // TODO(zanieb): Automatically replace an existing tool if the request differs if installed_tools.find_tool_entry(&name)?.is_some() { - writeln!(printer.stderr(), "Tool `{name}` is already installed.")?; - return Ok(ExitStatus::Failure); + if force { + debug!("Replacing existing tool due to `--force` flag."); + } else { + writeln!(printer.stderr(), "Tool `{name}` is already installed.")?; + return Ok(ExitStatus::Failure); + } } // TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface? @@ -112,6 +118,8 @@ pub(crate) async fn install( fs_err::create_dir_all(&executable_directory) .context("Failed to create executable directory")?; + debug!("Installing into {}", executable_directory.user_display()); + let entrypoints = entrypoint_paths( &environment, installed_dist.name(), @@ -119,11 +127,50 @@ pub(crate) async fn install( None, )?; + // Determine the entry points targets + let targets = entrypoints + .into_iter() + .map(|(name, path)| { + let target = executable_directory.join( + path.file_name() + .map(std::borrow::ToOwned::to_owned) + .unwrap_or_else(|| OsString::from(name.clone())), + ); + (name, path, target) + }) + .collect::>(); + + // Check if they exist, before installing + let mut existing_targets = targets + .iter() + .filter(|(_, _, target)| target.exists()) + .peekable(); + if force { + for (name, _, target) in existing_targets { + debug!("Removing existing install of `{name}`"); + fs_err::remove_file(target)?; + } + } else if existing_targets.peek().is_some() { + let existing_targets = existing_targets + // SAFETY: We know the target has a filename because we just constructed it above + .map(|(_, _, target)| target.file_name().unwrap().to_string_lossy()) + .collect::>(); + let (s, exists) = if existing_targets.len() == 1 { + ("", "exists") + } else { + ("s", "exist") + }; + bail!( + "Entry point{s} for tool already {exists}, use `--force` to overwrite: {}", + existing_targets.join(", ") + ) + } + // TODO(zanieb): Handle the case where there are no entrypoints // TODO(zanieb): Better error when an entry point exists, check if they all are don't exist first - for (name, path) in entrypoints { - let target = executable_directory.join(path.file_name().unwrap()); - debug!("Installing {name} to {}", target.user_display()); + // let existing = entrypoints.iter().map(|(name, file_name)| + for (name, path, target) in targets { + debug!("Installing `{name}`"); if cfg!(unix) { fs_err::os::unix::fs::symlink(path, target)?; } else { diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index f5bc602af38a8..8d997d038f352 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -812,6 +812,7 @@ async fn run() -> Result { args.python, args.from, args.with, + args.force, args.settings, globals.isolated, globals.preview, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 7a16d9ea7e91d..5e8733f789086 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -241,6 +241,7 @@ pub(crate) struct ToolInstallSettings { pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, + pub(crate) force: bool, } impl ToolInstallSettings { @@ -252,6 +253,7 @@ impl ToolInstallSettings { from, with, installer, + force, build, refresh, python, @@ -262,6 +264,7 @@ impl ToolInstallSettings { from, with, python, + force, refresh: Refresh::from(refresh), settings: ResolverInstallerSettings::combine( resolver_installer_options(installer, build),