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::install_context::InstallContext;
use crate::timeout;
use crate::toolset::ToolVersion;
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;
#[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 before_str = config
.get_tool_opts(self.ba())
.await
.ok()
.flatten()
.and_then(|opts| opts.get("install_before").map(|s| s.to_string()))
.or_else(|| Settings::get().install_before.clone());
if let Some(before) = before_str
.as_deref()
.map(crate::duration::parse_into_timestamp)
.transpose()?
{
return self
.latest_version_with_opts(config, None, Some(before))
.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(config, Some("latest".into())).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;
match Settings::get().npm.package_manager {
NpmPackageManager::Bun => {
let install_before_args = ctx.before_date.map_or_else(Vec::new, |before_date| {
Self::build_transitive_release_age_args(
NpmPackageManager::Bun,
before_date,
Timestamp::now(),
false,
)
});
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)?;
let install_before_args = ctx.before_date.map_or_else(Vec::new, |before_date| {
Self::build_transitive_release_age_args(
NpmPackageManager::Pnpm,
before_date,
Timestamp::now(),
false,
)
});
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()?;
}
_ => {
let install_before_args = if let Some(before_date) = ctx.before_date {
let supports = self.npm_supports_min_release_age_flag(&ctx.config).await;
Self::build_transitive_release_age_args(
NpmPackageManager::Npm,
before_date,
Timestamp::now(),
supports,
)
} else {
Vec::new()
};
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),
}
}
fn build_transitive_release_age_args(
package_manager: NpmPackageManager,
before_date: Timestamp,
now: Timestamp,
npm_supports_min_release_age: bool,
) -> Vec<OsString> {
match package_manager {
NpmPackageManager::Npm => {
if npm_supports_min_release_age {
let seconds = Self::elapsed_seconds_ceil(before_date, now);
if seconds < 86400 {
return vec!["--before".into(), before_date.to_string().into()];
}
let days = seconds.div_ceil(86400);
vec![format!("--min-release-age={days}").into()]
} else {
vec!["--before".into(), before_date.to_string().into()]
}
}
NpmPackageManager::Bun => {
let seconds = Self::elapsed_seconds_ceil(before_date, now);
vec!["--minimum-release-age".into(), seconds.to_string().into()]
}
NpmPackageManager::Pnpm => {
let seconds = Self::elapsed_seconds_ceil(before_date, now);
let minutes = seconds.div_ceil(60);
vec![format!("--config.minimumReleaseAge={minutes}").into()]
}
}
}
fn elapsed_seconds_ceil(before_date: Timestamp, now: Timestamp) -> u64 {
if before_date >= now {
return 0;
}
let nanos = now.as_nanosecond() - before_date.as_nanosecond();
u64::try_from((nanos + 999_999_999) / 1_000_000_000)
.expect("elapsed timestamp delta must fit into u64")
}
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,
};
let minor: u64 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
match major.cmp(&11) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => minor >= 10,
}
}
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 Self::npm_version_supports_min_release_age(&tv.version);
}
}
}
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;
}
};
Self::npm_version_supports_min_release_age(&output)
}
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 pretty_assertions::assert_eq;
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)
}
#[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_transitive_release_age_args_for_npm_legacy() {
let before_date: Timestamp = "2024-01-02T03:04:05Z".parse().unwrap();
let now: Timestamp = "2024-01-03T03:04:05Z".parse().unwrap();
let args = NPMBackend::build_transitive_release_age_args(
NpmPackageManager::Npm,
before_date,
now,
false,
);
assert_eq!(
args,
vec![
OsString::from("--before"),
OsString::from("2024-01-02T03:04:05Z")
]
);
}
#[test]
fn test_build_transitive_release_age_args_for_npm_min_release_age() {
let before_date: Timestamp = "2024-01-01T00:00:00Z".parse().unwrap();
let now: Timestamp = "2024-01-04T00:00:00Z".parse().unwrap();
let args = NPMBackend::build_transitive_release_age_args(
NpmPackageManager::Npm,
before_date,
now,
true,
);
assert_eq!(args, vec![OsString::from("--min-release-age=3")]);
}
#[test]
fn test_build_transitive_release_age_args_for_npm_sub_day_fallback() {
let before_date: Timestamp = "2024-01-01T00:00:00Z".parse().unwrap();
let now: Timestamp = "2024-01-01T00:00:01Z".parse().unwrap();
let args = NPMBackend::build_transitive_release_age_args(
NpmPackageManager::Npm,
before_date,
now,
true,
);
assert_eq!(
args,
vec![
OsString::from("--before"),
OsString::from("2024-01-01T00:00:00Z")
]
);
}
#[test]
fn test_build_transitive_release_age_args_for_bun() {
let before_date: Timestamp = "2024-01-02T03:04:04.100Z".parse().unwrap();
let now: Timestamp = "2024-01-02T03:04:05Z".parse().unwrap();
let args = NPMBackend::build_transitive_release_age_args(
NpmPackageManager::Bun,
before_date,
now,
false,
);
assert_eq!(
args,
vec![OsString::from("--minimum-release-age"), OsString::from("1")]
);
}
#[test]
fn test_build_transitive_release_age_args_for_pnpm() {
let before_date: Timestamp = "2024-01-02T03:03:05.100Z".parse().unwrap();
let now: Timestamp = "2024-01-02T03:04:05Z".parse().unwrap();
let args = NPMBackend::build_transitive_release_age_args(
NpmPackageManager::Pnpm,
before_date,
now,
false,
);
assert_eq!(args, vec![OsString::from("--config.minimumReleaseAge=1")]);
}
#[test]
fn test_npm_version_supports_min_release_age() {
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"));
assert!(NPMBackend::npm_version_supports_min_release_age("v11.10.0"));
assert!(NPMBackend::npm_version_supports_min_release_age(
"11.10.0\n"
));
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"));
}
}