diff --git a/Cargo.lock b/Cargo.lock index 47729af65..8f7ad9f75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4456,6 +4456,7 @@ dependencies = [ "flate2", "fs-err", "futures", + "home", "ignore", "indexmap", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index 4f7ff7b96..2142a58cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ fs2 = { version = "0.4.3" } futures = { version = "0.3.30" } glob = { version = "0.3.1" } hex = { version = "0.4.3" } +home = { version = "0.5.9" } html-escape = { version = "0.2.13" } http = { version = "1.1.0" } indexmap = { version = "2.2.5" } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 2035b8a4e..4fed29ac3 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2041,6 +2041,9 @@ pub enum ToolCommand { List(ToolListArgs), /// Uninstall a tool. Uninstall(ToolUninstallArgs), + /// Ensure that the tool executable directory is on `PATH`. + #[command(alias = "ensurepath")] + UpdateShell, /// Show the tools directory. Dir, } diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index 51605aced..f86821634 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -50,6 +50,7 @@ clap = { workspace = true, features = ["derive", "string", "wrap_help"] } flate2 = { workspace = true, default-features = false } fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } +home = { workspace = true } indexmap = { workspace = true } indicatif = { workspace = true } itertools = { workspace = true } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 3ba1b75ba..9deb21c6d 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -38,6 +38,7 @@ pub(crate) use tool::list::list as tool_list; pub(crate) use tool::run::run as tool_run; pub(crate) use tool::run::ToolRunCommand; pub(crate) use tool::uninstall::uninstall as tool_uninstall; +pub(crate) use tool::update_shell::update_shell as tool_update_shell; use uv_cache::Cache; use uv_fs::Simplified; use uv_git::GitResolver; diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 8dd5a17c9..daacc3921 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -265,7 +265,6 @@ pub(crate) async fn install( }; // Find a suitable path to install into - // TODO(zanieb): Warn if this directory is not on the PATH let executable_directory = find_executable_directory()?; fs_err::create_dir_all(&executable_directory) .context("Failed to create executable directory")?; @@ -375,74 +374,36 @@ pub(crate) async fn install( installed_tools.add_tool_receipt(&from.name, tool)?; // If the executable directory isn't on the user's PATH, warn. - if !std::env::var_os("PATH") - .as_ref() - .iter() - .flat_map(std::env::split_paths) - .any(|path| same_file::is_same_file(&executable_directory, path).unwrap_or(false)) - { - let dir = executable_directory.simplified_display(); - let export = match Shell::from_env() { - None => None, - Some(Shell::Nushell) => None, - Some(Shell::Bash | Shell::Zsh) => Some(format!( - "export PATH=\"{}:$PATH\"", - backslash_escape(&dir.to_string()), - )), - Some(Shell::Fish) => Some(format!( - "fish_add_path \"{}\"", - backslash_escape(&dir.to_string()), - )), - Some(Shell::Csh) => Some(format!( - "setenv PATH \"{}:$PATH\"", - backslash_escape(&dir.to_string()), - )), - Some(Shell::Powershell) => Some(format!( - "$env:PATH = \"{};$env:PATH\"", - backtick_escape(&dir.to_string()), - )), - Some(Shell::Cmd) => Some(format!( - "set PATH=\"{};%PATH%\"", - backslash_escape(&dir.to_string()), - )), - }; - if let Some(export) = export { - warn_user!( - "`{dir}` is not on your PATH. To use installed tools, run:\n {}", - export.green() - ); + if !Shell::contains_path(&executable_directory) { + if let Some(shell) = Shell::from_env() { + if let Some(command) = shell.prepend_path(&executable_directory) { + if shell.configuration_files().is_empty() { + warn_user!( + "{} is not on your PATH. To use installed tools, run {}.", + executable_directory.simplified_display().cyan(), + command.green() + ); + } else { + warn_user!( + "{} is not on your PATH. To use installed tools, run {} or {}.", + executable_directory.simplified_display().cyan(), + command.green(), + "uv tool update-shell".green() + ); + } + } else { + warn_user!( + "{} is not on your PATH. To use installed tools, add the directory to your PATH.", + executable_directory.simplified_display().cyan(), + ); + } } else { warn_user!( - "`{dir}` is not on your PATH. To use installed tools, add the directory to your PATH", + "{} is not on your PATH. To use installed tools, add the directory to your PATH.", + executable_directory.simplified_display().cyan(), ); } } Ok(ExitStatus::Success) } - -/// Escape a string for use in a shell command by inserting backslashes. -fn backslash_escape(s: &str) -> String { - let mut escaped = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '\\' | '"' => escaped.push('\\'), - _ => {} - } - escaped.push(c); - } - escaped -} - -/// Escape a string for use in a `PowerShell` command by inserting backticks. -fn backtick_escape(s: &str) -> String { - let mut escaped = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '\\' | '"' | '$' => escaped.push('`'), - _ => {} - } - escaped.push(c); - } - escaped -} diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index 24819eaae..0f16b6c76 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -4,3 +4,4 @@ pub(crate) mod install; pub(crate) mod list; pub(crate) mod run; pub(crate) mod uninstall; +pub(crate) mod update_shell; diff --git a/crates/uv/src/commands/tool/update_shell.rs b/crates/uv/src/commands/tool/update_shell.rs new file mode 100644 index 000000000..cb28f29af --- /dev/null +++ b/crates/uv/src/commands/tool/update_shell.rs @@ -0,0 +1,120 @@ +use std::fmt::Write; + +use anyhow::Result; +use owo_colors::OwoColorize; +use tokio::io::AsyncWriteExt; +use tracing::debug; + +use uv_configuration::PreviewMode; +use uv_fs::Simplified; +use uv_tool::find_executable_directory; +use uv_warnings::warn_user_once; + +use crate::commands::ExitStatus; +use crate::printer::Printer; +use crate::shell::Shell; + +/// Ensure that the executable directory is in PATH. +pub(crate) async fn update_shell(preview: PreviewMode, printer: Printer) -> Result { + if preview.is_disabled() { + warn_user_once!("`uv tool update-shell` is experimental and may change without warning."); + } + + let executable_directory = find_executable_directory()?; + debug!( + "Ensuring that the executable directory is in PATH: {}", + executable_directory.simplified_display() + ); + + if Shell::contains_path(&executable_directory) { + writeln!( + printer.stderr(), + "Executable directory {} is already in PATH", + executable_directory.simplified_display().cyan() + )?; + Ok(ExitStatus::Success) + } else { + // Determine the current shell. + let Some(shell) = Shell::from_env() else { + return Err(anyhow::anyhow!("The executable directory {} is not in PATH, but the current shell could not be determined.", executable_directory.simplified_display().cyan())); + }; + + // Look up the configuration files (e.g., `.bashrc`, `.zshrc`) for the shell. + let files = shell.configuration_files(); + if files.is_empty() { + return Err(anyhow::anyhow!("The executable directory {} is not in PATH, but updating {shell} is currently unsupported.", executable_directory.simplified_display().cyan())); + } + + // Prepare the command (e.g., `export PATH="$HOME/.cargo/bin:$PATH"`). + let Some(command) = shell.prepend_path(&executable_directory) else { + return Err(anyhow::anyhow!("The executable directory {} is not in PATH, but the necessary command to update {shell} could not be determined.", executable_directory.simplified_display().cyan())); + }; + + // Update each file, as necessary. + let mut updated = false; + for file in files { + // Search for the command in the file, to avoid redundant updates. + match fs_err::tokio::read_to_string(&file).await { + Ok(contents) => { + if contents.contains(&command) { + debug!( + "Skipping already-updated configuration file: {}", + file.simplified_display() + ); + continue; + } + + // Append the command to the file. + fs_err::tokio::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&file) + .await? + .write_all(format!("{contents}\n# uv\n{command}\n").as_bytes()) + .await?; + + writeln!( + printer.stderr(), + "Updated configuration file: {}", + file.simplified_display().cyan() + )?; + updated = true; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + // Ensure that the directory containing the file exists. + if let Some(parent) = file.parent() { + fs_err::tokio::create_dir_all(&parent).await?; + } + + // Append the command to the file. + fs_err::tokio::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&file) + .await? + .write_all(format!("# uv\n{command}\n").as_bytes()) + .await?; + + writeln!( + printer.stderr(), + "Created configuration file: {}", + file.simplified_display().cyan() + )?; + updated = true; + } + Err(err) => { + return Err(err.into()); + } + } + } + + if updated { + writeln!(printer.stderr(), "Restart your shell to apply changes.")?; + Ok(ExitStatus::Success) + } else { + Err(anyhow::anyhow!("The executable directory {} is not in PATH, but the {shell} configuration files are already up-to-date.", executable_directory.simplified_display().cyan())) + } + } +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ee0a27fb9..44e339470 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -681,6 +681,12 @@ async fn run(cli: Cli) -> Result { commands::tool_uninstall(args.name, globals.preview, printer).await } + Commands::Tool(ToolNamespace { + command: ToolCommand::UpdateShell, + }) => { + commands::tool_update_shell(globals.preview, printer).await?; + Ok(ExitStatus::Success) + } Commands::Tool(ToolNamespace { command: ToolCommand::Dir, }) => { diff --git a/crates/uv/src/shell.rs b/crates/uv/src/shell.rs index 7f6787ecd..de87fd51b 100644 --- a/crates/uv/src/shell.rs +++ b/crates/uv/src/shell.rs @@ -1,4 +1,5 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; +use uv_fs::Simplified; /// Shells for which virtualenv activation scripts are available. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -70,8 +71,100 @@ impl Shell { pub(crate) fn from_shell_path(path: impl AsRef) -> Option { parse_shell_from_path(path.as_ref()) } + + /// Return the configuration files that should be modified to append to a shell's `PATH`. + pub(crate) fn configuration_files(self) -> Vec { + let Some(home_dir) = home::home_dir() else { + return vec![]; + }; + match self { + Shell::Bash => { + // On Bash, we need to update both `.bashrc` and `.bash_profile`. The former is + // sourced for non-login shells, and the latter is sourced for login shells. If + // `.profile` is present, we prefer it over `.bash_profile`, to match the behavior + // of the system shell. + vec![ + home_dir.join(".bashrc"), + if home_dir.join(".profile").is_file() { + home_dir.join(".profile") + } else { + home_dir.join(".bash_profile") + }, + ] + } + Shell::Zsh => { + // On Zsh, we only need to update `.zshrc`. This file is sourced for both login and + // non-login shells. + vec![home_dir.join(".zshrc")] + } + Shell::Fish => { + // On Fish, we only need to update `config.fish`. This file is sourced for both + // login and non-login shells. + vec![home_dir.join(".config/fish/config.fish")] + } + Shell::Csh => { + // On Csh, we need to update both `.cshrc` and `.login`, like Bash. + vec![home_dir.join(".cshrc"), home_dir.join(".login")] + } + // TODO(charlie): Add support for Nushell, PowerShell, and Cmd. + Shell::Nushell => vec![], + Shell::Powershell => vec![], + Shell::Cmd => vec![], + } + } + + /// Returns `true` if the given path is on the `PATH` in this shell. + pub(crate) fn contains_path(path: &Path) -> bool { + std::env::var_os("PATH") + .as_ref() + .iter() + .flat_map(std::env::split_paths) + .any(|p| same_file::is_same_file(path, p).unwrap_or(false)) + } + + /// Returns the command necessary to prepend a directory to the `PATH` in this shell. + pub(crate) fn prepend_path(self, path: &Path) -> Option { + match self { + Shell::Nushell => None, + Shell::Bash | Shell::Zsh => Some(format!( + "export PATH=\"{}:$PATH\"", + backslash_escape(&path.simplified_display().to_string()), + )), + Shell::Fish => Some(format!( + "fish_add_path \"{}\"", + backslash_escape(&path.simplified_display().to_string()), + )), + Shell::Csh => Some(format!( + "setenv PATH \"{}:$PATH\"", + backslash_escape(&path.simplified_display().to_string()), + )), + Shell::Powershell => Some(format!( + "$env:PATH = \"{};$env:PATH\"", + backtick_escape(&path.simplified_display().to_string()), + )), + Shell::Cmd => Some(format!( + "set PATH=\"{};%PATH%\"", + backslash_escape(&path.simplified_display().to_string()), + )), + } + } } +impl std::fmt::Display for Shell { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Shell::Bash => write!(f, "Bash"), + Shell::Fish => write!(f, "Fish"), + Shell::Powershell => write!(f, "PowerShell"), + Shell::Cmd => write!(f, "Command Prompt"), + Shell::Zsh => write!(f, "Zsh"), + Shell::Nushell => write!(f, "Nushell"), + Shell::Csh => write!(f, "Csh"), + } + } +} + +/// Parse the shell from the name of the shell executable. fn parse_shell_from_path(path: &Path) -> Option { let name = path.file_stem()?.to_str()?; match name { @@ -83,3 +176,29 @@ fn parse_shell_from_path(path: &Path) -> Option { _ => None, } } + +/// Escape a string for use in a shell command by inserting backslashes. +fn backslash_escape(s: &str) -> String { + let mut escaped = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' | '"' => escaped.push('\\'), + _ => {} + } + escaped.push(c); + } + escaped +} + +/// Escape a string for use in a `PowerShell` command by inserting backticks. +fn backtick_escape(s: &str) -> String { + let mut escaped = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' | '"' | '$' => escaped.push('`'), + _ => {} + } + escaped.push(c); + } + escaped +} diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index c3f1f7f9b..72addee7f 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -1543,7 +1543,6 @@ fn tool_install_warn_path() { + pathspec==0.12.1 + platformdirs==4.2.0 Installed 2 executables: black, blackd - warning: `[TEMP_DIR]/bin` is not on your PATH. To use installed tools, run: - export PATH="[TEMP_DIR]/bin:$PATH" + warning: [TEMP_DIR]/bin is not on your PATH. To use installed tools, run export PATH="[TEMP_DIR]/bin:$PATH" or uv tool update-shell. "###); }