use std::{
env,
fs::{self, File},
io::{self, BufReader, Read},
path::{Path, PathBuf},
};
use anyhow::bail;
use comfy_table::{Cell, CellAlignment, Table, presets};
use indicatif::{ProgressBar, ProgressStyle};
use path_clean::PathClean;
use regex::Regex;
use url::Url;
use zip::ZipArchive;
#[cfg(feature = "debug")]
use std::sync::OnceLock;
#[cfg(feature = "debug")]
use tracing_appender::{
non_blocking::WorkerGuard,
rolling::{RollingFileAppender, Rotation},
};
#[cfg(feature = "debug")]
use tracing_subscriber::{
EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt,
};
use crate::model::{ArchSpec, Config, ReleaseDatabase, VersionSpec};
pub mod model;
#[cfg(feature = "debug")]
static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
#[cfg(feature = "debug")]
const LOG_FILTER: &str = "info,nvm_rs=debug,d=debug";
const LIST_COUNT: usize = 20;
pub type Result<T = ()> = anyhow::Result<T>;
#[cfg(feature = "debug")]
pub fn log_init() {
let file_appender = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix("app")
.filename_suffix("log")
.max_log_files(30)
.build("./logs")
.expect("failed to create rolling file appender");
let (non_blocking_file, guard) =
tracing_appender::non_blocking(file_appender);
LOG_GUARD.set(guard).expect("logging already initialized");
let console_layer = fmt::layer()
.with_writer(std::io::stderr)
.pretty()
.with_filter(EnvFilter::new(LOG_FILTER));
let file_layer = fmt::layer()
.with_writer(non_blocking_file)
.with_ansi(false)
.with_target(true)
.with_line_number(true)
.with_filter(EnvFilter::new(LOG_FILTER));
tracing_subscriber::registry()
.with(console_layer)
.with(file_layer)
.init();
}
pub fn download_file<U, D>(url: U, dest: D) -> Result<()>
where
U: AsRef<str>,
D: AsRef<Path>,
{
let url = url.as_ref();
let dest = dest.as_ref();
let mut resp = ureq::get(url).call()?;
let total_size = resp
.headers()
.get("Content-Length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let pb = if total_size > 0 {
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
.progress_chars("#>-"),
);
pb
} else {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} [{elapsed_precise}] {bytes} downloaded")?,
);
pb
};
let reader = resp.body_mut().as_reader();
let mut progress_reader = pb.wrap_read(reader);
let mut file = File::create(dest)?;
io::copy(&mut progress_reader, &mut file)?;
pb.finish_with_message("Download complete!");
Ok(())
}
pub fn list_local_versions(config: Config) -> Result<()> {
let (current_version, current_arch) = get_current_version_and_arch();
log::debug!("current version: {}({}bit)", current_version, current_arch);
let path = get_root(&config)?;
let versions = get_local_versions(path)?;
println!();
for version in versions {
log::debug!("found version: {version}");
if version == current_version {
println!(
" * {} (Currently using {}-bit executable)",
version, current_arch
);
} else {
println!(" {}", version);
}
}
println!();
Ok(())
}
fn get_local_versions<T>(root: T) -> Result<Vec<String>>
where
T: AsRef<Path>,
{
let mut versions = Vec::new();
let re = Regex::new(r"v\d+\.\d+\.\d+").unwrap();
for entry in fs::read_dir(root.as_ref())? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if let Some(name) = entry.file_name().to_str()
&& re.is_match(name)
{
versions.push(name.to_string());
} else {
log::debug!("skipping invalid directory entry: {:?}", entry.path());
}
}
Ok(versions)
}
pub fn get_current_version_and_arch() -> (String, String) {
let Ok(output) = std::process::Command::new("node")
.arg("-p")
.arg("`${process.version},${process.arch}`") .output()
else {
return (String::new(), String::new());
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut parts = stdout.trim_end().split(',');
let Some(version) = parts.next() else {
return (String::new(), String::new());
};
let arch = match parts.next() {
Some("x64") => "64",
Some(_) => "32",
None => return (String::new(), String::new()),
};
log::debug!("version: {}", version);
(version.to_string(), arch.to_string())
}
pub fn get_processor_architecture() -> String {
if env::var("PROCESSOR_ARCHITEW6432").is_ok() {
return "x64".to_string();
}
match env::var("PROCESSOR_ARCHITECTURE") {
Ok(val) if val.eq_ignore_ascii_case("AMD64") => "x64".to_string(),
Ok(val) => val.to_ascii_lowercase(),
Err(_) => String::new(),
}
}
pub fn display_current() -> Result<()> {
let (current_version, _) = get_current_version_and_arch();
if !current_version.is_empty() {
println!("current version is {}", current_version);
} else {
println!("No current version. Run 'nvm use x.x.x' to set a version.");
}
Ok(())
}
pub fn display_or_update_proxy(
mut config: Config,
url: Option<String>,
) -> Result<()> {
if let Some(url) = url {
config.proxy = if url.eq_ignore_ascii_case("none") {
None
} else {
Some(url)
};
config.save()?;
} else if let Some(proxy) = config.proxy {
println!("Current Proxy: {:?}", proxy);
} else {
println!("No Proxy set.");
}
Ok(())
}
pub fn display_or_update_root<T>(
mut config: Config,
path: Option<T>,
) -> Result<()>
where
T: AsRef<Path>,
{
if let Some(path) = path {
let p = path.as_ref();
if !p.exists() {
bail!("path {:?} does not exist", p);
}
if !p.is_dir() {
bail!("path {:?} is not a directory", p);
}
config.root = Some(p.to_path_buf());
config.save()?;
println!("Current Root: {:?}", p);
} else if let Some(root) = config.root {
println!("Current Root: {:?}", root);
} else {
println!("No Root set.");
}
Ok(())
}
pub fn display_or_update_node_mirror(
mut config: Config,
url: Option<String>,
) -> Result<()> {
if let Some(url) = url {
config.node_mirror = Some(url);
config.save()?;
} else if let Some(node_mirror) = config.node_mirror {
println!("Current NodeMirror: {:?}", node_mirror);
} else {
println!("No NodeMirror set.");
}
Ok(())
}
pub fn display_or_update_npm_mirror(
mut config: Config,
url: Option<String>,
) -> Result<()> {
if let Some(url) = url {
config.npm_mirror = Some(url);
config.save()?;
} else if let Some(npm_mirror) = config.npm_mirror {
println!("Current NpmMirror: {:?}", npm_mirror);
} else {
println!("No NpmMirror set.");
}
Ok(())
}
pub fn activate_version(config: Config) -> Result<()> {
let symlink = get_nvm_symlink()?;
if let Ok(metadata) = fs::symlink_metadata(&symlink)
&& metadata.is_symlink()
{
bail!("node is already activated");
}
let root = get_root(&config)?;
let versions = get_local_versions(&root)?;
let Some(last) = versions.last() else {
bail!("no node version installed");
};
create_junction_link(symlink, root.join(last))?;
println!("node is activated.");
Ok(())
}
pub fn deactivate_version() -> Result<()> {
let symlink = get_nvm_symlink()?;
let Ok(_) = fs::symlink_metadata(&symlink) else {
bail!("node is already deactivated");
};
delete_junction_link(symlink)?;
println!("node is deactivated.");
Ok(())
}
pub fn display_architecture(config: Config) -> Result<()> {
let arch = get_arch(&config.arch);
println!("current arch is {}", arch);
tips(arch);
Ok(())
}
pub fn uninstall_version(config: Config, version: VersionSpec) -> Result<()> {
let root = get_root(&config)?;
let versions = get_local_versions(&root)?;
log::debug!("local versions: {:?}", versions);
match version {
VersionSpec::Latest | VersionSpec::Lts => {
bail!(
"The version must be a specific version. Can not use 'latest' or 'lts' to uninstall."
);
}
VersionSpec::Exact(ver) => {
if !versions.contains(&ver) {
bail!("version {:?} not installed", ver);
}
if get_current_version_and_arch().0 == ver {
bail!("current version {} is in use, can not uninstall it", ver);
}
delete_version(&root, &ver)?;
println!("uninstall {} completed.", ver);
}
}
Ok(())
}
fn delete_version(root: &Path, ver: &str) -> Result<()> {
let path = root.join(ver);
log::debug!("delete: {:?}", path);
fs::remove_dir_all(&path)?;
Ok(())
}
fn delete_junction_link<T>(link: T) -> Result<()>
where
T: AsRef<Path>,
{
let path = link.as_ref();
log::debug!("delete link: {:?}", path);
if let Err(e) = fs::remove_dir(path) {
log::debug!("remove_dir failed: {e}");
}
Ok(())
}
fn create_junction_link<T, L>(link: T, target: L) -> Result<()>
where
T: AsRef<Path>,
L: AsRef<Path>,
{
let target = target.as_ref();
let link = link.as_ref();
log::debug!("create link: {:?} -> {:?}", target, link);
#[cfg(target_os = "windows")]
junction::create(target, link)?;
Ok(())
}
fn reset_junction_link<T, L>(link: T, target: L) -> Result<()>
where
T: AsRef<Path>,
L: AsRef<Path>,
{
delete_junction_link(&link)?;
create_junction_link(link, target)?;
Ok(())
}
fn tips(arch: &str) {
if arch != "x64" {
println!(
"\n* Notice: Since version v23.0.0, 32-bit versions are no longer available. Please use the 64-bit version."
);
}
}
fn file_validate<T>(path: T, sha256_checksum: &str) -> Result<bool>
where
T: AsRef<Path>,
{
use sha2::Digest;
let file = std::fs::File::open(path)?;
let mut reader = BufReader::with_capacity(256 * 1024, file);
let mut hasher = sha2::Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()) == sha256_checksum)
}
fn version_exists<T>(
db: &ReleaseDatabase,
version: &VersionSpec,
root: T,
) -> Result<(bool, String)>
where
T: AsRef<Path>,
{
let ver = match version {
VersionSpec::Latest => {
if let Some(latest_version) = db.latest() {
latest_version
} else {
bail!("No latest version found.")
}
}
VersionSpec::Lts => {
if let Some(lts_version) = db.latest_lts() {
lts_version
} else {
bail!("No LTS version found.")
}
}
VersionSpec::Exact(s) => {
if db.version_exists(s) {
s.clone()
} else {
bail!("node v{} not installed.", s)
}
}
};
let root = root.as_ref();
let exists = fs::exists(root.join(&ver))?;
Ok((exists, ver))
}
fn get_node_file_url(version: &str, arch: &str, base_url: &str) -> String {
let url =
format!("{}/{}/node-{}-win-{}.zip", base_url, version, version, arch);
log::debug!("url: {:?}", url);
url
}
fn get_node_file_checksum_url(version: &str, base_url: &str) -> String {
let url = format!("{}/{}/SHASUMS256.txt", base_url, version);
log::debug!("url: {:?}", url);
url
}
fn zip_extract<T, R>(source: T, dest: R) -> Result<()>
where
T: AsRef<Path>,
R: AsRef<Path>,
{
let file = std::fs::File::open(source)?;
let reader = BufReader::new(file);
let mut archive = ZipArchive::new(reader)?;
let pb = ProgressBar::new(archive.len() as u64);
pb.set_style(
ProgressStyle::default_bar()
.template("extracting [{bar:40}] [{percent:.2}%]")
.expect("invalid template")
.progress_chars("=> "),
);
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
safe_extract(&mut entry, &dest)?;
pb.inc(1);
}
pb.finish();
log::debug!("extract done");
Ok(())
}
fn safe_extract<T>(
entry: &mut zip::read::ZipFile<'_, BufReader<File>>,
dest: T,
) -> Result<()>
where
T: AsRef<Path>,
{
let dest_dir = dest.as_ref();
let out_path = dest_dir.join(entry.mangled_name());
if !out_path.starts_with(dest_dir) {
bail!("illegal path in archive: {}", entry.name());
}
if entry.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
let mut outfile = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry.take(100 * 1024 * 1024), &mut outfile)?; }
Ok(())
}
fn delete_zip_files<T>(root: T) -> Result<()>
where
T: AsRef<Path>,
{
let root = root.as_ref();
for entry in fs::read_dir(root)? {
let entry = entry?;
if entry.file_type()?.is_file()
&& entry.file_name().to_string_lossy().ends_with(".zip")
{
let file_path = entry.path();
log::debug!("delete zip file: {}", file_path.display());
fs::remove_file(file_path)?;
}
}
Ok(())
}
fn get_arch(arch: &Option<ArchSpec>) -> &'static str {
match arch {
Some(ArchSpec::X64) => "x64",
Some(ArchSpec::X86) => "x86",
None if get_processor_architecture().ends_with("64") => "x64",
None => "x86",
}
}
fn get_root(config: &Config) -> Result<PathBuf> {
Ok(match &config.root {
Some(root) => root.clone(),
None => env::current_dir()?,
})
}
fn get_node_mirror(config: &Config) -> String {
config
.node_mirror
.clone()
.unwrap_or_else(|| "https://nodejs.org/dist".to_string())
}
fn get_nvm_symlink() -> Result<PathBuf> {
Ok(env::var("NVM_SYMLINK")?.into())
}
pub fn list_remote_versions(config: Config) -> Result<()> {
let base_url = get_node_mirror(&config);
log::debug!("url: {}", base_url);
let release_database = get_release_db(&base_url)?;
let latest_version = release_database.latest_list(LIST_COUNT);
let lts_version = release_database.lts_list(LIST_COUNT);
log::debug!("latest_version: {:?}", latest_version);
log::debug!("lts_version: {:?}", lts_version);
let mut table = Table::new();
table.load_style(presets::UTF8_FULL_CONDENSED).set_header([
Cell::new("current").set_alignment(CellAlignment::Center),
Cell::new("lts").set_alignment(CellAlignment::Center),
]);
for (c, l) in latest_version.iter().zip(lts_version.iter()) {
table.add_row(vec![
Cell::new(c).set_alignment(CellAlignment::Center),
Cell::new(l).set_alignment(CellAlignment::Center),
]);
}
println!("{}", table);
println!(
"\n * Note: The list only shows the latest {} versions. Visit https://nodejs.org/en/ for more info.",
LIST_COUNT
);
Ok(())
}
pub fn list_versions(config: Config, is_remote_request: bool) -> Result<()> {
if is_remote_request {
list_remote_versions(config)
} else {
list_local_versions(config)
}
}
pub fn install_version(
config: Config,
version: VersionSpec,
arch: Option<ArchSpec>,
skip_checksum: bool,
) -> Result<()> {
let root = get_root(&config)?;
let arch = get_arch(&arch);
log::debug!("install: {:?} {}", version, arch);
tips(arch);
let base_url = get_node_mirror(&config);
let db = get_release_db(&base_url)?;
let (exists, ver) = version_exists(&db, &version, &root)?;
if exists {
bail!("version {} already installed", ver);
}
let url = get_node_file_url(&ver, arch, &base_url);
log::debug!("download url: {}", url);
let root = root.as_path();
let Some(file_name) = Path::new(&url).file_name() else {
bail!("invalid download url: {url}");
};
let zip_path = root.join(file_name);
download_file(&url, &zip_path)?;
if !skip_checksum {
let url = get_node_file_checksum_url(&ver, &base_url);
let checksum = load_checksum(&url, &ver, arch)?;
let valid = file_validate(&zip_path, &checksum)?;
if !valid {
bail!("sha256 checksum failed: {:?}", file_name);
}
println!("checksum valid.");
}
zip_extract(zip_path, root)?;
let org_path = root.join(Path::new(file_name).with_extension(""));
let dist_path = root.join(&ver);
log::debug!("rename {:?} -> {:?}", org_path, dist_path);
fs::rename(org_path, dist_path)?;
delete_zip_files(root)?;
println!("install {} completed.", ver);
Ok(())
}
pub fn switch_version(
config: Config,
version: VersionSpec,
arch: Option<ArchSpec>,
) -> Result<()> {
let root = get_root(&config)?;
let arch = get_arch(&arch);
let base_url = get_node_mirror(&config);
let db = get_release_db(&base_url)?;
let (exists, ver) = version_exists(&db, &version, &root)?;
let (current_ver, _) = get_current_version_and_arch();
if !exists {
bail!("version {:?} not installed", ver);
}
if current_ver == ver {
bail!("version {:?} is already used", ver);
}
tips(arch);
log::debug!("ready switch to {:?}({})", version, arch);
let node_path = root.join(&ver).clean();
reset_junction_link(get_nvm_symlink()?, node_path)?;
println!("switch to {} success.", ver);
tips(arch);
Ok(())
}
fn get_release_db<T>(base_url: T) -> Result<ReleaseDatabase>
where
T: AsRef<str>,
{
let base_url = base_url.as_ref();
log::debug!("base url: {}", base_url);
let url: Url = base_url.parse()?;
let target_url = url.join("index.json")?;
log::debug!("target url: {}", target_url);
let json_data: ReleaseDatabase = ureq::get(target_url.as_str())
.call()?
.body_mut()
.read_json()?;
log::debug!("node_release_info count: {}", json_data.len());
Ok(json_data)
}
fn load_checksum<B, V, A>(base_url: B, version: V, arch: A) -> Result<String>
where
B: AsRef<str>,
V: AsRef<str>,
A: AsRef<str>,
{
let base_url = base_url.as_ref();
let version = version.as_ref();
let arch = arch.as_ref();
let text_data = ureq::get(base_url).call()?.body_mut().read_to_string()?;
log::debug!("body len: {}", text_data.len());
let package_name = format!("node-{}-win-{}.zip", version, arch);
for line in text_data.lines() {
let mut parts = line.split_ascii_whitespace();
let (Some(checksum), Some(name)) = (parts.next(), parts.next()) else {
continue;
};
if name == package_name {
return Ok(checksum.to_string());
}
}
bail!("checksum not found: {:?}", package_name);
}