diff --git a/Cargo.lock b/Cargo.lock index 98cf28e5..5029798c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5005,6 +5005,7 @@ dependencies = [ "serde_json", "serde_plain", "serde_with", + "serde_yaml", "thiserror 2.0.18", "tokio", "tokio-tungstenite", diff --git a/packages/zpm-config/schema.json b/packages/zpm-config/schema.json index 8a7d3585..3da8d200 100644 --- a/packages/zpm-config/schema.json +++ b/packages/zpm-config/schema.json @@ -553,6 +553,10 @@ } } }, + "switchVersionRequirement": { + "type": ["zpm_semver::Range", "null"], + "description": "When set, validates that the Yarn Switch binary version satisfies this semver range before executing any command." + }, "catalog": { "type": "object", "description": "Default catalog containing package name to version range mappings. This is an alias for catalogs.default.", diff --git a/packages/zpm-config/src/lib.rs b/packages/zpm-config/src/lib.rs index d233b335..d226c838 100644 --- a/packages/zpm-config/src/lib.rs +++ b/packages/zpm-config/src/lib.rs @@ -1166,6 +1166,9 @@ merge_optional_settings!(zpm_primitives::PeerRange); merge_optional_settings!(zpm_primitives::Range); merge_optional_settings!(zpm_primitives::Reference); +merge_settings!(zpm_semver::Range, |s: &str| FromFileString::from_file_string(s).unwrap()); +merge_optional_settings!(zpm_semver::Range); + merge_settings!(zpm_semver::RangeKind, |s: &str| FromFileString::from_file_string(s).unwrap()); merge_optional_settings!(zpm_semver::RangeKind); diff --git a/packages/zpm-switch/Cargo.toml b/packages/zpm-switch/Cargo.toml index de4503d3..16b70b2c 100644 --- a/packages/zpm-switch/Cargo.toml +++ b/packages/zpm-switch/Cargo.toml @@ -24,6 +24,7 @@ futures = { workspace = true } open = { workspace = true } uuid = { version = "1.21.0", features = ["v4"] } zpm-allocator = { workspace = true } +serde_yaml = { workspace = true } zpm-formats = { workspace = true } zpm-macro-enum = { workspace = true } zpm-parsers = { workspace = true } diff --git a/packages/zpm-switch/src/commands/proxy.rs b/packages/zpm-switch/src/commands/proxy.rs index 2d91ed1d..64b86c59 100644 --- a/packages/zpm-switch/src/commands/proxy.rs +++ b/packages/zpm-switch/src/commands/proxy.rs @@ -4,7 +4,7 @@ use clipanion::cli; use clipanion::core::{Completion, CompletionContext}; use zpm_utils::{DataType, Note, ToFileString}; -use crate::{cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, links::{LinkTarget, get_link, unset_link}, manifest::{LocalPackageManagerReference, PackageManagerField, PackageManagerReference, find_closest_package_manager}, yarn::get_default_yarn_version, yarn_enums::ReleaseLine}; +use crate::{config::validate_yarn_version, cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, links::{LinkTarget, get_link, unset_link}, manifest::{LocalPackageManagerReference, PackageManagerField, PackageManagerReference, find_closest_package_manager}, yarn::get_default_yarn_version, yarn_enums::ReleaseLine}; use super::switch::explicit::ExplicitCommand; @@ -74,6 +74,10 @@ async fn proxy_completer_async(ctx: &CompletionContext<'_>) -> Vec { let mut binary = match &reference { PackageManagerReference::Version(params) => { + let Ok(()) = validate_yarn_version(¶ms.version) else { + return vec![]; + }; + let Ok(cmd) = install_package_manager(params).await else { return vec![]; }; diff --git a/packages/zpm-switch/src/commands/switch/daemon_open.rs b/packages/zpm-switch/src/commands/switch/daemon_open.rs index b301c22f..3b44d5d0 100644 --- a/packages/zpm-switch/src/commands/switch/daemon_open.rs +++ b/packages/zpm-switch/src/commands/switch/daemon_open.rs @@ -5,6 +5,7 @@ use zpm_semver::Version; use zpm_utils::{Path, ToFileString}; use crate::{ + config::validate_yarn_version, cwd::get_final_cwd, daemons::{self, DaemonEntry}, errors::Error, @@ -77,6 +78,8 @@ impl DaemonOpenCommand { match &reference { PackageManagerReference::Version(version_ref) => { + validate_yarn_version(&version_ref.version)?; + let mut binary = install_package_manager(version_ref).await?; diff --git a/packages/zpm-switch/src/commands/switch/explicit.rs b/packages/zpm-switch/src/commands/switch/explicit.rs index 08bcea72..a33d7adb 100644 --- a/packages/zpm-switch/src/commands/switch/explicit.rs +++ b/packages/zpm-switch/src/commands/switch/explicit.rs @@ -3,7 +3,7 @@ use std::{process::{Command, ExitStatus, Stdio}, sync::Arc}; use clipanion::cli; use zpm_utils::ToFileString; -use crate::{cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, manifest::{find_closest_package_manager, PackageManagerReference, VersionPackageManagerReference}, yarn::resolve_selector, yarn_enums::Selector}; +use crate::{config::validate_yarn_version, cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, manifest::{find_closest_package_manager, PackageManagerReference, VersionPackageManagerReference}, yarn::resolve_selector, yarn_enums::Selector}; /// Call a custom Yarn binary for the current project #[cli::command(proxy)] @@ -17,6 +17,10 @@ pub struct ExplicitCommand { impl ExplicitCommand { pub async fn run(reference: &PackageManagerReference, args: &[String]) -> Result { + if let PackageManagerReference::Version(params) = reference { + validate_yarn_version(¶ms.version)?; + } + let mut binary = match reference { PackageManagerReference::Version(params) => install_package_manager(params).await?, diff --git a/packages/zpm-switch/src/config.rs b/packages/zpm-switch/src/config.rs new file mode 100644 index 00000000..69bd67b9 --- /dev/null +++ b/packages/zpm-switch/src/config.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; +use zpm_semver::{Range, Version}; +use zpm_utils::{IoResultExt, Path}; + +use crate::errors::Error; + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PartialRcSettings { + #[serde(default)] + switch_version_requirement: Option, +} + +fn load_switch_version_requirement() -> Result, Error> { + let home_dir + = Path::home_dir()? + .ok_or(Error::MissingHomeFolder)?; + + let rc_filename + = std::env::var("YARN_RC_FILENAME") + .unwrap_or_else(|_| ".yarnrc.yml".to_string()); + + let rc_path + = home_dir.with_join_str(&rc_filename); + + let text + = rc_path.fs_read_text() + .ok_missing()?; + + let Some(text) = text else { + return Ok(None); + }; + + if text.is_empty() { + return Ok(None); + } + + let partial: PartialRcSettings + = serde_yaml::from_str(&text)?; + + Ok(partial.switch_version_requirement) +} + +pub fn validate_yarn_version(version: &Version) -> Result<(), Error> { + let Some(requirement) = load_switch_version_requirement()? else { + return Ok(()); + }; + + if !requirement.check_ignore_rc(version) { + return Err(Error::SwitchVersionMismatch { + requirement, + actual: version.clone(), + }); + } + + Ok(()) +} diff --git a/packages/zpm-switch/src/errors.rs b/packages/zpm-switch/src/errors.rs index 122eff3e..059b0cd4 100644 --- a/packages/zpm-switch/src/errors.rs +++ b/packages/zpm-switch/src/errors.rs @@ -27,6 +27,9 @@ pub enum Error { #[error(transparent)] JsonError(#[from] zpm_parsers::Error), + #[error(transparent)] + YamlError(#[from] Arc), + #[error("Internal error: Join failed ({0})")] JoinFailed(#[from] Arc), @@ -93,6 +96,12 @@ pub enum Error { #[error("Failed to start daemon: {0}")] FailedToStartDaemon(Arc), + #[error("The resolved Yarn version ({actual}) does not satisfy the required range ({requirement})", actual = .actual.to_print_string(), requirement = .requirement.to_print_string())] + SwitchVersionMismatch { + requirement: zpm_semver::Range, + actual: zpm_semver::Version, + }, + #[error("No daemon is running for this project")] DaemonNotRunning, @@ -141,3 +150,9 @@ impl From for Error { Error::from(Arc::new(value)) } } + +impl From for Error { + fn from(value: serde_yaml::Error) -> Self { + Error::from(Arc::new(value)) + } +} diff --git a/packages/zpm-switch/src/lib.rs b/packages/zpm-switch/src/lib.rs index ffd2b0f6..528c425a 100644 --- a/packages/zpm-switch/src/lib.rs +++ b/packages/zpm-switch/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod daemons; mod errors; mod http; diff --git a/packages/zpm-switch/src/main.rs b/packages/zpm-switch/src/main.rs index 11caf954..bd9c5fdb 100644 --- a/packages/zpm-switch/src/main.rs +++ b/packages/zpm-switch/src/main.rs @@ -4,6 +4,7 @@ use std::process::ExitCode; mod cache; mod commands; +mod config; mod cwd; mod daemons; mod errors; diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/features/yarnSwitch.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/features/yarnSwitch.test.ts new file mode 100644 index 00000000..ce3648e2 --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/features/yarnSwitch.test.ts @@ -0,0 +1,86 @@ +import {Filename, ppath, xfs} from '@yarnpkg/fslib'; + +describe(`Features`, () => { + describe(`Yarn Switch`, () => { + describe(`switchVersionRequirement`, () => { + test( + `it should succeed when no config file exists`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + await expect(runSwitch(`--version`)).resolves.toMatchObject({ + code: 0, + }); + }), + ); + + test( + `it should succeed when the config file is empty`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + const homePath = ppath.dirname(path); + + await xfs.writeFilePromise(ppath.join(homePath, Filename.rc), ``); + + await expect(runSwitch(`--version`)).resolves.toMatchObject({ + code: 0, + }); + }), + ); + + test( + `it should succeed when the version matches the requirement`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + const homePath = ppath.dirname(path); + + await xfs.writeJsonPromise(ppath.join(homePath, Filename.rc), { + switchVersionRequirement: `>=6.0.0`, + }); + + await expect(runSwitch(`--version`)).resolves.toMatchObject({ + code: 0, + }); + }), + ); + + test( + `it should fail when the version does not match the requirement`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + const homePath = ppath.dirname(path); + + await xfs.writeJsonPromise(ppath.join(homePath, Filename.rc), { + switchVersionRequirement: `>=99.0.0`, + }); + + await expect(runSwitch(`--version`)).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining(`does not satisfy the required range`), + }); + }), + ); + + test( + `it should fail to start a daemon when the version does not match the requirement`, + makeTemporaryEnv({ + packageManager: `yarn@6.0.0`, + }, async ({path, runSwitch}) => { + const homePath = ppath.dirname(path); + + await xfs.writeJsonPromise(ppath.join(homePath, Filename.rc), { + switchVersionRequirement: `>=99.0.0`, + }); + + await expect(runSwitch(`switch`, `daemon`, `--start`)).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining(`does not satisfy the required range`), + }); + }), + ); + }); + }); +});