Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "command-update-interactive-changelog",
"private": true,
"dependencies": {
"testnpm2": "1.0.0"
},
"packageManager": "pnpm@11.0.6"
}
Original file line number Diff line number Diff line change
@@ -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"] },
]
Original file line number Diff line number Diff line change
@@ -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 <duration> using pnpm <version>
```

## `vpt print-file package.json`

```
{
"name": "command-update-interactive-changelog",
"private": true,
"dependencies": {
"testnpm2": "1.0.1"
},
"packageManager": "pnpm@11.0.6"
}
```
31 changes: 30 additions & 1 deletion crates/vp_command/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<I, S>(
bin_name: &str,
args: I,
envs: &HashMap<String, String>,
cwd: impl AsRef<AbsolutePath>,
) -> Result<Output, Error>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let cwd = cwd.as_ref();
let (program, prefix_args) = resolve_program(bin_name, envs, cwd)?;
let args: Vec<OsString> = 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
Expand Down
2 changes: 2 additions & 0 deletions crates/vp_pm_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
9 changes: 9 additions & 0 deletions crates/vp_pm_cli/src/resolution/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
121 changes: 111 additions & 10 deletions crates/vp_pm_cli/src/resolution/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -74,6 +75,10 @@ pub struct UpdateArgs {

impl Resolve<UpdateArgs> 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")
Expand All @@ -92,6 +97,50 @@ impl Resolve<UpdateArgs> 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<UpdateArgs> for Npm {
fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution {
let mut cmd = CommandBuilder::new("npm");
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Loading