diff --git a/Cargo.lock b/Cargo.lock index 532818cc61..4c45443498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8594,7 +8594,9 @@ dependencies = [ "clap", "cow-utils", "crossterm", + "dialoguer", "flate2", + "form_urlencoded", "futures-util", "hex", "httpmock", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/package.json new file mode 100644 index 0000000000..76e95f4b98 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/package.json @@ -0,0 +1,8 @@ +{ + "name": "command-update-interactive-changelog", + "private": true, + "dependencies": { + "testnpm2": "1.0.0" + }, + "packageManager": "pnpm@11.0.6" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/snapshots.toml new file mode 100644 index 0000000000..c1020935b4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "command_update_interactive_changelog" +vp = "global" +steps = [ + { argv = ["vp", "install"], snapshot = false }, + { argv = ["vp", "up", "--interactive", "--latest"], comment = "interactive updates link directly to the selected version's npmx changelog", interactions = [ + { "expect-milestone" = "multi-select:update:ready" }, + { "write-key" = "space" }, + { "write-key" = "enter" }, + ] }, + { argv = ["vpt", "print-file", "package.json"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/snapshots/command_update_interactive_changelog.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/snapshots/command_update_interactive_changelog.md new file mode 100644 index 0000000000..eee351de68 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_update_interactive_changelog/snapshots/command_update_interactive_changelog.md @@ -0,0 +1,44 @@ +# command_update_interactive_changelog + +## `vp install` + + +## `vp up --interactive --latest` + +interactive updates link directly to the selected version's npmx changelog + +**→ expect-milestone:** `multi-select:update:ready` + +``` +? Choose which dependencies to update › +⬚ [dependencies] testnpm2 1.0.0 ❯ 1.0.1 https://npmx.dev/package-changelog/testnpm2/v/1.0.1 +``` + +**← write-key:** `space` + +**← write-key:** `enter` + +``` +✔ Choose which dependencies to update · [dependencies] testnpm2 1.0.0 ❯ 1.0.1 https://npmx.dev/package-changelog/testnpm2/v/1.0.1 + -1 +- + +dependencies: +- testnpm2 1.0.0 + testnpm2 1.0.1 + +Done in using pnpm +``` + +## `vpt print-file package.json` + +``` +{ + "name": "command-update-interactive-changelog", + "private": true, + "dependencies": { + "testnpm2": "1.0.1" + }, + "packageManager": "pnpm@11.0.6" +} +``` diff --git a/crates/vp_command/src/lib.rs b/crates/vp_command/src/lib.rs index 0373bf8577..52909494ef 100644 --- a/crates/vp_command/src/lib.rs +++ b/crates/vp_command/src/lib.rs @@ -10,7 +10,7 @@ use std::os::fd::{BorrowedFd, RawFd}; use std::{ collections::HashMap, ffi::{OsStr, OsString}, - process::{ExitStatus, Stdio}, + process::{ExitStatus, Output, Stdio}, }; use fspy::AccessMode; @@ -201,6 +201,35 @@ where Ok(status) } +/// Run a command and capture stdout while leaving stderr attached to the terminal. +/// +/// This uses the same binary and Windows shim resolution as [`run_command`]. +pub async fn capture_stdout( + bin_name: &str, + args: I, + envs: &HashMap, + cwd: impl AsRef, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let cwd = cwd.as_ref(); + let (program, prefix_args) = resolve_program(bin_name, envs, cwd)?; + let args: Vec = args.into_iter().map(|s| s.as_ref().to_owned()).collect(); + tracing::debug!( + target: "vp_command::spawn", + program = %program.as_path().display(), + prefix_args = ?prefix_args, + args = ?args, + cwd = %cwd.as_path().display(), + "spawn with captured stdout", + ); + let mut cmd = build_command(&program, cwd); + cmd.args(&prefix_args).args(&args).envs(envs).stdout(Stdio::piped()).stderr(Stdio::inherit()); + cmd.output().await.map_err(Into::into) +} + /// Run a command with fspy tracking. /// /// # Arguments diff --git a/crates/vp_pm_cli/Cargo.toml b/crates/vp_pm_cli/Cargo.toml index c0e5c7b181..c298cd7ce3 100644 --- a/crates/vp_pm_cli/Cargo.toml +++ b/crates/vp_pm_cli/Cargo.toml @@ -13,7 +13,9 @@ base64-simd = { workspace = true } clap = { workspace = true, features = ["derive"] } cow-utils = { workspace = true } crossterm = { workspace = true } +dialoguer = { workspace = true } flate2 = { workspace = true } +form_urlencoded = { workspace = true } futures-util = { workspace = true } hex = { workspace = true } indicatif = { workspace = true } diff --git a/crates/vp_pm_cli/src/resolution/command.rs b/crates/vp_pm_cli/src/resolution/command.rs index a5369bd7ba..1b008f2bf9 100644 --- a/crates/vp_pm_cli/src/resolution/command.rs +++ b/crates/vp_pm_cli/src/resolution/command.rs @@ -3,10 +3,19 @@ use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CommandResolution { Run(ResolvedCommand), + PnpmInteractiveUpdate(PnpmInteractiveUpdate), Noop, InvalidArgument(String), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PnpmInteractiveUpdate { + pub(crate) outdated: ResolvedCommand, + pub(crate) update: ResolvedCommand, + pub(crate) latest: bool, + pub(crate) include_github_actions: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ResolvedCommand { pub(crate) program: String, diff --git a/crates/vp_pm_cli/src/resolution/commands/update.rs b/crates/vp_pm_cli/src/resolution/commands/update.rs index f547e7ac89..b191f45597 100644 --- a/crates/vp_pm_cli/src/resolution/commands/update.rs +++ b/crates/vp_pm_cli/src/resolution/commands/update.rs @@ -3,6 +3,7 @@ use vp_pm_cli_macros::pm_args; use super::parse_positive_usize; use crate::resolution::{ Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn, + command::PnpmInteractiveUpdate, }; #[pm_args] @@ -74,6 +75,10 @@ pub struct UpdateArgs { impl Resolve for Pnpm { fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution { + if args.interactive { + return resolve_interactive_pnpm_update(args); + } + let mut cmd = CommandBuilder::new("pnpm"); cmd.repeated("--filter", args.filter.iter()) .arg("update") @@ -92,6 +97,50 @@ impl Resolve for Pnpm { } } +fn resolve_interactive_pnpm_update(args: &UpdateArgs) -> CommandResolution { + let lockfile_only = args + .pass_through_args + .iter() + .any(|arg| arg == "--lockfile-only" || arg.starts_with("--lockfile-only=")); + // Match pnpm's own interactive picker: GitHub Actions are offered only when + // dev dependencies are in scope and the update can modify source files. + let include_github_actions = !args.no_save && !lockfile_only && (args.dev || !args.prod); + + let mut outdated = CommandBuilder::new("pnpm"); + outdated + .repeated("--filter", args.filter.iter()) + .arg("outdated") + .arg("--format") + .arg("json") + .arg_if("--workspace-root", args.workspace_root) + .arg_if("--recursive", args.recursive) + .arg_if("--dev", args.dev) + .arg_if("--prod", args.prod) + .arg_if("--no-optional", args.no_optional) + .extend(args.packages.iter()); + + let mut update = CommandBuilder::new("pnpm"); + update + .repeated("--filter", args.filter.iter()) + .arg("update") + .arg_if("--latest", args.latest) + .arg_if("--workspace-root", args.workspace_root) + .arg_if("--recursive", args.recursive) + .arg_if("--dev", args.dev) + .arg_if("--prod", args.prod) + .arg_if("--no-optional", args.no_optional) + .arg_if("--no-save", args.no_save) + .arg_if("--workspace", args.workspace) + .extend(args.pass_through_args.iter()); + + CommandResolution::PnpmInteractiveUpdate(PnpmInteractiveUpdate { + outdated: outdated.build(), + update: update.build(), + latest: args.latest, + include_github_actions, + }) +} + impl Resolve for Npm { fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution { let mut cmd = CommandBuilder::new("npm"); @@ -264,12 +313,32 @@ mod tests { #[test] fn test_pnpm_update_interactive() { - let options = UpdateArgs { interactive: true, ..Default::default() }; + let options = UpdateArgs { + packages: vec!["react".to_string()], + filter: vec!["app".to_string()], + latest: true, + interactive: true, + no_save: true, + pass_through_args: vec!["--save-exact".to_string()], + ..Default::default() + }; let resolution = resolve(&pnpm("10.0.0"), options); - let command = expect_run(resolution.outcome); + let CommandResolution::PnpmInteractiveUpdate(plan) = resolution.outcome else { + panic!("expected interactive pnpm update resolution"); + }; - assert_eq!(command.program, "pnpm"); - assert_eq!(command.args, vec!["update", "--interactive"]); + assert_eq!(plan.outdated.program, "pnpm"); + assert_eq!( + plan.outdated.args, + vec!["--filter", "app", "outdated", "--format", "json", "react"] + ); + assert_eq!(plan.update.program, "pnpm"); + assert_eq!( + plan.update.args, + vec!["--filter", "app", "update", "--latest", "--no-save", "--save-exact"] + ); + assert!(plan.latest); + assert!(!plan.include_github_actions); } #[test] @@ -497,24 +566,56 @@ mod tests { options.dev = true; options.interactive = true; let resolution = resolve(&pnpm("10.0.0"), options); - let command = expect_run(resolution.outcome); + let CommandResolution::PnpmInteractiveUpdate(plan) = resolution.outcome else { + panic!("expected interactive pnpm update resolution"); + }; - assert_eq!(command.program, "pnpm"); assert_eq!( - command.args, + plan.outdated.args, vec![ "--filter", "app", "--filter", "web", - "update", - "--latest", + "outdated", + "--format", + "json", "--recursive", "--dev", - "--interactive", "react" ] ); + assert_eq!( + plan.update.args, + vec![ + "--filter", + "app", + "--filter", + "web", + "update", + "--latest", + "--recursive", + "--dev" + ] + ); + assert!(plan.latest); + assert!(plan.include_github_actions); + } + + #[test] + fn test_pnpm_interactive_update_excludes_github_actions_for_lockfile_only() { + let options = UpdateArgs { + interactive: true, + pass_through_args: vec!["--lockfile-only".to_string()], + ..Default::default() + }; + + let resolution = resolve(&pnpm("11.0.0"), options); + let CommandResolution::PnpmInteractiveUpdate(plan) = resolution.outcome else { + panic!("expected interactive pnpm update resolution"); + }; + + assert!(!plan.include_github_actions); } #[test] diff --git a/crates/vp_pm_cli/src/resolution/interactive_update.rs b/crates/vp_pm_cli/src/resolution/interactive_update.rs new file mode 100644 index 0000000000..e7c27bb78a --- /dev/null +++ b/crates/vp_pm_cli/src/resolution/interactive_update.rs @@ -0,0 +1,320 @@ +use std::{ + collections::HashMap, + fmt, + process::ExitStatus, + sync::atomic::{AtomicU64, AtomicUsize, Ordering}, +}; + +use dialoguer::{ + MultiSelect, + theme::{ColorfulTheme, Theme}, +}; +use serde::Deserialize; +use vt_path::AbsolutePath; + +use crate::{ + Error, + resolution::command::{PnpmInteractiveUpdate, ResolvedCommand}, +}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct OutdatedPackage { + current: Option, + wanted: Option, + latest: Option, + dependency_type: Option, +} + +struct UpdateChoice { + package: String, + label: vt_str::Str, + github_action: bool, +} + +pub(super) async fn run_pnpm_interactive_update( + cwd: &AbsolutePath, + plan: PnpmInteractiveUpdate, +) -> Result { + let output = capture(cwd, &plan.outdated).await?; + if !output.status.success() && vp_shared::exit_code_from_status(output.status) != 1 { + if !output.stdout.is_empty() { + vp_shared::output::raw(&String::from_utf8_lossy(&output.stdout)); + } + return Ok(output.status); + } + + let outdated: HashMap = serde_json::from_slice(&output.stdout)?; + let choices = update_choices(outdated, plan.latest, plan.include_github_actions); + if choices.is_empty() { + if plan.latest { + vp_shared::output::raw("All of your dependencies are already up to date"); + } else { + vp_shared::output::raw( + "All of your dependencies are already up to date inside the specified ranges. \ + Use the --latest option to update the ranges in package.json", + ); + } + return Ok(ExitStatus::default()); + } + + let theme = InteractiveUpdateTheme::new(choices.len()); + let selected = MultiSelect::with_theme(&theme) + .with_prompt("Choose which dependencies to update") + .items(choices.iter().map(|choice| choice.label.as_str()).collect::>()) + .interact() + .map_err(|error| { + Error::Other(vt_str::format!("interactive update selection failed: {error}")) + })?; + + if selected.is_empty() { + return Ok(ExitStatus::default()); + } + + let mut update = plan.update; + if selected.iter().any(|&index| choices[index].github_action) + && !update.args.iter().any(|arg| arg == "--include-github-actions") + { + update.args.push("--include-github-actions".to_string()); + } + update.args.extend(selected.into_iter().map(|index| choices[index].package.clone())); + run(cwd, update).await +} + +async fn capture( + cwd: &AbsolutePath, + command: &ResolvedCommand, +) -> Result { + let env = command.env.clone().into_iter().collect::>(); + vp_command::capture_stdout(&command.program, &command.args, &env, cwd).await.map_err(Into::into) +} + +async fn run(cwd: &AbsolutePath, command: ResolvedCommand) -> Result { + let env = command.env.into_iter().collect::>(); + Ok(vp_command::run_command(&command.program, command.args, &env, cwd).await?) +} + +fn update_choices( + outdated: HashMap, + latest: bool, + include_github_actions: bool, +) -> Vec { + let mut outdated = outdated.into_iter().collect::>(); + outdated.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let rows = outdated + .into_iter() + .filter_map(|(package, details)| { + let github_action = details.dependency_type.as_deref() == Some("githubAction"); + if github_action && !include_github_actions { + return None; + } + let target = if latest { details.latest } else { details.wanted }?; + if details.current.as_deref() == Some(target.as_str()) { + return None; + } + Some(( + package, + details.current.unwrap_or_default(), + target, + details.dependency_type, + github_action, + )) + }) + .collect::>(); + let package_width = rows.iter().map(|(package, ..)| package.len()).max().unwrap_or_default(); + let current_width = rows.iter().map(|(_, current, ..)| current.len()).max().unwrap_or_default(); + let target_width = rows.iter().map(|(_, _, target, ..)| target.len()).max().unwrap_or_default(); + + rows.into_iter() + .map(|(package, current, target, dependency_type, github_action)| { + let group = + dependency_type.map(|group| vt_str::format!("[{group}] ")).unwrap_or_default(); + let url = if github_action { + vt_str::format!("https://github.com/{package}") + } else { + npmx_changelog_url(&package, &target) + }; + let label = vt_str::format!( + "{group}{package:package_width$} {current:>current_width$} ❯ {target:target_width$} {url}" + ); + UpdateChoice { package, label, github_action } + }) + .collect() +} + +fn npmx_changelog_url(package: &str, version: &str) -> vt_str::Str { + let package = form_urlencoded::byte_serialize(package.as_bytes()).collect::(); + let version = form_urlencoded::byte_serialize(version.as_bytes()).collect::(); + vt_str::format!("https://npmx.dev/package-changelog/{package}/v/{version}") +} + +/// A `dialoguer` theme that lets the PTY runner synchronize only after the +/// first complete multi-select frame has been written. +struct InteractiveUpdateTheme { + inner: ColorfulTheme, + items_before_milestone: AtomicUsize, +} + +impl InteractiveUpdateTheme { + fn new(item_count: usize) -> Self { + Self { + inner: ColorfulTheme::default(), + items_before_milestone: AtomicUsize::new(item_count), + } + } + + fn rendered_last_initial_item(&self) -> bool { + let mut remaining = self.items_before_milestone.load(Ordering::Relaxed); + loop { + if remaining == 0 { + return false; + } + match self.items_before_milestone.compare_exchange_weak( + remaining, + remaining - 1, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => return remaining == 1, + Err(actual) => remaining = actual, + } + } + } +} + +impl Theme for InteractiveUpdateTheme { + fn format_multi_select_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { + self.inner.format_multi_select_prompt(f, prompt) + } + + fn format_multi_select_prompt_selection( + &self, + f: &mut dyn fmt::Write, + prompt: &str, + selections: &[&str], + ) -> fmt::Result { + self.inner.format_multi_select_prompt_selection(f, prompt, selections) + } + + fn format_multi_select_prompt_item( + &self, + f: &mut dyn fmt::Write, + text: &str, + checked: bool, + active: bool, + ) -> fmt::Result { + self.inner.format_multi_select_prompt_item(f, text, checked, active)?; + + if self.rendered_last_initial_item() { + write_prompt_milestone(f)?; + } + + Ok(()) + } +} + +/// Write an invisible window-title marker into the rendered frame. Dialoguer +/// flushes the frame before reading a key, so the runner cannot race the prompt. +fn write_prompt_milestone(f: &mut dyn fmt::Write) -> fmt::Result { + if std::env::var_os(vp_shared::env_vars::VP_EMIT_MILESTONES).is_none_or(|value| value != "1") { + return Ok(()); + } + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = (u128::from(std::process::id()) << 64) + | u128::from(COUNTER.fetch_add(1, Ordering::Relaxed)); + let encoded = base64_simd::URL_SAFE_NO_PAD.encode_to_string(b"multi-select:update:ready"); + write!(f, "\x1b]2;pty-terminal-test:{id:032x}:{encoded}\x1b\\") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_version_specific_npmx_changelog_links() { + let outdated = HashMap::from([ + ( + "vite".to_string(), + OutdatedPackage { + current: Some("7.0.0".to_string()), + wanted: Some("7.2.0".to_string()), + latest: Some("8.2.1".to_string()), + dependency_type: Some("devDependencies".to_string()), + }, + ), + ( + "@vitejs/plugin-react".to_string(), + OutdatedPackage { + current: Some("5.0.0".to_string()), + wanted: Some("5.1.4".to_string()), + latest: Some("6.0.1".to_string()), + dependency_type: Some("devDependencies".to_string()), + }, + ), + ]); + + let choices = update_choices(outdated, false, true); + + assert_eq!(choices.len(), 2); + assert_eq!(choices[0].package, "@vitejs/plugin-react"); + assert!(choices[0].label.contains("@vitejs/plugin-react")); + assert!( + choices[0] + .label + .contains("https://npmx.dev/package-changelog/%40vitejs%2Fplugin-react/v/5.1.4") + ); + assert!(choices[1].label.contains("https://npmx.dev/package-changelog/vite/v/7.2.0")); + } + + #[test] + fn latest_uses_latest_target_and_omits_current_versions() { + let outdated = HashMap::from([ + ( + "already-current".to_string(), + OutdatedPackage { + current: Some("2.0.0".to_string()), + wanted: Some("2.0.0".to_string()), + latest: Some("2.0.0".to_string()), + dependency_type: None, + }, + ), + ( + "next".to_string(), + OutdatedPackage { + current: Some("1.0.0".to_string()), + wanted: Some("1.1.0".to_string()), + latest: Some("3.0.0".to_string()), + dependency_type: None, + }, + ), + ]); + + let choices = update_choices(outdated, true, true); + + assert_eq!(choices.len(), 1); + assert_eq!(choices[0].package, "next"); + assert!(choices[0].label.contains("/next/v/3.0.0")); + } + + #[test] + fn github_actions_keep_their_repository_link_and_can_be_filtered() { + let outdated = HashMap::from([( + "actions/checkout".to_string(), + OutdatedPackage { + current: Some("4".to_string()), + wanted: Some("4".to_string()), + latest: Some("5".to_string()), + dependency_type: Some("githubAction".to_string()), + }, + )]); + + assert!(update_choices(outdated.clone(), true, false).is_empty()); + let choices = update_choices(outdated, true, true); + assert_eq!(choices.len(), 1); + assert!(choices[0].github_action); + assert!(choices[0].label.contains("https://github.com/actions/checkout")); + assert!(!choices[0].label.contains("npmx.dev")); + } +} diff --git a/crates/vp_pm_cli/src/resolution/mod.rs b/crates/vp_pm_cli/src/resolution/mod.rs index 9a354370ec..28731607cd 100644 --- a/crates/vp_pm_cli/src/resolution/mod.rs +++ b/crates/vp_pm_cli/src/resolution/mod.rs @@ -31,6 +31,7 @@ mod command; mod commands; mod diagnostics; mod dialect; +mod interactive_update; mod resolve; mod runner; mod support; diff --git a/crates/vp_pm_cli/src/resolution/resolve.rs b/crates/vp_pm_cli/src/resolution/resolve.rs index 8a013e1c10..65d102cc51 100644 --- a/crates/vp_pm_cli/src/resolution/resolve.rs +++ b/crates/vp_pm_cli/src/resolution/resolve.rs @@ -56,10 +56,16 @@ where } }; - if let CommandResolution::Run(command) = &mut resolution.outcome { - command - .env - .insert("PATH".to_string(), vp_shared::format_path_prepended(manager.get_bin_prefix())); + let path = vp_shared::format_path_prepended(manager.get_bin_prefix()); + match &mut resolution.outcome { + CommandResolution::Run(command) => { + command.env.insert("PATH".to_string(), path); + } + CommandResolution::PnpmInteractiveUpdate(plan) => { + plan.outdated.env.insert("PATH".to_string(), path.clone()); + plan.update.env.insert("PATH".to_string(), path); + } + CommandResolution::Noop | CommandResolution::InvalidArgument(_) => {} } Ok(resolution) @@ -76,7 +82,7 @@ fn parse_version(manager: &PackageManager) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::resolution::ApproveBuildsArgs; + use crate::resolution::{ApproveBuildsArgs, UpdateArgs}; fn package_manager(client: PackageManagerType, version: &str) -> PackageManager { let workspace_root = vt_path::current_dir().unwrap(); @@ -122,4 +128,23 @@ mod tests { } if version == "latest" )); } + + #[test] + fn interactive_update_binds_the_managed_package_manager_path_to_both_commands() { + let manager = package_manager(PackageManagerType::Pnpm, "11.0.0"); + let resolution = + resolve_for_manager(&manager, UpdateArgs { interactive: true, ..Default::default() }) + .unwrap(); + let CommandResolution::PnpmInteractiveUpdate(plan) = resolution.outcome else { + panic!("expected interactive pnpm update resolution"); + }; + + for command in [&plan.outdated, &plan.update] { + let path = command.env.get("PATH").expect("resolved command should bind PATH"); + assert_eq!( + std::env::split_paths(path).next().as_deref(), + Some(manager.get_bin_prefix().as_path()) + ); + } + } } diff --git a/crates/vp_pm_cli/src/resolution/runner.rs b/crates/vp_pm_cli/src/resolution/runner.rs index 56967549e3..c5363e95d5 100644 --- a/crates/vp_pm_cli/src/resolution/runner.rs +++ b/crates/vp_pm_cli/src/resolution/runner.rs @@ -7,6 +7,7 @@ use crate::{ resolution::{ CommandResolution, Resolution, command::{PreRunAction, ResolvedCommand}, + interactive_update::run_pnpm_interactive_update, }, }; @@ -21,6 +22,9 @@ pub(crate) async fn run_resolution( match resolution.outcome { CommandResolution::Run(command) => run_command(cwd, command).await, + CommandResolution::PnpmInteractiveUpdate(plan) => { + run_pnpm_interactive_update(cwd, plan).await + } CommandResolution::Noop => Ok(ExitStatus::default()), CommandResolution::InvalidArgument(message) => Err(Error::UserMessage(message.into())), }