mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
Add a command to append uv's binary directory to PATH (#4975)
## Summary I'll open follow-up tickets for Windows support. Closes https://github.com/astral-sh/uv/issues/4953. ## Test Plan ``` ❯ cargo run tool install flask Resolved 7 packages in 353ms Prepared 7 packages in 392ms Installed 7 packages in 17ms + blinker==1.8.2 + click==8.1.7 + flask==3.0.3 + itsdangerous==2.2.0 + jinja2==3.1.4 + markupsafe==2.1.5 + werkzeug==3.0.3 Installed 1 executable: flask warning: /Users/crmarsh/.local/bin is not on your PATH. To use installed tools, run: export PATH="/Users/crmarsh/.local/bin:$PATH" ``` Then: ``` ❯ which flask flask not found ``` Then: ``` ❯ cargo run tool ensurepath warning: `uv tool ensurepath` is experimental and may change without warning. Updated configuration file: /Users/crmarsh/workspace/puffin/bar Restart your shell for the changes to take effect. ``` Then: ``` ❯ which flask /Users/crmarsh/.local/bin/flask ```
This commit is contained in:
parent
a4e9b63d5b
commit
a4cb21e081
11 changed files with 280 additions and 67 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -4456,6 +4456,7 @@ dependencies = [
|
|||
"flate2",
|
||||
"fs-err",
|
||||
"futures",
|
||||
"home",
|
||||
"ignore",
|
||||
"indexmap",
|
||||
"indicatif",
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
120
crates/uv/src/commands/tool/update_shell.rs
Normal file
120
crates/uv/src/commands/tool/update_shell.rs
Normal file
|
@ -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<ExitStatus> {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -681,6 +681,12 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
|
|||
|
||||
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,
|
||||
}) => {
|
||||
|
|
|
@ -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<Path>) -> Option<Shell> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<Shell> {
|
||||
let name = path.file_stem()?.to_str()?;
|
||||
match name {
|
||||
|
@ -83,3 +176,29 @@ fn parse_shell_from_path(path: &Path) -> Option<Shell> {
|
|||
_ => 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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
"###);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue