diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1841fe5..0128f56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: id: generate_token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 with: - app-id: ${{ secrets.BOT_APP_ID }} + client-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - name: Checkout repository @@ -105,7 +105,7 @@ jobs: id: generate_token uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 with: - app-id: ${{ secrets.BOT_APP_ID }} + client-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - name: Checkout repository diff --git a/src/commands/init.rs b/src/commands/init.rs index 62ee0d0..984511a 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -59,6 +59,7 @@ impl Init { // Call use programmatically let use_cmd = crate::commands::use_cmd::Use { version: Some(selected.clone()), + silent: false, }; use_cmd.call().await?; } diff --git a/src/commands/install.rs b/src/commands/install.rs index beee165..dda3152 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -12,6 +12,16 @@ pub struct Install { } pub async fn execute_install(version: &str) -> Result<()> { + execute_install_with(version, true).await.map(|_| ()) +} + +/// `prompt_activation` controls the trailing "Do you want to use PHP X now?" prompt and the +/// resulting env-file write. Callers like `pvm use` set it to `false` because they will fall +/// through to their own activation path with the returned resolved version. +pub async fn execute_install_with( + version: &str, + prompt_activation: bool, +) -> Result> { let versions_dir = fs::get_versions_dir()?; std::fs::create_dir_all(&versions_dir)?; @@ -50,7 +60,7 @@ pub async fn execute_install(version: &str) -> Result<()> { if selections.is_empty() { println!("{} No packages selected. Operation cancelled.", "✗".red()); - return Ok(()); + return Ok(None); } let selected_packages: Vec = selections @@ -94,6 +104,18 @@ pub async fn execute_install(version: &str) -> Result<()> { // Only the cli package places a `php` binary on PATH; without it, switching is meaningless. let cli_selected = selected_packages.iter().any(|p| p == "cli"); + + if !prompt_activation { + if !cli_selected { + println!( + "{} The 'cli' package was not selected; this version cannot be activated via PATH.", + "💡".yellow() + ); + return Ok(None); + } + return Ok(Some(resolved_version)); + } + let use_now = cli_selected && dialoguer::Confirm::with_theme(&theme) .with_prompt( @@ -120,20 +142,21 @@ pub async fn execute_install(version: &str) -> Result<()> { // is unsound in a multi-threaded tokio runtime, and the wrapper sources env_file // into the parent shell on exit, so subsequent pvm invocations see the new PATH. println!("{} Switched to PHP {}", "✓".green(), v.bold()); + Ok(Some(resolved_version)) } else if !cli_selected { println!( "{} The 'cli' package was not selected; this version cannot be activated via PATH.", "💡".yellow() ); + Ok(None) } else { println!( "{} To use this version later, run `{}`", "💡".yellow(), format!("pvm use {}", version).bold() ); + Ok(Some(resolved_version)) } - - Ok(()) } impl Install { diff --git a/src/commands/use_cmd.rs b/src/commands/use_cmd.rs index df822b0..37fc750 100644 --- a/src/commands/use_cmd.rs +++ b/src/commands/use_cmd.rs @@ -11,12 +11,45 @@ use std::path::Path; pub struct Use { /// The version to use (omit for interactive list) pub version: Option, + + /// Skip interactive prompts when the requested version is missing (used by shell hooks). + #[arg(long, hide = true)] + pub silent: bool, } impl Use { pub async fn call(self) -> Result<()> { let mut version = match self.version { - Some(ref v) => fs::resolve_local_version(v)?, + Some(ref v) => match fs::try_resolve_local_version(v)? { + Some(resolved) => resolved, + None => { + if self.silent { + return Ok(()); + } + + let prompt = format!( + "PHP {} is not installed locally. Do you want to install it now?", + v.bold() + ); + let install_now = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(&prompt) + .default(true) + .interact_opt()? + .unwrap_or(false); + + if !install_now { + eprintln!("{} Operation cancelled.", "✗".red()); + return Ok(()); + } + + // Skip install's own "use now?" prompt — we fall through to + // the activation path below with the freshly installed version. + match crate::commands::install::execute_install_with(v, false).await? { + Some(installed) => installed, + None => return Ok(()), + } + } + }, None => { let items = fs::get_aliased_versions()?; if items.is_empty() { diff --git a/src/fs.rs b/src/fs.rs index 453f150..499bfd7 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -172,18 +172,17 @@ pub fn get_aliased_versions() -> Result> { Ok(items) } -pub fn resolve_local_version(requested: &str) -> Result { +pub fn try_resolve_local_version(requested: &str) -> Result> { if requested == "latest" { - return get_aliased_versions()? + return Ok(get_aliased_versions()? .into_iter() .find(|item| item.display.starts_with("latest")) - .map(|item| item.version) - .ok_or_else(|| anyhow::anyhow!("No PHP versions are currently installed.")); + .map(|item| item.version)); } let installed = list_installed_versions()?; if installed.contains(&requested.to_string()) { - return Ok(requested.to_string()); + return Ok(Some(requested.to_string())); } let prefix = format!("{}.", requested); @@ -192,11 +191,17 @@ pub fn resolve_local_version(requested: &str) -> Result { .filter(|v| v.starts_with(&prefix)) .collect(); - if let Some(latest) = matching.last() { - return Ok((*latest).clone()); - } + Ok(matching.last().map(|s| (*s).clone())) +} - anyhow::bail!("PHP {} is not installed locally.", requested) +pub fn resolve_local_version(requested: &str) -> Result { + match try_resolve_local_version(requested)? { + Some(v) => Ok(v), + None if requested == "latest" => { + anyhow::bail!("No PHP versions are currently installed.") + } + None => anyhow::bail!("PHP {} is not installed locally.", requested), + } } #[cfg(test)] diff --git a/src/interactive.rs b/src/interactive.rs index dd4986d..7c44a18 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -34,7 +34,10 @@ pub async fn run_root_menu() -> Result<()> { let res = match choice { 0 => { - let cmd = commands::use_cmd::Use { version: None }; + let cmd = commands::use_cmd::Use { + version: None, + silent: false, + }; cmd.call().await } 1 => { @@ -46,7 +49,7 @@ pub async fn run_root_menu() -> Result<()> { cmd.call().await } 3 => { - let cmd = commands::use_cmd::Use { version: None }; + let cmd = commands::ls::Ls; cmd.call().await } 4 => { diff --git a/src/shell.rs b/src/shell.rs index 96dabd3..9a269b7 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -57,7 +57,7 @@ impl Shell for Bash { " _pvm_cd_hook() { if [[ -f .php-version ]]; then - pvm use \"$(cat .php-version)\" || true + pvm use --silent \"$(cat .php-version)\" || true fi } if [[ -n \"$BASH_VERSION\" ]]; then @@ -118,7 +118,7 @@ impl Shell for Zsh { " _pvm_cd_hook() { if [[ -f .php-version ]]; then - pvm use \"$(cat .php-version)\" || true + pvm use --silent \"$(cat .php-version)\" || true fi } autoload -U add-zsh-hook @@ -176,7 +176,7 @@ impl Shell for Fish { " function _pvm_cd_hook --on-variable PWD if test -f .php-version - pvm use (cat .php-version) + pvm use --silent (cat .php-version) end end " diff --git a/tests/cli.rs b/tests/cli.rs index 28b0983..8a7d442 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -124,3 +124,27 @@ fn test_use_silent_export() { .success() .stdout(predicate::str::contains("export PVM_MULTISHELL_PATH").not()); } + +#[test] +fn test_use_silent_skips_missing_version() { + let temp_dir = tempfile::tempdir().unwrap(); + let env_file = temp_dir.path().join("custom_env_update"); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm"); + cmd.env("PVM_DIR", temp_dir.path()); + cmd.env("PVM_UPDATE_MODE", "disabled"); + cmd.env("PVM_ENV_UPDATE_PATH", &env_file); + cmd.current_dir(temp_dir.path()); + cmd.arg("use").arg("--silent").arg("8.3"); + + // Silent mode: missing version exits 0 with no output and no env file written. + cmd.assert() + .success() + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::is_empty()); + + assert!( + !env_file.exists(), + "silent mode must not write env file when version is missing" + ); +}