Skip to content
Closed
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
1 change: 1 addition & 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 @@
node_modules/
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "command-update-catalog-protocol-yarn",
"devDependencies": {
"vite": "^7.0.0"
},
"packageManager": "yarn@4.12.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[[case]]
name = "command_update_catalog_protocol_yarn"
vp = "global"
skip-platforms = ["windows"]
unset-env = ["CI", "VP_SKIP_INSTALL"]
local-registry = true
steps = [
{ argv = [
"vp",
"migrate",
"--no-interactive",
"--no-hooks",
"--package-manager",
"yarn",
], comment = "migrate pins the toolchain through the Yarn catalog", continue-on-failure = true },
{ argv = [
"vpt",
"print-file",
"package.json",
], comment = "the migrated project references the catalog", continue-on-failure = true },
{ argv = [
"vp",
"up",
"vite",
"vite-plus",
], comment = "#2309 Yarn variant: `yarn up` rewrites `catalog:` specs, so catalog-pinned names are skipped", continue-on-failure = true },
{ argv = [
"vpt",
"print-file",
"package.json",
], comment = "`vite` and `vite-plus` stay `catalog:` instead of concrete ranges", continue-on-failure = true },
{ argv = [
"vpt",
"print-file",
".yarnrc.yml",
], comment = "the catalog keeps owning the resolved toolchain version", continue-on-failure = true },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# command_update_catalog_protocol_yarn

## `vp migrate --no-interactive --no-hooks --package-manager yarn`

migrate pins the toolchain through the Yarn catalog

```
VITE+ - The Unified Toolchain for the Web

⚠ Vite+ does not currently support Yarn Plug'n'Play (PnP).

✔ Switched Yarn to node-modules mode

Formatting code...

Code formatted
◇ Migrated . to Vite+ <version>
• Node <version> yarn <version>
✓ Dependencies installed in <duration>
• 1 config update applied
• Package manager settings configured
```

## `vpt print-file package.json`

the migrated project references the catalog

```
{
"name": "command-update-catalog-protocol-yarn",
"devDependencies": {
"vite": "catalog:",
"vite-plus": "catalog:"
},
"resolutions": {
"vite": "npm:@voidzero-dev/vite-plus-core@<version>"
},
"packageManager": "yarn@4.12.0"
}
```

## `vp up vite vite-plus`

#2309 Yarn variant: `yarn up` rewrites `catalog:` specs, so catalog-pinned names are skipped

```
warn: Skipped vite: the Yarn catalog pins its version, and `yarn up` would overwrite the `catalog:` reference. Edit the catalog entry in .yarnrc.yml, or run `vp migrate` when Vite+ manages the pin.
warn: Skipped vite-plus: the Yarn catalog pins its version, and `yarn up` would overwrite the `catalog:` reference. Edit the catalog entry in .yarnrc.yml, or run `vp migrate` when Vite+ manages the pin.
```

## `vpt print-file package.json`

`vite` and `vite-plus` stay `catalog:` instead of concrete ranges

```
{
"name": "command-update-catalog-protocol-yarn",
"devDependencies": {
"vite": "catalog:",
"vite-plus": "catalog:"
},
"resolutions": {
"vite": "npm:@voidzero-dev/vite-plus-core@<version>"
},
"packageManager": "yarn@4.12.0"
}
```

## `vpt print-file .yarnrc.yml`

the catalog keeps owning the resolved toolchain version

```
nodeLinker: node-modules
npmPreapprovedPackages:
- vitest
- "@vitest/*"
catalog:
vite: npm:@voidzero-dev/vite-plus-core@<version>
vite-plus: <version>
```
1 change: 1 addition & 0 deletions crates/vp_pm_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pathdiff = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
serde_yaml = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
tar = { workspace = true }
Expand Down
9 changes: 9 additions & 0 deletions crates/vp_pm_cli/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ pub async fn dispatch_with_metadata(
};

let package_manager = manager.client;
let mut command = command;
if let PackageManagerCommand::Update(args) = &mut command
&& !args.packages.is_empty()
{
// Resolution stays free of filesystem access, so the Yarn catalog
// context is read here and handed over as data. The berry update
// resolver uses it to keep `catalog:` references out of `yarn up`.
args.yarn_catalog_packages = crate::yarn_catalog::yarn_catalog_package_names(cwd, &manager);
}
let resolution = command.resolve_for_manager(&manager)?;
let status = run_resolution(cwd, resolution, render_diagnostics).await?;
Ok(DispatchResult { status, package_manager })
Expand Down
1 change: 1 addition & 0 deletions crates/vp_pm_cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod package_manager;
mod request;
pub(crate) mod resolution;
mod shim;
mod yarn_catalog;

pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand};
pub use config::npm_registry;
Expand Down
110 changes: 105 additions & 5 deletions crates/vp_pm_cli/src/resolution/commands/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use vp_pm_cli_macros::pm_args;

use super::parse_positive_usize;
use crate::resolution::{
Bun, CommandBuilder, CommandResolution, Diagnostics, Npm, Pnpm, Resolve, Yarn,
Bun, CommandBuilder, CommandResolution, DiagnosticKind, Diagnostics, Npm, Pnpm, Resolve, Yarn,
};

#[pm_args]
Expand Down Expand Up @@ -70,6 +70,14 @@ pub struct UpdateArgs {
/// Additional arguments to pass through to the package manager
#[arg(last = true, allow_hyphen_values = true)]
pub(crate) pass_through_args: Vec<String>,

/// Package names the Yarn catalog pins (`catalog`/`catalogs` keys in
/// `.yarnrc.yml`). Dispatch fills this from the workspace root; it never
/// comes from the command line. `yarn up <name>` rewrites the manifest
/// spec of every named package and would replace a `catalog:` reference
/// with a concrete range, so the berry resolver skips these names.
#[arg(skip)]
pub(crate) yarn_catalog_packages: Vec<String>,
}

impl Resolve<UpdateArgs> for Pnpm {
Expand Down Expand Up @@ -111,17 +119,33 @@ impl Resolve<UpdateArgs> for Npm {
}

impl Resolve<UpdateArgs> for Yarn {
fn resolve(&self, args: &UpdateArgs, _diag: &mut Diagnostics) -> CommandResolution {
fn resolve(&self, args: &UpdateArgs, diag: &mut Diagnostics) -> CommandResolution {
if self.is_berry() {
Yarn::resolve_berry_update(args)
Yarn::resolve_berry_update(args, diag)
} else {
Yarn::resolve_v1_update(args)
}
}
}

impl Yarn {
fn resolve_berry_update(args: &UpdateArgs) -> CommandResolution {
fn resolve_berry_update(args: &UpdateArgs, diag: &mut Diagnostics) -> CommandResolution {
// `yarn up <name>` rewrites the manifest spec of every named package.
// A `catalog:` reference would come back as a concrete range and lose
// its catalog provenance (issue #2309 under pnpm; Yarn has no
// upstream fix as of 4.18), so catalog-pinned bare names are skipped.
// A descriptor with an explicit range (`vite@^8`) states the user's
// intent to leave the catalog and passes through.
let packages: Vec<&String> = args
.packages
.iter()
.filter(|package| !Self::skip_yarn_catalog_pinned(package.as_str(), args, diag))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve safe recursive catalog updates

When a Yarn Berry user runs vp up --recursive vite for a catalog-pinned package, this filter removes vite and the following branch returns Noop, so the requested lockfile refresh never occurs. Checked Yarn 4.14.1's yarn up --help: -R,--recursive forces matching resolutions to be resolved again but does not touch manifests, so it cannot overwrite the catalog: reference and should bypass this guard.

Useful? React with 👍 / 👎.

.collect();
if packages.is_empty() && !args.packages.is_empty() {
// Every requested package is catalog-pinned. A bare `yarn up`
// would re-resolve the whole project instead, so do not run one.
return CommandResolution::Noop;
}
let mut cmd = CommandBuilder::new("yarn");
if !args.filter.is_empty() {
cmd.arg("workspaces").arg("foreach").arg("--all");
Expand All @@ -131,10 +155,27 @@ impl Yarn {
.arg_if("--recursive", args.recursive)
.arg_if("--interactive", args.interactive)
.extend(args.pass_through_args.iter())
.extend(args.packages.iter());
.extend(packages);
cmd.into()
}

/// True when `package` is a bare name the Yarn catalog pins; also emits
/// the skip warning. Glob patterns and `name@range` descriptors never
/// match: they carry explicit user intent and pass through to `yarn up`.
fn skip_yarn_catalog_pinned(package: &str, args: &UpdateArgs, diag: &mut Diagnostics) -> bool {
let has_explicit_range = package.char_indices().any(|(index, ch)| ch == '@' && index > 0);
if has_explicit_range || !args.yarn_catalog_packages.iter().any(|name| name == package) {
return false;
}
diag.warn(
DiagnosticKind::BehaviorChange,
vt_str::format!(
"Skipped {package}: the Yarn catalog pins its version, and `yarn up` would overwrite the `catalog:` reference. Edit the catalog entry in .yarnrc.yml, or run `vp migrate` when Vite+ manages the pin."
),
);
true
}

fn resolve_v1_update(args: &UpdateArgs) -> CommandResolution {
let mut cmd = CommandBuilder::new("yarn");
if let Some(filter) = args.filter.first() {
Expand Down Expand Up @@ -379,6 +420,65 @@ mod tests {
);
}

#[test]
fn test_yarn_v4_update_skips_catalog_pinned_bare_names() {
let mut options = update_args(&["vite-plus", "react"]);
options.yarn_catalog_packages = vec!["vite".to_string(), "vite-plus".to_string()];
let resolution = resolve(&yarn("4.12.0"), options);
let command = expect_run(resolution.outcome);

assert_eq!(command.program, "yarn");
assert_eq!(command.args, vec!["up", "react"]);
let messages =
resolution.diagnostics.iter().map(|entry| entry.message.as_str()).collect::<Vec<_>>();
assert_eq!(messages.len(), 1);
assert!(messages[0].starts_with("Skipped vite-plus:"));
}

#[test]
fn test_yarn_v4_update_noop_when_all_names_catalog_pinned() {
let mut options = update_args(&["vite", "vite-plus"]);
options.yarn_catalog_packages = vec!["vite".to_string(), "vite-plus".to_string()];
let resolution = resolve(&yarn("4.12.0"), options);

assert_eq!(resolution.outcome, CommandResolution::Noop);
assert_eq!(resolution.diagnostics.len(), 2);
}

#[test]
fn test_yarn_v4_update_explicit_range_bypasses_catalog_pin() {
// `vite@^8` and `@scope/pkg@^1` carry an explicit range: the user
// chose to leave the catalog, so the descriptors pass through.
let mut options = update_args(&["vite@^8.0.0", "@scope/pkg@^1.0.0"]);
options.yarn_catalog_packages = vec!["vite".to_string(), "@scope/pkg".to_string()];
let resolution = resolve(&yarn("4.12.0"), options);
let command = expect_run(resolution.outcome);

assert_eq!(command.args, vec!["up", "vite@^8.0.0", "@scope/pkg@^1.0.0"]);
assert!(resolution.diagnostics.is_empty());
}

#[test]
fn test_yarn_v4_update_skips_catalog_pinned_scoped_bare_name() {
let mut options = update_args(&["@scope/pkg"]);
options.yarn_catalog_packages = vec!["@scope/pkg".to_string()];
let resolution = resolve(&yarn("4.12.0"), options);

assert_eq!(resolution.outcome, CommandResolution::Noop);
assert_eq!(resolution.diagnostics.len(), 1);
}

#[test]
fn test_yarn_v1_update_ignores_catalog_context() {
let mut options = update_args(&["vite"]);
options.yarn_catalog_packages = vec!["vite".to_string()];
let resolution = resolve(&yarn("1.22.0"), options);
let command = expect_run(resolution.outcome);

assert_eq!(command.args, vec!["upgrade", "vite"]);
assert!(resolution.diagnostics.is_empty());
}

#[test]
fn test_yarn_v4_update_recursive() {
let options = UpdateArgs { recursive: true, ..Default::default() };
Expand Down
Loading
Loading