1use std::{
2 env,
3 fs::{self, File},
4 io::{self, BufReader, Read},
5 path::{Path, PathBuf},
6};
7
8use anyhow::bail;
9use comfy_table::{Cell, CellAlignment, Table, presets};
10use indicatif::{ProgressBar, ProgressStyle};
11use path_clean::PathClean;
12use regex::Regex;
13use url::Url;
14use zip::ZipArchive;
15
16#[cfg(feature = "debug")]
17use std::sync::OnceLock;
18#[cfg(feature = "debug")]
19use tracing_appender::{
20 non_blocking::WorkerGuard,
21 rolling::{RollingFileAppender, Rotation},
22};
23#[cfg(feature = "debug")]
24use tracing_subscriber::{
25 EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt,
26};
27
28use crate::model::{ArchSpec, Config, ReleaseDatabase, VersionSpec};
29
30pub mod model;
31
32#[cfg(feature = "debug")]
33static LOG_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
34
35#[cfg(feature = "debug")]
36const LOG_FILTER: &str = "info,nvm_rs=debug,d=debug";
37
38const LIST_COUNT: usize = 20;
39
40pub type Result<T = ()> = anyhow::Result<T>;
41
42#[cfg(feature = "debug")]
43pub fn log_init() {
44 let file_appender = RollingFileAppender::builder()
46 .rotation(Rotation::DAILY)
47 .filename_prefix("app")
48 .filename_suffix("log")
49 .max_log_files(30)
50 .build("./logs")
51 .expect("failed to create rolling file appender");
52
53 let (non_blocking_file, guard) =
55 tracing_appender::non_blocking(file_appender);
56
57 LOG_GUARD.set(guard).expect("logging already initialized");
59
60 let console_layer = fmt::layer()
63 .with_writer(std::io::stderr)
64 .pretty()
65 .with_filter(EnvFilter::new(LOG_FILTER));
66
67 let file_layer = fmt::layer()
69 .with_writer(non_blocking_file)
70 .with_ansi(false)
71 .with_target(true)
72 .with_line_number(true)
73 .with_filter(EnvFilter::new(LOG_FILTER));
74
75 tracing_subscriber::registry()
77 .with(console_layer)
78 .with(file_layer)
79 .init();
80}
81
82pub fn download_file<U, D>(url: U, dest: D) -> Result<()>
83where
84 U: AsRef<str>,
85 D: AsRef<Path>,
86{
87 let url = url.as_ref();
88 let dest = dest.as_ref();
89 let mut resp = ureq::get(url).call()?;
91
92 let total_size = resp
94 .headers()
95 .get("Content-Length")
96 .and_then(|v| v.to_str().ok())
97 .and_then(|s| s.parse::<u64>().ok())
98 .unwrap_or(0);
99
100 let pb = if total_size > 0 {
102 let pb = ProgressBar::new(total_size);
103 pb.set_style(
104 ProgressStyle::default_bar()
105 .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
106 .progress_chars("#>-"),
107 );
108 pb
109 } else {
110 let pb = ProgressBar::new_spinner();
112 pb.set_style(
113 ProgressStyle::default_spinner()
114 .template("{spinner:.green} [{elapsed_precise}] {bytes} downloaded")?,
115 );
116 pb
117 };
118
119 let reader = resp.body_mut().as_reader();
121 let mut progress_reader = pb.wrap_read(reader);
122
123 let mut file = File::create(dest)?;
125 io::copy(&mut progress_reader, &mut file)?;
126
127 pb.finish_with_message("Download complete!");
129 Ok(())
130}
131
132pub fn list_local_versions(config: Config) -> Result<()> {
133 let (current_version, current_arch) = get_current_version_and_arch();
134 log::debug!("current version: {}({}bit)", current_version, current_arch);
135
136 let path = get_root(&config)?;
137 let versions = get_local_versions(path)?;
138
139 println!();
140 for version in versions {
141 log::debug!("found version: {version}");
143
144 if version == current_version {
145 println!(
146 " * {} (Currently using {}-bit executable)",
147 version, current_arch
148 );
149 } else {
150 println!(" {}", version);
151 }
152 }
153
154 println!();
155
156 Ok(())
157}
158
159fn get_local_versions<T>(root: T) -> Result<Vec<String>>
163where
164 T: AsRef<Path>,
165{
166 let mut versions = Vec::new();
167 let re = Regex::new(r"v\d+\.\d+\.\d+").unwrap();
168
169 for entry in fs::read_dir(root.as_ref())? {
170 let entry = entry?;
171 if !entry.file_type()?.is_dir() {
172 continue;
173 }
174
175 if let Some(name) = entry.file_name().to_str()
176 && re.is_match(name)
177 {
178 versions.push(name.to_string());
179 } else {
180 log::debug!("skipping invalid directory entry: {:?}", entry.path());
181 }
182 }
183
184 Ok(versions)
185}
186
187pub fn get_current_version_and_arch() -> (String, String) {
192 let Ok(output) = std::process::Command::new("node")
193 .arg("-p")
194 .arg("`${process.version},${process.arch}`") .output()
196 else {
197 return (String::new(), String::new());
198 };
199
200 let stdout = String::from_utf8_lossy(&output.stdout);
201 let mut parts = stdout.trim_end().split(',');
202
203 let Some(version) = parts.next() else {
204 return (String::new(), String::new());
205 };
206 let arch = match parts.next() {
207 Some("x64") => "64",
208 Some(_) => "32",
209 None => return (String::new(), String::new()),
210 };
211 log::debug!("version: {}", version);
212
213 (version.to_string(), arch.to_string())
214}
215
216pub fn get_processor_architecture() -> String {
217 if env::var("PROCESSOR_ARCHITEW6432").is_ok() {
219 return "x64".to_string();
221 }
222
223 match env::var("PROCESSOR_ARCHITECTURE") {
224 Ok(val) if val.eq_ignore_ascii_case("AMD64") => "x64".to_string(),
226 Ok(val) => val.to_ascii_lowercase(),
227 Err(_) => String::new(),
228 }
229}
230
231pub fn display_current() -> Result<()> {
232 let (current_version, _) = get_current_version_and_arch();
233 if !current_version.is_empty() {
234 println!("current version is {}", current_version);
235 } else {
236 println!("No current version. Run 'nvm use x.x.x' to set a version.");
237 }
238
239 Ok(())
240}
241
242pub fn display_or_update_proxy(
243 mut config: Config,
244 url: Option<String>,
245) -> Result<()> {
246 if let Some(url) = url {
247 config.proxy = if url.eq_ignore_ascii_case("none") {
248 None
249 } else {
250 Some(url)
251 };
252 config.save()?;
253 } else if let Some(proxy) = config.proxy {
254 println!("Current Proxy: {:?}", proxy);
255 } else {
256 println!("No Proxy set.");
257 }
258
259 Ok(())
260}
261
262pub fn display_or_update_root<T>(
263 mut config: Config,
264 path: Option<T>,
265) -> Result<()>
266where
267 T: AsRef<Path>,
268{
269 if let Some(path) = path {
270 let p = path.as_ref();
271 if !p.exists() {
272 bail!("path {:?} does not exist", p);
273 }
274 if !p.is_dir() {
275 bail!("path {:?} is not a directory", p);
276 }
277 config.root = Some(p.to_path_buf());
278 config.save()?;
279 println!("Current Root: {:?}", p);
280 } else if let Some(root) = config.root {
281 println!("Current Root: {:?}", root);
282 } else {
283 println!("No Root set.");
284 }
285
286 Ok(())
287}
288
289pub fn display_or_update_node_mirror(
290 mut config: Config,
291 url: Option<String>,
292) -> Result<()> {
293 if let Some(url) = url {
294 config.node_mirror = Some(url);
295 config.save()?;
296 } else if let Some(node_mirror) = config.node_mirror {
297 println!("Current NodeMirror: {:?}", node_mirror);
298 } else {
299 println!("No NodeMirror set.");
300 }
301
302 Ok(())
303}
304
305pub fn display_or_update_npm_mirror(
306 mut config: Config,
307 url: Option<String>,
308) -> Result<()> {
309 if let Some(url) = url {
310 config.npm_mirror = Some(url);
311 config.save()?;
312 } else if let Some(npm_mirror) = config.npm_mirror {
313 println!("Current NpmMirror: {:?}", npm_mirror);
314 } else {
315 println!("No NpmMirror set.");
316 }
317
318 Ok(())
319}
320
321pub fn activate_version(config: Config) -> Result<()> {
322 let symlink = get_nvm_symlink()?;
323 if let Ok(metadata) = fs::symlink_metadata(&symlink)
324 && metadata.is_symlink()
325 {
326 bail!("node is already activated");
327 }
328
329 let root = get_root(&config)?;
330 let versions = get_local_versions(&root)?;
331 let Some(last) = versions.last() else {
332 bail!("no node version installed");
333 };
334
335 create_junction_link(symlink, root.join(last))?;
336
337 println!("node is activated.");
338
339 Ok(())
340}
341
342pub fn deactivate_version() -> Result<()> {
343 let symlink = get_nvm_symlink()?;
344 let Ok(_) = fs::symlink_metadata(&symlink) else {
345 bail!("node is already deactivated");
346 };
347
348 delete_junction_link(symlink)?;
349
350 println!("node is deactivated.");
351
352 Ok(())
353}
354
355pub fn display_architecture(config: Config) -> Result<()> {
356 let arch = get_arch(&config.arch);
357 println!("current arch is {}", arch);
358
359 tips(arch);
360
361 Ok(())
362}
363
364pub fn uninstall_version(config: Config, version: VersionSpec) -> Result<()> {
365 let root = get_root(&config)?;
366 let versions = get_local_versions(&root)?;
367 log::debug!("local versions: {:?}", versions);
368
369 match version {
370 VersionSpec::Latest | VersionSpec::Lts => {
371 bail!(
372 "The version must be a specific version. Can not use 'latest' or 'lts' to uninstall."
373 );
374 }
375 VersionSpec::Exact(ver) => {
376 if !versions.contains(&ver) {
378 bail!("version {:?} not installed", ver);
379 }
380 if get_current_version_and_arch().0 == ver {
381 bail!("current version {} is in use, can not uninstall it", ver);
382 }
383
384 delete_version(&root, &ver)?;
385
386 println!("uninstall {} completed.", ver);
387 }
388 }
389
390 Ok(())
391}
392
393fn delete_version(root: &Path, ver: &str) -> Result<()> {
394 let path = root.join(ver);
395 log::debug!("delete: {:?}", path);
396 fs::remove_dir_all(&path)?;
397
398 Ok(())
399}
400
401fn delete_junction_link<T>(link: T) -> Result<()>
402where
403 T: AsRef<Path>,
404{
405 let path = link.as_ref();
406 log::debug!("delete link: {:?}", path);
407 if let Err(e) = fs::remove_dir(path) {
409 log::debug!("remove_dir failed: {e}");
410 }
411
412 Ok(())
413}
414
415fn create_junction_link<T, L>(link: T, target: L) -> Result<()>
420where
421 T: AsRef<Path>,
422 L: AsRef<Path>,
423{
424 let target = target.as_ref();
425 let link = link.as_ref();
426 log::debug!("create link: {:?} -> {:?}", target, link);
427 #[cfg(target_os = "windows")]
428 junction::create(target, link)?;
429
430 Ok(())
431}
432
433fn reset_junction_link<T, L>(link: T, target: L) -> Result<()>
438where
439 T: AsRef<Path>,
440 L: AsRef<Path>,
441{
442 delete_junction_link(&link)?;
443 create_junction_link(link, target)?;
444
445 Ok(())
446}
447
448fn tips(arch: &str) {
455 if arch != "x64" {
456 println!(
457 "\n* Notice: Since version v23.0.0, 32-bit versions are no longer available. Please use the 64-bit version."
458 );
459 }
460}
461
462fn file_validate<T>(path: T, sha256_checksum: &str) -> Result<bool>
463where
464 T: AsRef<Path>,
465{
466 use sha2::Digest;
467
468 let file = std::fs::File::open(path)?;
469 let mut reader = BufReader::with_capacity(256 * 1024, file);
470 let mut hasher = sha2::Sha256::new();
471 let mut buf = [0u8; 64 * 1024];
472 loop {
474 let n = reader.read(&mut buf)?;
475 if n == 0 {
476 break;
477 }
478 hasher.update(&buf[..n]);
479 }
480
481 Ok(hex::encode(hasher.finalize()) == sha256_checksum)
482}
483
484fn version_exists<T>(
488 db: &ReleaseDatabase,
489 version: &VersionSpec,
490 root: T,
491) -> Result<(bool, String)>
492where
493 T: AsRef<Path>,
494{
495 let ver = match version {
496 VersionSpec::Latest => {
497 if let Some(latest_version) = db.latest() {
498 latest_version
499 } else {
500 bail!("No latest version found.")
501 }
502 }
503 VersionSpec::Lts => {
504 if let Some(lts_version) = db.latest_lts() {
505 lts_version
506 } else {
507 bail!("No LTS version found.")
508 }
509 }
510 VersionSpec::Exact(s) => {
512 if db.version_exists(s) {
513 s.clone()
514 } else {
515 bail!("node v{} not installed.", s)
516 }
517 }
518 };
519
520 let root = root.as_ref();
521 let exists = fs::exists(root.join(&ver))?;
522
523 Ok((exists, ver))
524}
525
526fn get_node_file_url(version: &str, arch: &str, base_url: &str) -> String {
527 let url =
528 format!("{}/{}/node-{}-win-{}.zip", base_url, version, version, arch);
529 log::debug!("url: {:?}", url);
530 url
531}
532
533fn get_node_file_checksum_url(version: &str, base_url: &str) -> String {
534 let url = format!("{}/{}/SHASUMS256.txt", base_url, version);
535 log::debug!("url: {:?}", url);
536 url
537}
538
539fn zip_extract<T, R>(source: T, dest: R) -> Result<()>
540where
541 T: AsRef<Path>,
542 R: AsRef<Path>,
543{
544 let file = std::fs::File::open(source)?;
545 let reader = BufReader::new(file);
546 let mut archive = ZipArchive::new(reader)?;
547 let pb = ProgressBar::new(archive.len() as u64);
548 pb.set_style(
549 ProgressStyle::default_bar()
550 .template("extracting [{bar:40}] [{percent:.2}%]")
551 .expect("invalid template")
552 .progress_chars("=> "),
553 );
554 for i in 0..archive.len() {
555 let mut entry = archive.by_index(i)?;
556 safe_extract(&mut entry, &dest)?;
557 pb.inc(1);
558 }
559 pb.finish();
560 log::debug!("extract done");
561 Ok(())
562}
563
564fn safe_extract<T>(
565 entry: &mut zip::read::ZipFile<'_, BufReader<File>>,
566 dest: T,
567) -> Result<()>
568where
569 T: AsRef<Path>,
570{
571 let dest_dir = dest.as_ref();
572
573 let out_path = dest_dir.join(entry.mangled_name());
575 if !out_path.starts_with(dest_dir) {
576 bail!("illegal path in archive: {}", entry.name());
577 }
578
579 if entry.is_dir() {
580 std::fs::create_dir_all(&out_path)?;
581 } else if let Some(parent) = out_path.parent() {
582 std::fs::create_dir_all(parent)?;
583 let mut outfile = std::fs::File::create(&out_path)?;
584 std::io::copy(&mut entry.take(100 * 1024 * 1024), &mut outfile)?; }
586 Ok(())
587}
588
589fn delete_zip_files<T>(root: T) -> Result<()>
590where
591 T: AsRef<Path>,
592{
593 let root = root.as_ref();
594 for entry in fs::read_dir(root)? {
595 let entry = entry?;
596 if entry.file_type()?.is_file()
597 && entry.file_name().to_string_lossy().ends_with(".zip")
598 {
599 let file_path = entry.path();
600 log::debug!("delete zip file: {}", file_path.display());
601 fs::remove_file(file_path)?;
602 }
603 }
604 Ok(())
605}
606
607fn get_arch(arch: &Option<ArchSpec>) -> &'static str {
608 match arch {
609 Some(ArchSpec::X64) => "x64",
610 Some(ArchSpec::X86) => "x86",
611 None if get_processor_architecture().ends_with("64") => "x64",
613 None => "x86",
614 }
615}
616
617fn get_root(config: &Config) -> Result<PathBuf> {
618 Ok(match &config.root {
619 Some(root) => root.clone(),
620 None => env::current_dir()?,
621 })
622}
623
624fn get_node_mirror(config: &Config) -> String {
625 config
626 .node_mirror
627 .clone()
628 .unwrap_or_else(|| "https://nodejs.org/dist".to_string())
629}
630
631fn get_nvm_symlink() -> Result<PathBuf> {
632 Ok(env::var("NVM_SYMLINK")?.into())
633}
634
635pub fn list_remote_versions(config: Config) -> Result<()> {
636 let base_url = get_node_mirror(&config);
638 log::debug!("url: {}", base_url);
639
640 let release_database = get_release_db(&base_url)?;
641
642 let latest_version = release_database.latest_list(LIST_COUNT);
643
644 let lts_version = release_database.lts_list(LIST_COUNT);
645
646 log::debug!("latest_version: {:?}", latest_version);
647 log::debug!("lts_version: {:?}", lts_version);
648
649 let mut table = Table::new();
650 table.load_style(presets::UTF8_FULL_CONDENSED).set_header([
651 Cell::new("current").set_alignment(CellAlignment::Center),
652 Cell::new("lts").set_alignment(CellAlignment::Center),
653 ]);
654
655 for (c, l) in latest_version.iter().zip(lts_version.iter()) {
656 table.add_row(vec![
657 Cell::new(c).set_alignment(CellAlignment::Center),
658 Cell::new(l).set_alignment(CellAlignment::Center),
659 ]);
660 }
661
662 println!("{}", table);
663
664 println!(
666 "\n * Note: The list only shows the latest {} versions. Visit https://nodejs.org/en/ for more info.",
667 LIST_COUNT
668 );
669
670 Ok(())
671}
672
673pub fn list_versions(config: Config, is_remote_request: bool) -> Result<()> {
674 if is_remote_request {
675 list_remote_versions(config)
676 } else {
677 list_local_versions(config)
678 }
679}
680
681pub fn install_version(
682 config: Config,
683 version: VersionSpec,
684 arch: Option<ArchSpec>,
685 skip_checksum: bool,
686) -> Result<()> {
687 let root = get_root(&config)?;
688
689 let arch = get_arch(&arch);
690 log::debug!("install: {:?} {}", version, arch);
691 tips(arch);
692
693 let base_url = get_node_mirror(&config);
694
695 let db = get_release_db(&base_url)?;
696 let (exists, ver) = version_exists(&db, &version, &root)?;
697 if exists {
698 bail!("version {} already installed", ver);
699 }
700
701 let url = get_node_file_url(&ver, arch, &base_url);
702 log::debug!("download url: {}", url);
703
704 let root = root.as_path();
705 let Some(file_name) = Path::new(&url).file_name() else {
706 bail!("invalid download url: {url}");
707 };
708 let zip_path = root.join(file_name);
709
710 download_file(&url, &zip_path)?;
711
712 if !skip_checksum {
713 let url = get_node_file_checksum_url(&ver, &base_url);
714 let checksum = load_checksum(&url, &ver, arch)?;
715 let valid = file_validate(&zip_path, &checksum)?;
716 if !valid {
717 bail!("sha256 checksum failed: {:?}", file_name);
718 }
719
720 println!("checksum valid.");
721 }
722
723 zip_extract(zip_path, root)?;
724
725 let org_path = root.join(Path::new(file_name).with_extension(""));
726 let dist_path = root.join(&ver);
727 log::debug!("rename {:?} -> {:?}", org_path, dist_path);
728 fs::rename(org_path, dist_path)?;
729
730 delete_zip_files(root)?;
731
732 println!("install {} completed.", ver);
733
734 Ok(())
735}
736
737pub fn switch_version(
738 config: Config,
739 version: VersionSpec,
740 arch: Option<ArchSpec>,
741) -> Result<()> {
742 let root = get_root(&config)?;
743 let arch = get_arch(&arch);
744
745 let base_url = get_node_mirror(&config);
746
747 let db = get_release_db(&base_url)?;
748 let (exists, ver) = version_exists(&db, &version, &root)?;
749 let (current_ver, _) = get_current_version_and_arch();
750 if !exists {
751 bail!("version {:?} not installed", ver);
752 }
753 if current_ver == ver {
754 bail!("version {:?} is already used", ver);
755 }
756
757 tips(arch);
758
759 log::debug!("ready switch to {:?}({})", version, arch);
760
761 let node_path = root.join(&ver).clean();
762
763 reset_junction_link(get_nvm_symlink()?, node_path)?;
765
766 println!("switch to {} success.", ver);
767
768 tips(arch);
770
771 Ok(())
772}
773
774fn get_release_db<T>(base_url: T) -> Result<ReleaseDatabase>
775where
776 T: AsRef<str>,
777{
778 let base_url = base_url.as_ref();
779 log::debug!("base url: {}", base_url);
780
781 let url: Url = base_url.parse()?;
782 let target_url = url.join("index.json")?;
783 log::debug!("target url: {}", target_url);
784
785 let json_data: ReleaseDatabase = ureq::get(target_url.as_str())
786 .call()?
787 .body_mut()
788 .read_json()?;
789 log::debug!("node_release_info count: {}", json_data.len());
793
794 Ok(json_data)
795}
796
797fn load_checksum<B, V, A>(base_url: B, version: V, arch: A) -> Result<String>
813where
814 B: AsRef<str>,
815 V: AsRef<str>,
816 A: AsRef<str>,
817{
818 let base_url = base_url.as_ref();
819 let version = version.as_ref();
820 let arch = arch.as_ref();
821
822 let text_data = ureq::get(base_url).call()?.body_mut().read_to_string()?;
823 log::debug!("body len: {}", text_data.len());
824
825 let package_name = format!("node-{}-win-{}.zip", version, arch);
826 for line in text_data.lines() {
827 let mut parts = line.split_ascii_whitespace();
829 let (Some(checksum), Some(name)) = (parts.next(), parts.next()) else {
830 continue;
831 };
832 if name == package_name {
833 return Ok(checksum.to_string());
834 }
835 }
836
837 bail!("checksum not found: {:?}", package_name);
838}