use crate::backend::VersionInfo;
use crate::backend::static_helpers::fetch_checksum_from_shasums;
use crate::backend::{
Backend, VersionCacheManager, normalize_idiomatic_contents, platform_target::PlatformTarget,
};
use crate::build_time::built_info;
use crate::cache::CacheManagerBuilder;
use crate::cli::args::BackendArg;
use crate::cmd::CmdLineRunner;
use crate::config::settings::DEFAULT_NODE_MIRROR_URL;
use crate::config::{Config, Settings};
use crate::file::{TarFormat, TarOptions};
use crate::http::{HTTP, HTTP_FETCH};
use crate::install_context::InstallContext;
use crate::lockfile::PlatformInfo;
use crate::toolset::{ToolRequest, ToolVersion};
use crate::ui::progress_report::SingleReport;
use crate::{env, file, gpg, hash, http, plugins};
use async_trait::async_trait;
use eyre::{Result, bail, ensure};
use serde_derive::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
use tempfile::tempdir_in;
use tokio::sync::Mutex;
use url::Url;
use xx::regex;
#[derive(Debug)]
pub struct NodePlugin {
ba: Arc<BackendArg>,
}
enum FetchOutcome {
Downloaded,
NotFound,
}
impl NodePlugin {
pub fn new() -> Self {
Self {
ba: plugins::core::new_backend_arg("node").into(),
}
}
async fn fetch_binary(
&self,
ctx: &InstallContext,
tv: &mut ToolVersion,
opts: &BuildOpts,
extract: impl FnOnce() -> Result<()>,
) -> Result<FetchOutcome> {
debug!("{:?}: we will fetch a precompiled version", self);
match self
.fetch_tarball(
ctx,
tv,
ctx.pr.as_ref(),
&opts.binary_tarball_url,
&opts.binary_tarball_path,
&opts.version,
)
.await
{
Ok(()) => {
debug!("{:?}: successfully downloaded node archive", self);
}
Err(e) if matches!(http::error_code(&e), Some(404)) => {
debug!("{:?}: precompiled node archive not found {e}", self);
return Ok(FetchOutcome::NotFound);
}
Err(e) => return Err(e),
};
ctx.pr.next_operation();
let tarball_name = &opts.binary_tarball_name;
ctx.pr.set_message(format!("extract {tarball_name}"));
debug!("{:?}: extracting precompiled node", self);
if let Err(e) = extract() {
debug!("{:?}: extraction failed: {e}", self);
return Err(e);
}
debug!("{:?}: precompiled node extraction was successful", self);
Ok(FetchOutcome::Downloaded)
}
fn extract_zip(&self, opts: &BuildOpts, _ctx: &InstallContext) -> Result<()> {
let tmp_extract_path = tempdir_in(opts.install_path.parent().unwrap())?;
file::unzip(
&opts.binary_tarball_path,
tmp_extract_path.path(),
&Default::default(),
)?;
file::remove_all(&opts.install_path)?;
file::rename(
tmp_extract_path.path().join(slug(&opts.version)),
&opts.install_path,
)?;
Ok(())
}
async fn install_precompiled(
&self,
ctx: &InstallContext,
tv: &mut ToolVersion,
opts: &BuildOpts,
) -> Result<()> {
match self
.fetch_binary(ctx, tv, opts, || {
file::untar(
&opts.binary_tarball_path,
&opts.install_path,
&TarOptions {
strip_components: 1,
pr: Some(ctx.pr.as_ref()),
..TarOptions::new(TarFormat::TarGz)
},
)?;
Ok(())
})
.await?
{
FetchOutcome::Downloaded => Ok(()),
FetchOutcome::NotFound => {
if Settings::get().node.compile != Some(false) {
self.install_compiling(ctx, tv, opts).await
} else {
bail!("precompiled node archive not found and compilation is disabled")
}
}
}
}
async fn install_windows(
&self,
ctx: &InstallContext,
tv: &mut ToolVersion,
opts: &BuildOpts,
) -> Result<()> {
match self
.fetch_binary(ctx, tv, opts, || self.extract_zip(opts, ctx))
.await?
{
FetchOutcome::Downloaded => Ok(()),
FetchOutcome::NotFound => bail!("precompiled node archive not found (404)"),
}
}
async fn install_compiling(
&self,
ctx: &InstallContext,
tv: &mut ToolVersion,
opts: &BuildOpts,
) -> Result<()> {
debug!("{:?}: we will fetch the source and compile", self);
let tarball_name = &opts.source_tarball_name;
if let Err(err) = self
.fetch_tarball(
ctx,
tv,
ctx.pr.as_ref(),
&opts.source_tarball_url,
&opts.source_tarball_path,
&opts.version,
)
.await
{
if let Some(reqwest_err) = err.root_cause().downcast_ref::<reqwest::Error>()
&& reqwest_err.status() == Some(reqwest::StatusCode::NOT_FOUND)
&& let Ok(Some(msg)) = self
.suggest_available_flavors(&opts.version, &Settings::get())
.await
{
return Err(eyre::eyre!("{err}\n{msg}"));
}
return Err(err);
}
ctx.pr.next_operation();
ctx.pr.set_message(format!("extract {tarball_name}"));
file::remove_all(&opts.build_dir)?;
file::untar(
&opts.source_tarball_path,
opts.build_dir.parent().unwrap(),
&TarOptions {
pr: Some(ctx.pr.as_ref()),
..TarOptions::new(TarFormat::TarGz)
},
)?;
self.exec_configure(ctx, opts)?;
self.exec_make(ctx, opts)?;
self.exec_make_install(ctx, opts)?;
Ok(())
}
async fn fetch_tarball(
&self,
ctx: &InstallContext,
tv: &mut ToolVersion,
pr: &dyn SingleReport,
url: &Url,
local: &Path,
version: &str,
) -> Result<()> {
let settings = Settings::get();
let tarball_name = local.file_name().unwrap().to_string_lossy().to_string();
if local.exists() {
pr.set_message(format!("using previously downloaded {tarball_name}"));
} else {
pr.set_message(format!("download {tarball_name}"));
HTTP.download_file(url.clone(), local, Some(pr)).await?;
}
ctx.pr.next_operation();
let platform_info = tv
.lock_platforms
.entry(self.get_platform_key())
.or_default();
platform_info.url = Some(url.to_string());
if settings.node.verify && platform_info.checksum.is_none() {
platform_info.checksum = Some(self.get_checksum(ctx, local, version).await?);
}
self.verify_checksum(ctx, tv, local)?;
Ok(())
}
fn sh<'a>(&self, ctx: &'a InstallContext, opts: &BuildOpts) -> eyre::Result<CmdLineRunner<'a>> {
let settings = Settings::get();
let mut cmd = CmdLineRunner::new("sh")
.prepend_path(opts.path.clone())?
.with_pr(ctx.pr.as_ref())
.current_dir(&opts.build_dir)
.arg("-c");
if let Some(cflags) = settings.node.cflags() {
cmd = cmd.env("CFLAGS", cflags);
}
Ok(cmd)
}
fn exec_configure(&self, ctx: &InstallContext, opts: &BuildOpts) -> Result<()> {
self.sh(ctx, opts)?.arg(&opts.configure_cmd).execute()
}
fn exec_make(&self, ctx: &InstallContext, opts: &BuildOpts) -> Result<()> {
self.sh(ctx, opts)?.arg(&opts.make_cmd).execute()
}
fn exec_make_install(&self, ctx: &InstallContext, opts: &BuildOpts) -> Result<()> {
self.sh(ctx, opts)?.arg(&opts.make_install_cmd).execute()
}
async fn get_checksum(
&self,
ctx: &InstallContext,
tarball: &Path,
version: &str,
) -> Result<String> {
let tarball_name = tarball.file_name().unwrap().to_string_lossy().to_string();
let shasums_file = tarball.parent().unwrap().join("SHASUMS256.txt");
HTTP.download_file(
self.shasums_url(version)?,
&shasums_file,
Some(ctx.pr.as_ref()),
)
.await?;
if Settings::get().node.gpg_verify != Some(false) && version.starts_with("2") {
self.verify_with_gpg(ctx, &shasums_file, version).await?;
}
let shasums = file::read_to_string(&shasums_file)?;
let shasums = hash::parse_shasums(&shasums);
let shasum = shasums.get(&tarball_name).unwrap();
Ok(format!("sha256:{shasum}"))
}
async fn verify_with_gpg(
&self,
ctx: &InstallContext,
shasums_file: &Path,
v: &str,
) -> Result<()> {
if file::which_non_pristine("gpg").is_none() && Settings::get().node.gpg_verify.is_none() {
warn!("gpg not found, skipping verification");
return Ok(());
}
let sig_file = shasums_file.with_extension("asc");
let sig_url = format!("{}.sig", self.shasums_url(v)?);
if let Err(e) = HTTP
.download_file(sig_url, &sig_file, Some(ctx.pr.as_ref()))
.await
{
if matches!(http::error_code(&e), Some(404)) {
warn!("gpg signature not found, skipping verification");
return Ok(());
}
return Err(e);
}
gpg::add_keys_node(ctx)?;
CmdLineRunner::new("gpg")
.arg("--quiet")
.arg("--trust-model")
.arg("always")
.arg("--verify")
.arg(sig_file)
.arg(shasums_file)
.with_pr(ctx.pr.as_ref())
.execute()?;
Ok(())
}
fn node_path(&self, tv: &ToolVersion) -> PathBuf {
if cfg!(windows) {
tv.install_path().join("node.exe")
} else {
tv.install_path().join("bin").join("node")
}
}
fn npm_path(&self, tv: &ToolVersion) -> PathBuf {
if cfg!(windows) {
tv.install_path().join("npm.cmd")
} else {
tv.install_path().join("bin").join("npm")
}
}
fn corepack_path(&self, tv: &ToolVersion) -> PathBuf {
if cfg!(windows) {
tv.install_path().join("corepack.cmd")
} else {
tv.install_path().join("bin").join("corepack")
}
}
async fn install_default_packages(
&self,
config: &Arc<Config>,
tv: &ToolVersion,
pr: &dyn SingleReport,
) -> Result<()> {
let settings = Settings::get();
let default_packages_file = file::replace_path(settings.node.default_packages_file());
let body = file::read_to_string(&default_packages_file).unwrap_or_default();
for package in body.lines() {
let package = package.split('#').next().unwrap_or_default().trim();
if package.is_empty() {
continue;
}
pr.set_message(format!("install default package: {package}"));
let npm = self.npm_path(tv);
CmdLineRunner::new(npm)
.with_pr(pr)
.arg("install")
.arg("--global")
.arg(package)
.envs(config.env().await?)
.env(&*env::PATH_KEY, plugins::core::path_env_with_tv_path(tv)?)
.execute()?;
}
Ok(())
}
fn install_npm_shim(&self, tv: &ToolVersion) -> Result<()> {
file::remove_file(self.npm_path(tv)).ok();
file::write(self.npm_path(tv), include_str!("assets/node_npm_shim"))?;
file::make_executable(self.npm_path(tv))?;
Ok(())
}
fn enable_default_corepack_shims(&self, tv: &ToolVersion, pr: &dyn SingleReport) -> Result<()> {
pr.set_message("enable corepack shims".into());
let corepack = self.corepack_path(tv);
CmdLineRunner::new(corepack)
.with_pr(pr)
.arg("enable")
.env(&*env::PATH_KEY, plugins::core::path_env_with_tv_path(tv)?)
.execute()?;
Ok(())
}
async fn test_node(
&self,
config: &Arc<Config>,
tv: &ToolVersion,
pr: &dyn SingleReport,
) -> Result<()> {
pr.set_message("node -v".into());
CmdLineRunner::new(self.node_path(tv))
.with_pr(pr)
.arg("-v")
.envs(config.env().await?)
.execute()
}
async fn test_npm(
&self,
config: &Arc<Config>,
tv: &ToolVersion,
pr: &dyn SingleReport,
) -> Result<()> {
pr.set_message("npm -v".into());
CmdLineRunner::new(self.npm_path(tv))
.with_pr(pr)
.arg("-v")
.envs(config.env().await?)
.env(&*env::PATH_KEY, plugins::core::path_env_with_tv_path(tv)?)
.execute()
}
fn shasums_url(&self, v: &str) -> Result<Url> {
let settings = Settings::get();
let url = settings
.node
.mirror_url()
.join(&format!("v{v}/SHASUMS256.txt"))?;
Ok(url)
}
async fn suggest_available_flavors(
&self,
v: &str,
settings: &Settings,
) -> Result<Option<String>> {
let base = settings.node.mirror_url();
if base.to_string() == DEFAULT_NODE_MIRROR_URL {
return Ok(None);
}
let versions: Vec<NodeVersion> = HTTP_FETCH
.json(base.join("index.json")?)
.await
.unwrap_or_default();
if let Some(version) = versions.iter().find(|nv| {
nv.version == format!("v{v}") || nv.version == v || nv.version == format!("v{v}.")
}) {
let os = os();
let arch = arch(settings);
let candidates: Vec<&String> = version
.files
.iter()
.filter(|f| f.starts_with(&format!("{os}-{arch}-")))
.collect();
if !candidates.is_empty() {
let mut msg = format!("Could not find node@{v} with the current settings.\n");
msg.push_str(&format!(
"However, the following flavors are available on the mirror for {os}-{arch}:\n"
));
for candidate in candidates {
let prefix = format!("{os}-{arch}-");
if let Some(flavor) = candidate.strip_prefix(&prefix) {
msg.push_str(&format!(" - {flavor}\n"));
} else {
msg.push_str(&format!(" - {candidate} (unknown format)\n"));
}
}
msg.push_str("\nYou can try setting the flavor using:\n");
msg.push_str(" mise settings set node.flavor <flavor>\n");
return Ok(Some(msg));
} else {
let mut msg = format!("Could not find node@{v} for {os}-{arch}.\n");
msg.push_str("Available files for this version on the mirror:\n");
for file in &version.files {
msg.push_str(&format!(" - {file}\n"));
}
return Ok(Some(msg));
}
}
Ok(None)
}
}
#[async_trait]
impl Backend for NodePlugin {
fn ba(&self) -> &Arc<BackendArg> {
&self.ba
}
async fn security_info(&self) -> Vec<crate::backend::SecurityFeature> {
use crate::backend::SecurityFeature;
let mut features = vec![SecurityFeature::Checksum {
algorithm: Some("sha256".to_string()),
}];
if Settings::get().node.gpg_verify != Some(false) {
features.push(SecurityFeature::Gpg);
}
features
}
async fn _list_remote_versions(&self, _config: &Arc<Config>) -> Result<Vec<VersionInfo>> {
let settings = Settings::get();
let base = settings.node.mirror_url();
let versions = HTTP_FETCH
.json::<Vec<NodeVersion>, _>(base.join("index.json")?)
.await?
.into_iter()
.filter(|v| {
if let Some(flavor) = &settings.node.flavor {
v.files
.iter()
.any(|f| f == &format!("{}-{}-{}", os(), arch(&settings), flavor))
} else {
true
}
})
.map(|v| {
let version = if regex!(r"^v\d+\.").is_match(&v.version) {
v.version.strip_prefix('v').unwrap().to_string()
} else {
v.version
};
VersionInfo {
version,
created_at: v.date,
..Default::default()
}
})
.rev()
.collect();
Ok(versions)
}
fn get_aliases(&self) -> Result<BTreeMap<String, String>> {
let aliases = [
("lts/argon", "4"),
("lts/boron", "6"),
("lts/carbon", "8"),
("lts/dubnium", "10"),
("lts/erbium", "12"),
("lts/fermium", "14"),
("lts/gallium", "16"),
("lts/hydrogen", "18"),
("lts/iron", "20"),
("lts/jod", "22"),
("lts/krypton", "24"),
("lts-argon", "4"),
("lts-boron", "6"),
("lts-carbon", "8"),
("lts-dubnium", "10"),
("lts-erbium", "12"),
("lts-fermium", "14"),
("lts-gallium", "16"),
("lts-hydrogen", "18"),
("lts-iron", "20"),
("lts-jod", "22"),
("lts-krypton", "24"),
("lts", "24"),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Ok(aliases)
}
async fn _idiomatic_filenames(&self) -> Result<Vec<String>> {
Ok(vec![
".node-version".into(),
".nvmrc".into(),
"package.json".into(),
])
}
async fn _parse_idiomatic_file(&self, path: &Path) -> Result<Vec<String>> {
let contents = file::read_to_string(path)?;
let body = normalize_idiomatic_contents(&contents);
let versions = body
.lines()
.map(|line| {
let mut version = line.trim().strip_prefix('v').unwrap_or(line).to_string();
version = version.replace("lts/*", "lts");
version
})
.collect();
Ok(versions)
}
async fn install_version_(
&self,
ctx: &InstallContext,
mut tv: ToolVersion,
) -> eyre::Result<ToolVersion> {
ensure!(
tv.version != "latest",
"version should not be 'latest' for node, something is wrong"
);
let settings = Settings::get();
let opts = BuildOpts::new(ctx, &tv).await?;
trace!("node build opts: {:#?}", opts);
if cfg!(windows) {
self.install_windows(ctx, &mut tv, &opts).await?;
} else if settings.node.compile == Some(true) {
self.install_compiling(ctx, &mut tv, &opts).await?;
} else {
self.install_precompiled(ctx, &mut tv, &opts).await?;
}
debug!("{:?}: checking installation is working as expected", self);
self.test_node(&ctx.config, &tv, ctx.pr.as_ref()).await?;
if !cfg!(windows) {
self.install_npm_shim(&tv)?;
}
self.test_npm(&ctx.config, &tv, ctx.pr.as_ref()).await?;
if let Err(err) = self
.install_default_packages(&ctx.config, &tv, ctx.pr.as_ref())
.await
{
warn!("failed to install default npm packages: {err:#}");
}
if settings.node.corepack && self.corepack_path(&tv).exists() {
self.enable_default_corepack_shims(&tv, ctx.pr.as_ref())?;
}
Ok(tv)
}
#[cfg(windows)]
async fn list_bin_paths(
&self,
_config: &Arc<Config>,
tv: &ToolVersion,
) -> eyre::Result<Vec<PathBuf>> {
Ok(vec![tv.install_path()])
}
fn get_remote_version_cache(&self) -> Arc<Mutex<VersionCacheManager>> {
static CACHE: OnceLock<Arc<Mutex<VersionCacheManager>>> = OnceLock::new();
CACHE
.get_or_init(|| {
let settings = Settings::get();
Mutex::new(
CacheManagerBuilder::new(
self.ba().cache_path.join("remote_versions.msgpack.z"),
)
.with_fresh_duration(settings.fetch_remote_versions_cache())
.with_cache_key(settings.node.mirror_url.clone().unwrap_or_default())
.with_cache_key(settings.node.flavor.clone().unwrap_or_default())
.build(),
)
.into()
})
.clone()
}
async fn get_tarball_url(
&self,
tv: &ToolVersion,
target: &PlatformTarget,
) -> Result<Option<String>> {
let version = &tv.version;
let settings = Settings::get();
let slug = self.build_platform_slug(version, target);
let filename = if target.os_name() == "windows" {
format!("{slug}.zip")
} else {
format!("{slug}.tar.gz")
};
let url = settings
.node
.mirror_url()
.join(&format!("v{version}/{filename}"))
.map_err(|e| eyre::eyre!("Failed to construct Node.js download URL: {e}"))?;
Ok(Some(url.to_string()))
}
fn resolve_lockfile_options(
&self,
_request: &ToolRequest,
target: &PlatformTarget,
) -> BTreeMap<String, String> {
let mut opts = BTreeMap::new();
let settings = Settings::get();
let is_current_platform = target.is_current();
let compile = if is_current_platform {
settings.node.compile.unwrap_or(false)
} else {
false
};
if compile {
opts.insert("compile".to_string(), "true".to_string());
}
if let Some(flavor) = settings.node.flavor.clone() {
opts.insert("flavor".to_string(), flavor);
}
opts
}
async fn resolve_lock_info(
&self,
tv: &ToolVersion,
target: &PlatformTarget,
) -> Result<PlatformInfo> {
let version = &tv.version;
let settings = Settings::get();
let slug = self.build_platform_slug(version, target);
let filename = if target.os_name() == "windows" {
format!("{slug}.zip")
} else {
format!("{slug}.tar.gz")
};
let url = settings
.node
.mirror_url()
.join(&format!("v{version}/{filename}"))
.map_err(|e| eyre::eyre!("Failed to construct Node.js download URL: {e}"))?;
let shasums_url = settings
.node
.mirror_url()
.join(&format!("v{version}/SHASUMS256.txt"))?;
let checksum = fetch_checksum_from_shasums(shasums_url.as_str(), &filename).await;
Ok(PlatformInfo {
url: Some(url.to_string()),
checksum,
size: None,
url_api: None,
conda_deps: None,
..Default::default()
})
}
}
impl NodePlugin {
fn map_os(os_name: &str) -> &str {
match os_name {
"macos" => "darwin",
"linux" => "linux",
"windows" => "win",
other => other,
}
}
fn map_arch(arch_name: &str) -> &str {
match arch_name {
"x86" => "x86",
"x64" => "x64",
"arm" => "armv7l",
"arm64" => "arm64",
"aarch64" => "arm64",
"loongarch64" => "loong64",
"riscv64" => "riscv64",
other => other,
}
}
fn build_platform_slug(&self, version: &str, target: &PlatformTarget) -> String {
let settings = Settings::get();
let os = Self::map_os(target.os_name());
let arch = Self::map_arch(target.arch_name());
if target.is_current()
&& target.os_name() == "linux"
&& let Some(flavor) = &settings.node.flavor
{
return format!("node-v{version}-{os}-{arch}-{flavor}");
}
format!("node-v{version}-{os}-{arch}")
}
}
#[derive(Debug)]
struct BuildOpts {
version: String,
path: Vec<PathBuf>,
install_path: PathBuf,
build_dir: PathBuf,
configure_cmd: String,
make_cmd: String,
make_install_cmd: String,
source_tarball_name: String,
source_tarball_path: PathBuf,
source_tarball_url: Url,
binary_tarball_name: String,
binary_tarball_path: PathBuf,
binary_tarball_url: Url,
}
impl BuildOpts {
async fn new(ctx: &InstallContext, tv: &ToolVersion) -> Result<Self> {
let v = &tv.version;
let install_path = tv.install_path();
let source_tarball_name = format!("node-v{v}.tar.gz");
let slug = slug(v);
#[cfg(windows)]
let binary_tarball_name = format!("{slug}.zip");
#[cfg(not(windows))]
let binary_tarball_name = format!("{slug}.tar.gz");
let settings = Settings::get();
Ok(Self {
version: v.clone(),
path: ctx.ts.list_paths(&ctx.config).await,
build_dir: env::MISE_TMP_DIR.join(format!("node-v{v}")),
configure_cmd: settings.node.configure_cmd(&install_path),
make_cmd: settings.node.make_cmd(),
make_install_cmd: settings.node.make_install_cmd(),
source_tarball_path: tv.download_path().join(&source_tarball_name),
source_tarball_url: settings
.node
.mirror_url()
.join(&format!("v{v}/{source_tarball_name}"))?,
source_tarball_name,
binary_tarball_path: tv.download_path().join(&binary_tarball_name),
binary_tarball_url: settings
.node
.mirror_url()
.join(&format!("v{v}/{binary_tarball_name}"))?,
binary_tarball_name,
install_path,
})
}
}
fn os() -> &'static str {
NodePlugin::map_os(built_info::CFG_OS)
}
fn arch(settings: &Settings) -> &str {
let arch = settings.arch();
if arch == "arm" && cfg!(target_feature = "v6") {
return "armv6l";
}
NodePlugin::map_arch(arch)
}
fn slug(v: &str) -> String {
let settings = Settings::get();
if let Some(flavor) = &settings.node.flavor {
format!("node-v{v}-{}-{}-{flavor}", os(), arch(&settings))
} else {
format!("node-v{v}-{}-{}", os(), arch(&settings))
}
}
#[derive(Debug, Deserialize)]
struct NodeVersion {
version: String,
date: Option<String>,
files: Vec<String>,
}