use crate::Result;
use crate::backend::Backend;
use crate::backend::VersionInfo;
use crate::backend::backend_type::BackendType;
use crate::cache::{CacheManager, CacheManagerBuilder};
use crate::cli::args::BackendArg;
use crate::cmd::CmdLineRunner;
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, Toolset};
use async_trait::async_trait;
use jiff::Timestamp;
use serde_json::Value;
use std::ffi::OsString;
use std::{fmt::Debug, sync::Arc};
use tokio::sync::Mutex as TokioMutex;
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 {
ba: Arc<BackendArg>,
latest_version_cache: TokioMutex<CacheManager<Option<String>>>,
}
const NPM_PROGRAM: &str = if cfg!(windows) { "npm.cmd" } else { "npm" };
#[async_trait]
impl Backend for NPMBackend {
fn get_type(&self) -> BackendType {
BackendType::Npm
}
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
fn get_dependencies(&self) -> eyre::Result<Vec<&str>> {
let settings = Settings::get();
let package_manager = settings.npm.package_manager;
let tool_name = self.tool_name();
if tool_name == "npm" {
return match package_manager {
NpmPackageManager::Bun => Ok(vec!["node", "bun"]),
NpmPackageManager::Pnpm => Ok(vec!["node", "pnpm"]),
NpmPackageManager::Npm => Ok(vec!["node"]),
};
}
if tool_name == package_manager.to_string() {
return Ok(vec!["node", "npm"]);
}
let mut deps = vec!["node", "npm"];
match package_manager {
NpmPackageManager::Bun => deps.push("bun"),
NpmPackageManager::Pnpm => deps.push("pnpm"),
NpmPackageManager::Npm => {} }
Ok(deps)
}
fn supports_lockfile_url(&self) -> bool {
false
}
async fn _list_remote_versions(&self, config: &Arc<Config>) -> eyre::Result<Vec<VersionInfo>> {
self.ensure_npm_for_version_check(config).await;
timeout::run_with_timeout_async(
async || {
let env = self.dependency_env(config).await?;
let raw = cmd!(
NPM_PROGRAM,
"view",
self.tool_name(),
"versions",
"time",
"--json"
)
.full_env(&env)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.read()?;
let data: Value = serde_json::from_str(&raw)?;
let versions = data["versions"]
.as_array()
.ok_or_else(|| eyre::eyre!("invalid versions"))?;
let time = data["time"]
.as_object()
.ok_or_else(|| eyre::eyre!("invalid time"))?;
let version_info = versions
.iter()
.filter_map(|v| v.as_str())
.map(|version| {
let created_at = time
.get(version)
.and_then(|v| v.as_str())
.map(|s| s.to_string());
VersionInfo {
version: version.to_string(),
created_at,
..Default::default()
}
})
.collect();
Ok(version_info)
},
Settings::get().fetch_remote_versions_timeout(),
)
.await
}
async fn latest_stable_version(&self, config: &Arc<Config>) -> eyre::Result<Option<String>> {
self.ensure_npm_for_version_check(config).await;
let cache = self.latest_version_cache.lock().await;
let this = self;
timeout::run_with_timeout_async(
async || {
cache
.get_or_try_init_async(async || {
let raw =
cmd!(NPM_PROGRAM, "view", this.tool_name(), "dist-tags", "--json")
.full_env(this.dependency_env(config).await?)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.read()?;
let dist_tags: Value = serde_json::from_str(&raw)?;
match dist_tags["latest"] {
Value::String(ref s) => Ok(Some(s.clone())),
_ => this.latest_version_for_query(config, "latest", None).await,
}
})
.await
},
Settings::get().fetch_remote_versions_timeout(),
)
.await
.cloned()
}
async fn install_version_(&self, ctx: &InstallContext, tv: ToolVersion) -> Result<ToolVersion> {
self.check_install_deps(&ctx.config).await;
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
}
None => Vec::new(),
};
match package_manager {
NpmPackageManager::Bun => {
CmdLineRunner::new("bun")
.arg("install")
.arg(format!("{}@{}", self.tool_name(), tv.version))
.arg("--global")
.arg("--trust")
.arg("--linker")
.arg("hoisted")
.args(install_before_args)
.with_pr(ctx.pr.as_ref())
.envs(ctx.ts.env_with_path_without_tools(&ctx.config).await?)
.env("BUN_INSTALL_GLOBAL_DIR", tv.install_path())
.env("BUN_INSTALL_BIN", tv.install_path().join("bin"))
.prepend_path(ctx.ts.list_paths(&ctx.config).await)?
.prepend_path(
self.dependency_toolset(&ctx.config)
.await?
.list_paths(&ctx.config)
.await,
)?
.current_dir(tv.install_path())
.execute()?;
}
NpmPackageManager::Pnpm => {
let bin_dir = tv.install_path().join("bin");
crate::file::create_dir_all(&bin_dir)?;
CmdLineRunner::new("pnpm")
.arg("add")
.arg("--global")
.arg(format!("{}@{}", self.tool_name(), tv.version))
.arg("--global-dir")
.arg(tv.install_path())
.arg("--global-bin-dir")
.arg(&bin_dir)
.args(install_before_args)
.with_pr(ctx.pr.as_ref())
.envs(ctx.ts.env_with_path_without_tools(&ctx.config).await?)
.prepend_path(ctx.ts.list_paths(&ctx.config).await)?
.prepend_path(
self.dependency_toolset(&ctx.config)
.await?
.list_paths(&ctx.config)
.await,
)?
.prepend_path(vec![bin_dir])?
.execute()?;
}
_ => {
CmdLineRunner::new(NPM_PROGRAM)
.arg("install")
.arg("-g")
.arg(format!("{}@{}", self.tool_name(), tv.version))
.arg("--prefix")
.arg(tv.install_path())
.args(install_before_args)
.with_pr(ctx.pr.as_ref())
.envs(ctx.ts.env_with_path_without_tools(&ctx.config).await?)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.prepend_path(ctx.ts.list_paths(&ctx.config).await)?
.prepend_path(
self.dependency_toolset(&ctx.config)
.await?
.list_paths(&ctx.config)
.await,
)?
.execute()?;
}
}
Ok(tv)
}
#[cfg(windows)]
async fn list_bin_paths(
&self,
_config: &Arc<Config>,
tv: &crate::toolset::ToolVersion,
) -> eyre::Result<Vec<std::path::PathBuf>> {
if Settings::get().npm.package_manager == NpmPackageManager::Npm {
Ok(vec![tv.install_path()])
} else {
Ok(vec![tv.install_path().join("bin")])
}
}
}
impl NPMBackend {
pub fn from_arg(ba: BackendArg) -> Self {
Self {
latest_version_cache: TokioMutex::new(
CacheManagerBuilder::new(ba.cache_path.join("latest_version.msgpack.z"))
.with_fresh_duration(Settings::get().fetch_remote_versions_cache())
.build(),
),
ba: Arc::new(ba),
}
}
async fn build_transitive_release_age_args(
&self,
config: &Arc<Config>,
package_manager: NpmPackageManager,
before_date: Timestamp,
) -> Vec<OsString> {
let seconds = elapsed_seconds_ceil(before_date, process_now());
match package_manager {
NpmPackageManager::Npm => {
let supports_min_release_age =
seconds >= 86400 && self.npm_supports_min_release_age_flag(config).await;
Self::build_npm_release_age_args(before_date, seconds, supports_min_release_age)
}
NpmPackageManager::Bun => Self::build_bun_release_age_args(seconds),
NpmPackageManager::Pnpm => Self::build_pnpm_release_age_args(seconds),
}
}
fn build_npm_release_age_args(
before_date: Timestamp,
seconds: u64,
supports_min_release_age: bool,
) -> Vec<OsString> {
if !supports_min_release_age || seconds < 86400 {
return vec!["--before".into(), before_date.to_string().into()];
}
let days = seconds
.saturating_sub(BEFORE_DATE_TOLERANCE_SECS)
.div_ceil(86400)
.max(1);
vec![format!("--min-release-age={days}").into()]
}
fn build_bun_release_age_args(seconds: u64) -> Vec<OsString> {
vec!["--minimum-release-age".into(), seconds.to_string().into()]
}
fn build_pnpm_release_age_args(seconds: u64) -> Vec<OsString> {
let minutes = seconds.div_ceil(60);
vec![format!("--config.minimumReleaseAge={minutes}").into()]
}
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;
};
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())
}
async fn npm_supports_min_release_age_flag(&self, config: &Arc<Config>) -> bool {
if let Ok(ts) = self.dependency_toolset(config).await {
for (ba, tvl) in &ts.versions {
if ba.short == "npm"
&& let Some(tv) = tvl.versions.first()
{
debug!(
"npm version detection: found npm {} in ToolSet, skipping subprocess",
tv.version
);
return semver_is_at_least(&tv.version, NPM_MIN_RELEASE_AGE_VERSION)
.unwrap_or(false);
}
}
}
let env = match self.dependency_env(config).await {
Ok(env) => env,
Err(e) => {
debug!(
"npm version detection: dependency_env failed, using --before fallback: {e:#}"
);
return false;
}
};
let output = match cmd!(NPM_PROGRAM, "--version")
.full_env(env)
.env("NPM_CONFIG_UPDATE_NOTIFIER", "false")
.read()
{
Ok(s) => s,
Err(e) => {
debug!(
"npm version detection: `npm --version` failed, using --before fallback: {e:#}"
);
return false;
}
};
semver_is_at_least(&output, NPM_MIN_RELEASE_AGE_VERSION).unwrap_or(false)
}
async fn ensure_npm_for_version_check(&self, config: &Arc<Config>) {
self.warn_if_dependency_missing(
config,
"npm", &["node", "npm"],
"To use npm packages with mise, you need to install Node.js first:\n\
mise use node@latest\n\n\
Note: npm is required for querying package information, even when using bun for installation.",
)
.await
}
async fn check_install_deps(&self, config: &Arc<Config>) {
match Settings::get().npm.package_manager {
NpmPackageManager::Bun => {
self.warn_if_dependency_missing(
config,
"bun",
&["bun"],
"To use npm packages with bun, you need to install bun first:\n\
mise use bun@latest\n\n\
Or switch back to npm by setting:\n\
mise settings npm.package_manager=npm",
)
.await
}
NpmPackageManager::Pnpm => {
self.warn_if_dependency_missing(
config,
"pnpm",
&["pnpm"],
"To use npm packages with pnpm, you need to install pnpm first:\n\
mise use pnpm@latest\n\n\
Or switch back to npm by setting:\n\
mise settings npm.package_manager=npm",
)
.await
}
_ => {
self.warn_if_dependency_missing(
config,
"npm",
&["node", "npm"],
"To use npm packages with mise, you need to install Node.js first:\n\
mise use node@latest\n\n\
Alternatively, you can use bun or pnpm instead of npm by setting:\n\
mise settings npm.package_manager=bun",
)
.await
}
}
}
}
#[cfg(test)]
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(
"npm".to_string(),
Some(tool.to_string()),
tool.to_string(),
None,
BackendResolution::new(true),
);
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() {
let backend = create_npm_backend("npm");
let deps = backend.get_dependencies().unwrap();
assert_eq!(deps, vec!["node"]);
}
#[test]
fn test_get_dependencies_default_package_manager() {
let backend = create_npm_backend("prettier");
let deps = backend.get_dependencies().unwrap();
assert!(deps.contains(&"node"));
assert!(deps.contains(&"npm"));
assert!(!deps.contains(&"bun"));
assert!(!deps.contains(&"pnpm"));
}
#[test]
fn test_build_npm_release_age_args_legacy() {
let before_date: Timestamp = "2024-01-02T03:04:05Z".parse().unwrap();
let args = NPMBackend::build_npm_release_age_args(before_date, 86400, false);
assert_eq!(
args,
vec![
OsString::from("--before"),
OsString::from("2024-01-02T03:04:05Z")
]
);
}
#[test]
fn test_build_npm_release_age_args_sub_day_uses_before() {
let before_date: Timestamp = "2024-01-01T00:00:00Z".parse().unwrap();
let args = NPMBackend::build_npm_release_age_args(before_date, 1, true);
assert_eq!(
args,
vec![
OsString::from("--before"),
OsString::from("2024-01-01T00:00:00Z")
]
);
}
#[test]
fn test_build_npm_release_age_args_full_days() {
let before_date: Timestamp = "2024-01-01T00:00:00Z".parse().unwrap();
let args = NPMBackend::build_npm_release_age_args(before_date, 86400 * 3, true);
assert_eq!(args, vec![OsString::from("--min-release-age=3")]);
}
#[test]
fn test_build_npm_release_age_args_tolerates_drift() {
let before_date: Timestamp = "2024-01-01T00:00:00Z".parse().unwrap();
let args = NPMBackend::build_npm_release_age_args(before_date, 86400 * 3 + 30, true);
assert_eq!(args, vec![OsString::from("--min-release-age=3")]);
}
#[test]
fn test_build_npm_release_age_args_past_tolerance_rounds_up() {
let before_date: Timestamp = "2024-01-01T00:00:00Z".parse().unwrap();
let args = NPMBackend::build_npm_release_age_args(before_date, 86400 * 3 + 120, true);
assert_eq!(args, vec![OsString::from("--min-release-age=4")]);
}
#[test]
fn test_build_npm_release_age_args_one_day_boundary() {
let before_date: Timestamp = "2024-01-01T00:00:00Z".parse().unwrap();
let args = NPMBackend::build_npm_release_age_args(before_date, 86400 + 5, true);
assert_eq!(args, vec![OsString::from("--min-release-age=1")]);
}
#[test]
fn test_build_bun_release_age_args() {
let args = NPMBackend::build_bun_release_age_args(1);
assert_eq!(
args,
vec![OsString::from("--minimum-release-age"), OsString::from("1")]
);
}
#[test]
fn test_build_pnpm_release_age_args_rounds_up_to_minutes() {
let args = NPMBackend::build_pnpm_release_age_args(1);
assert_eq!(args, vec![OsString::from("--config.minimumReleaseAge=1")]);
}
#[test]
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
);
}
}