use crate::paths;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallOrigin {
Aube,
Mise,
}
impl InstallOrigin {
pub fn label(self) -> &'static str {
match self {
InstallOrigin::Aube => aube_util::embedder().name,
InstallOrigin::Mise => "mise",
}
}
}
#[derive(Debug, Clone)]
pub struct InstalledNode {
pub version: node_semver::Version,
pub install_dir: PathBuf,
pub bin_dir: PathBuf,
pub node_bin: PathBuf,
pub origin: InstallOrigin,
}
pub fn list_installed() -> Vec<InstalledNode> {
let mut by_version: BTreeMap<node_semver::Version, InstalledNode> = BTreeMap::new();
if let Some(dir) = mise_node_installs_dir() {
for node in scan_install_dir(&dir, InstallOrigin::Mise) {
by_version.insert(node.version.clone(), node);
}
}
if let Some(dir) = paths::runtime_dir() {
for node in scan_install_dir(&dir, InstallOrigin::Aube) {
by_version.insert(node.version.clone(), node);
}
}
by_version.into_values().collect()
}
pub fn mise_node_installs_dir() -> Option<PathBuf> {
mise_tool_installs_dir("node")
}
pub fn mise_tool_installs_dir(tool: &str) -> Option<PathBuf> {
let installs = if let Some(dir) = std::env::var_os("MISE_INSTALLS_DIR") {
PathBuf::from(dir)
} else if let Some(dir) = std::env::var_os("MISE_DATA_DIR") {
PathBuf::from(dir).join("installs")
} else {
let data_home = aube_util::env::xdg_data_home()
.or_else(|| aube_util::env::home_dir().map(|h| h.join(".local/share")))?;
data_home.join("mise/installs")
};
Some(installs.join(tool))
}
fn scan_install_dir(root: &Path, origin: InstallOrigin) -> Vec<InstalledNode> {
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Ok(version) = node_semver::Version::parse(name.trim_start_matches('v')) else {
continue;
};
if let Some(node) = validate_install(&path, version, origin) {
out.push(node);
}
}
out
}
pub(crate) fn validate_install(
dir: &Path,
version: node_semver::Version,
origin: InstallOrigin,
) -> Option<InstalledNode> {
if dir.join("incomplete").exists() {
return None;
}
let (bin_dir, node_bin) = node_paths_in(dir);
if !is_executable_file(&node_bin) {
return None;
}
Some(InstalledNode {
version,
install_dir: dir.to_path_buf(),
bin_dir,
node_bin,
origin,
})
}
pub(crate) fn is_executable_file(path: &Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
true
}
pub(crate) const fn node_exe_name() -> &'static str {
if cfg!(windows) { "node.exe" } else { "node" }
}
pub(crate) fn node_paths_in(dir: &Path) -> (PathBuf, PathBuf) {
let exe_name = node_exe_name();
if cfg!(windows) {
let root_exe = dir.join(exe_name);
if root_exe.is_file() {
return (dir.to_path_buf(), root_exe);
}
}
let bin = dir.join("bin");
let exe = bin.join(exe_name);
(bin, exe)
}
pub fn probe_path_node() -> Option<(node_semver::Version, PathBuf)> {
static PROBED: std::sync::OnceLock<Option<(node_semver::Version, PathBuf)>> =
std::sync::OnceLock::new();
PROBED.get_or_init(probe_path_node_uncached).clone()
}
fn probe_path_node_uncached() -> Option<(node_semver::Version, PathBuf)> {
let exe = find_on_path(node_exe_name())?;
let output = std::process::Command::new(&exe)
.arg("--version")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8(output.stdout).ok()?;
let version = node_semver::Version::parse(raw.trim().trim_start_matches('v')).ok()?;
Some((version, exe))
}
pub fn node_on_path() -> Option<PathBuf> {
let node = find_on_path(node_exe_name())?;
let node = if node.is_absolute() {
node
} else {
std::env::current_dir().ok()?.join(node)
};
is_executable_file(&node).then_some(node)
}
pub(crate) fn find_on_path(bin_name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(bin_name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
let with_exe = dir.join(format!("{bin_name}.exe"));
if with_exe.is_file() {
return Some(with_exe);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn fab_install(root: &Path, version: &str) {
let dir = root.join(version);
let bin = if cfg!(windows) {
dir.clone()
} else {
dir.join("bin")
};
std::fs::create_dir_all(&bin).unwrap();
let exe = bin.join(node_exe_name());
std::fs::write(&exe, "#!/bin/sh\necho v0.0.0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[cfg(unix)]
#[test]
fn non_executable_node_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
fab_install(tmp.path(), "22.1.0");
use std::os::unix::fs::PermissionsExt;
let exe = tmp.path().join("22.1.0/bin/node");
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(scan_install_dir(tmp.path(), InstallOrigin::Aube).is_empty());
}
#[test]
fn scans_and_validates() {
let tmp = tempfile::tempdir().unwrap();
fab_install(tmp.path(), "22.1.0");
fab_install(tmp.path(), "24.4.1");
fab_install(tmp.path(), "26.0.0");
std::fs::write(tmp.path().join("26.0.0/incomplete"), "").unwrap();
std::fs::create_dir_all(tmp.path().join("20.0.0")).unwrap();
std::fs::create_dir_all(tmp.path().join(".downloads")).unwrap();
let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
let mut versions: Vec<String> = found.iter().map(|n| n.version.to_string()).collect();
versions.sort();
assert_eq!(versions, vec!["22.1.0", "24.4.1"]);
}
#[cfg(unix)]
#[test]
fn alias_symlinks_are_skipped() {
let tmp = tempfile::tempdir().unwrap();
fab_install(tmp.path(), "22.1.0");
std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("22")).unwrap();
std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("latest")).unwrap();
let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
assert_eq!(found.len(), 1, "{found:?}");
}
}