Skip to content
Merged
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
218 changes: 180 additions & 38 deletions src/backend/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ use crate::config::settings::NpmPackageManager;
use crate::config::{Config, Settings};
use crate::duration::{elapsed_seconds_ceil, process_now};
use crate::install_context::InstallContext;
use crate::semver::{semver_is_at_least, semver_is_older_than, semver_triplet};
use crate::timeout;
use crate::toolset::ToolVersion;
use crate::toolset::{ToolVersion, Toolset};
use async_trait::async_trait;
use jiff::Timestamp;
use serde_json::Value;
Expand All @@ -24,6 +25,9 @@ use tokio::sync::Mutex as TokioMutex;
/// of elapsed time between when mise resolved the cutoff and when it invoked
/// the package manager.
const BEFORE_DATE_TOLERANCE_SECS: u64 = 60;
const NPM_MIN_RELEASE_AGE_VERSION: &str = "11.10.0";
const BUN_MIN_RELEASE_AGE_VERSION: &str = "1.3.0";
const PNPM_MIN_RELEASE_AGE_VERSION: &str = "10.16.0";

#[derive(Debug)]
pub struct NPMBackend {
Expand Down Expand Up @@ -170,6 +174,8 @@ impl Backend for NPMBackend {
let package_manager = Settings::get().npm.package_manager;
let install_before_args = match ctx.before_date {
Some(before_date) => {
self.warn_if_package_manager_may_not_support_release_age(ctx, package_manager)
.await;
self.build_transitive_release_age_args(&ctx.config, package_manager, before_date)
.await
}
Expand Down Expand Up @@ -327,22 +333,77 @@ impl NPMBackend {
vec![format!("--config.minimumReleaseAge={minutes}").into()]
}

/// Returns true if the npm major.minor.patch version is >= 11.10.0,
/// which is when the --min-release-age flag was added (npm/cli#8965).
fn npm_version_supports_min_release_age(version: &str) -> bool {
let trimmed = version.trim().trim_start_matches('v');
let mut parts = trimmed.split(['.', '-', '+']);
let major: u64 = match parts.next().and_then(|p| p.parse().ok()) {
Some(v) => v,
None => return false,
async fn warn_if_package_manager_may_not_support_release_age(
&self,
ctx: &InstallContext,
package_manager: NpmPackageManager,
) {
let Some((tool, required_version, flag)) =
Self::release_age_package_manager_requirement(package_manager)
else {
return;
};

let version = match Self::toolset_package_manager_version(&ctx.ts, tool) {
Some(version) => Some(version),
None => match self.dependency_toolset(&ctx.config).await {
Ok(ts) => Self::toolset_package_manager_version(&ts, tool),
Err(_) => None,
},
};

let Some(version) = version else {
return;
};
let minor: u64 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
// 11.10.0+ — only major+minor matter for the gate
match major.cmp(&11) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => minor >= 10,

if semver_is_older_than(&version, required_version).unwrap_or(false) {
warn!(
"install_before is set for npm:{} but {}@{} is older than the documented minimum {}@{} required for {}. Older versions may fail while processing the forwarded argument. See https://mise.jdx.dev/dev-tools/backends/npm.html",
self.tool_name(),
tool,
version,
tool,
required_version,
flag
);
}
}

fn release_age_package_manager_requirement(
package_manager: NpmPackageManager,
) -> Option<(&'static str, &'static str, &'static str)> {
match package_manager {
NpmPackageManager::Npm => None,
NpmPackageManager::Bun => {
Some(("bun", BUN_MIN_RELEASE_AGE_VERSION, "--minimum-release-age"))
}
NpmPackageManager::Pnpm => Some((
"pnpm",
PNPM_MIN_RELEASE_AGE_VERSION,
"--config.minimumReleaseAge",
)),
}
}

fn toolset_package_manager_version(ts: &Toolset, tool: &str) -> Option<String> {
let tvl = ts
.versions
.iter()
.find(|(ba, _)| ba.short == tool)
.map(|(_, tvl)| tvl)?;

if let Some(tv) = tvl
.versions
.iter()
.find(|tv| semver_triplet(&tv.version).is_some())
{
return Some(tv.version.clone());
}

tvl.requests
.iter()
.map(|tr| tr.version())
.find(|version| semver_triplet(version).is_some())
}

/// Detect whether the locally installed npm supports --min-release-age.
Expand All @@ -363,7 +424,8 @@ impl NPMBackend {
"npm version detection: found npm {} in ToolSet, skipping subprocess",
tv.version
);
return Self::npm_version_supports_min_release_age(&tv.version);
return semver_is_at_least(&tv.version, NPM_MIN_RELEASE_AGE_VERSION)
.unwrap_or(false);
}
}
}
Expand Down Expand Up @@ -391,7 +453,7 @@ impl NPMBackend {
return false;
}
};
Self::npm_version_supports_min_release_age(&output)
semver_is_at_least(&output, NPM_MIN_RELEASE_AGE_VERSION).unwrap_or(false)
}

/// Check dependencies for version checking (always needs npm)
Expand Down Expand Up @@ -456,7 +518,9 @@ impl NPMBackend {
mod tests {
use super::*;
use crate::cli::args::{BackendArg, BackendResolution};
use crate::toolset::{ToolRequest, ToolSource, ToolVersionList, ToolVersionOptions};
use pretty_assertions::assert_eq;
use std::sync::Arc;

fn create_npm_backend(tool: &str) -> NPMBackend {
let ba = BackendArg::new_raw(
Expand All @@ -469,6 +533,25 @@ mod tests {
NPMBackend::from_arg(ba)
}

fn create_test_backend_arg(tool: &str) -> Arc<BackendArg> {
Arc::new(BackendArg::new_raw(
tool.to_string(),
None,
tool.to_string(),
None,
BackendResolution::new(true),
))
}

fn create_test_tool_request(ba: Arc<BackendArg>, version: &str) -> ToolRequest {
ToolRequest::Version {
backend: ba,
version: version.to_string(),
options: ToolVersionOptions::default(),
source: ToolSource::Argument,
}
}

#[test]
fn test_get_dependencies_for_npm_itself() {
// When the tool is npm itself (npm:npm) with default settings (npm as package manager),
Expand Down Expand Up @@ -565,26 +648,85 @@ mod tests {
}

#[test]
fn test_npm_version_supports_min_release_age() {
// 11.10.0 is the cutoff where --min-release-age was added
assert!(NPMBackend::npm_version_supports_min_release_age("11.10.0"));
assert!(NPMBackend::npm_version_supports_min_release_age("11.10.1"));
assert!(NPMBackend::npm_version_supports_min_release_age("11.11.0"));
assert!(NPMBackend::npm_version_supports_min_release_age("12.0.0"));
// Tolerate `v` prefix and trailing whitespace from `npm --version`
assert!(NPMBackend::npm_version_supports_min_release_age("v11.10.0"));
assert!(NPMBackend::npm_version_supports_min_release_age(
"11.10.0\n"
));
// Pre-release still satisfies the gate (no known 11.10.0 pre-releases exist)
assert!(NPMBackend::npm_version_supports_min_release_age(
"11.10.0-pre.1"
));

assert!(!NPMBackend::npm_version_supports_min_release_age("11.9.9"));
assert!(!NPMBackend::npm_version_supports_min_release_age("11.0.0"));
assert!(!NPMBackend::npm_version_supports_min_release_age("10.99.0"));
assert!(!NPMBackend::npm_version_supports_min_release_age(""));
assert!(!NPMBackend::npm_version_supports_min_release_age("garbage"));
fn test_release_age_package_manager_requirements() {
assert_eq!(
NPMBackend::release_age_package_manager_requirement(NpmPackageManager::Npm),
None
);
assert_eq!(
NPMBackend::release_age_package_manager_requirement(NpmPackageManager::Bun),
Some(("bun", BUN_MIN_RELEASE_AGE_VERSION, "--minimum-release-age"))
);
assert_eq!(
NPMBackend::release_age_package_manager_requirement(NpmPackageManager::Pnpm),
Some((
"pnpm",
PNPM_MIN_RELEASE_AGE_VERSION,
"--config.minimumReleaseAge"
))
);
}

#[test]
fn test_npm_min_release_age_version_requirement() {
assert_eq!(NPM_MIN_RELEASE_AGE_VERSION, "11.10.0");
assert_eq!(
crate::semver::semver_is_at_least("11.10.0", NPM_MIN_RELEASE_AGE_VERSION),
Some(true)
);
assert_eq!(
crate::semver::semver_is_at_least("11.9.9", NPM_MIN_RELEASE_AGE_VERSION),
Some(false)
);
}

#[test]
fn test_toolset_package_manager_version_prefers_resolved_version() {
let ba = create_test_backend_arg("bun");
let request = create_test_tool_request(ba.clone(), "1.2.0");
let mut tvl = ToolVersionList::new(ba.clone(), ToolSource::Argument);
tvl.requests.push(request.clone());
tvl.versions
.push(ToolVersion::new(request, "1.3.0".to_string()));

let mut ts = Toolset::default();
ts.versions.insert(ba, tvl);

assert_eq!(
NPMBackend::toolset_package_manager_version(&ts, "bun"),
Some("1.3.0".to_string())
);
}

#[test]
fn test_toolset_package_manager_version_uses_exact_request() {
let ba = create_test_backend_arg("pnpm");
let request = create_test_tool_request(ba.clone(), "10.15.0");
let mut tvl = ToolVersionList::new(ba.clone(), ToolSource::Argument);
tvl.requests.push(request);

let mut ts = Toolset::default();
ts.versions.insert(ba, tvl);

assert_eq!(
NPMBackend::toolset_package_manager_version(&ts, "pnpm"),
Some("10.15.0".to_string())
);
}

#[test]
fn test_toolset_package_manager_version_ignores_unresolved_request() {
let ba = create_test_backend_arg("pnpm");
let request = create_test_tool_request(ba.clone(), "10");
let mut tvl = ToolVersionList::new(ba.clone(), ToolSource::Argument);
tvl.requests.push(request);

let mut ts = Toolset::default();
ts.versions.insert(ba, tvl);

assert_eq!(
NPMBackend::toolset_package_manager_version(&ts, "pnpm"),
None
);
}
}
61 changes: 60 additions & 1 deletion src/semver.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use nodejs_semver::{Range, Version as NodeVersion};
use std::cmp::Ordering;
use versions::{Mess, Versioning};

/// splits a version number into an optional prefix and the remaining version string
Expand Down Expand Up @@ -104,9 +105,34 @@ pub fn is_npm_semver_range_query(query: &str) -> bool {
query.split('.').any(|part| matches!(part, "*" | "x" | "X"))
}

pub fn semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
let trimmed = version.trim().trim_start_matches(['v', 'V']);
let mut parts = trimmed.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.split(['-', '+']).next()?.parse().ok()?;
Some((major, minor, patch))
}

pub fn semver_cmp(version: &str, other: &str) -> Option<Ordering> {
Some(semver_triplet(version)?.cmp(&semver_triplet(other)?))
}

pub fn semver_is_older_than(version: &str, minimum: &str) -> Option<bool> {
Some(semver_cmp(version, minimum)? == Ordering::Less)
}

pub fn semver_is_at_least(version: &str, minimum: &str) -> Option<bool> {
Some(semver_cmp(version, minimum)? != Ordering::Less)
}

#[cfg(test)]
mod tests {
use super::{chunkify_version, npm_semver_range_filter, split_version_prefix};
use super::{
chunkify_version, npm_semver_range_filter, semver_cmp, semver_is_at_least,
semver_is_older_than, semver_triplet, split_version_prefix,
};
use std::cmp::Ordering;

#[test]
fn test_split_version_prefix() {
Expand Down Expand Up @@ -221,4 +247,37 @@ mod tests {
None
);
}

#[test]
fn test_semver_triplet() {
assert_eq!(semver_triplet("1.2.3"), Some((1, 2, 3)));
assert_eq!(semver_triplet("v1.2.3"), Some((1, 2, 3)));
assert_eq!(semver_triplet("V1.2.3"), Some((1, 2, 3)));
assert_eq!(semver_triplet("1.2.3-pre.1"), Some((1, 2, 3)));
assert_eq!(semver_triplet("1.2.3+build.1"), Some((1, 2, 3)));
assert_eq!(semver_triplet("1.2"), None);
assert_eq!(semver_triplet("latest"), None);
assert_eq!(semver_triplet("garbage"), None);
}

#[test]
fn test_semver_cmp() {
assert_eq!(semver_cmp("1.2.9", "1.3.0"), Some(Ordering::Less));
assert_eq!(semver_cmp("1.3.0", "1.3.0"), Some(Ordering::Equal));
assert_eq!(semver_cmp("1.3.1", "1.3.0"), Some(Ordering::Greater));
assert_eq!(semver_cmp("latest", "1.3.0"), None);
}

#[test]
fn test_semver_minimum_helpers() {
assert_eq!(semver_is_older_than("1.2.9", "1.3.0"), Some(true));
assert_eq!(semver_is_older_than("1.3.0", "1.3.0"), Some(false));
assert_eq!(semver_is_older_than("v1.3.1", "1.3.0"), Some(false));
assert_eq!(semver_is_older_than("latest", "1.3.0"), None);

assert_eq!(semver_is_at_least("1.2.9", "1.3.0"), Some(false));
assert_eq!(semver_is_at_least("1.3.0", "1.3.0"), Some(true));
assert_eq!(semver_is_at_least("v1.3.1", "1.3.0"), Some(true));
assert_eq!(semver_is_at_least("latest", "1.3.0"), None);
}
}
Loading