Skip to main content

aube_runtime/
discover.rs

1//! Discovery of already-installed Node versions: aube's own runtime
2//! dir, mise's installs dir (read-only), and the `node` on PATH.
3
4use crate::paths;
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8/// Where an installed Node came from.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum InstallOrigin {
11    Aube,
12    Mise,
13}
14
15impl InstallOrigin {
16    pub fn label(self) -> &'static str {
17        match self {
18            InstallOrigin::Aube => "aube",
19            InstallOrigin::Mise => "mise",
20        }
21    }
22}
23
24/// A validated on-disk Node install.
25#[derive(Debug, Clone)]
26pub struct InstalledNode {
27    pub version: node_semver::Version,
28    pub install_dir: PathBuf,
29    /// The directory to prepend to PATH: `<dir>/bin` on unix, the
30    /// install dir itself on Windows (node.exe sits at the root).
31    pub bin_dir: PathBuf,
32    pub node_bin: PathBuf,
33    pub origin: InstallOrigin,
34}
35
36/// List every valid installed Node version across aube's runtime dir
37/// and mise's installs dir. When both have the same version, aube's
38/// copy wins (deterministic, and it's the copy aube can manage).
39pub fn list_installed() -> Vec<InstalledNode> {
40    let mut by_version: BTreeMap<node_semver::Version, InstalledNode> = BTreeMap::new();
41    // Insert mise first so aube entries overwrite on version collision.
42    if let Some(dir) = mise_node_installs_dir() {
43        for node in scan_install_dir(&dir, InstallOrigin::Mise) {
44            by_version.insert(node.version.clone(), node);
45        }
46    }
47    if let Some(dir) = paths::runtime_dir() {
48        for node in scan_install_dir(&dir, InstallOrigin::Aube) {
49            by_version.insert(node.version.clone(), node);
50        }
51    }
52    by_version.into_values().collect()
53}
54
55/// mise's node installs directory.
56pub fn mise_node_installs_dir() -> Option<PathBuf> {
57    mise_tool_installs_dir("node")
58}
59
60/// mise's installs directory for one tool:
61/// `$MISE_INSTALLS_DIR || $MISE_DATA_DIR/installs || ~/.local/share/mise/installs`,
62/// plus the tool segment. mise uses `~/.local/share` on every OS.
63pub fn mise_tool_installs_dir(tool: &str) -> Option<PathBuf> {
64    let installs = if let Some(dir) = std::env::var_os("MISE_INSTALLS_DIR") {
65        PathBuf::from(dir)
66    } else if let Some(dir) = std::env::var_os("MISE_DATA_DIR") {
67        PathBuf::from(dir).join("installs")
68    } else {
69        let data_home = aube_util::env::xdg_data_home()
70            .or_else(|| aube_util::env::home_dir().map(|h| h.join(".local/share")))?;
71        data_home.join("mise/installs")
72    };
73    Some(installs.join(tool))
74}
75
76/// Scan one installs root (dir-per-version) and validate each entry:
77/// the dir name must parse as a version, symlinks are skipped (mise's
78/// `latest` / `lts` / `20` aliases are symlinks — including them would
79/// double-count), an in-progress install (mise's `incomplete` marker
80/// file) is skipped, and the node binary must exist.
81fn scan_install_dir(root: &Path, origin: InstallOrigin) -> Vec<InstalledNode> {
82    let Ok(entries) = std::fs::read_dir(root) else {
83        return Vec::new();
84    };
85    let mut out = Vec::new();
86    for entry in entries.flatten() {
87        let path = entry.path();
88        let Ok(file_type) = entry.file_type() else {
89            continue;
90        };
91        if !file_type.is_dir() {
92            // Skips both files and symlinked alias dirs:
93            // `DirEntry::file_type` does not follow symlinks.
94            continue;
95        }
96        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
97            continue;
98        };
99        let Ok(version) = node_semver::Version::parse(name.trim_start_matches('v')) else {
100            continue;
101        };
102        if let Some(node) = validate_install(&path, version, origin) {
103            out.push(node);
104        }
105    }
106    out
107}
108
109/// Validate a single version dir and compute its bin paths. Public to
110/// the crate so the installer can re-check a freshly-published dir
111/// (or one mise just created) through the exact same rules.
112pub(crate) fn validate_install(
113    dir: &Path,
114    version: node_semver::Version,
115    origin: InstallOrigin,
116) -> Option<InstalledNode> {
117    if dir.join("incomplete").exists() {
118        return None;
119    }
120    let (bin_dir, node_bin) = node_paths_in(dir);
121    if !is_executable_file(&node_bin) {
122        return None;
123    }
124    Some(InstalledNode {
125        version,
126        install_dir: dir.to_path_buf(),
127        bin_dir,
128        node_bin,
129        origin,
130    })
131}
132
133/// A regular file with execute permission (any bit — discovery never
134/// knows which uid will spawn it). Windows has no exec bit; existence
135/// suffices there. Guards against a corrupt or partially-written
136/// install entering the candidate set and only failing at spawn time.
137pub(crate) fn is_executable_file(path: &Path) -> bool {
138    let Ok(meta) = std::fs::metadata(path) else {
139        return false;
140    };
141    if !meta.is_file() {
142        return false;
143    }
144    #[cfg(unix)]
145    {
146        use std::os::unix::fs::PermissionsExt;
147        meta.permissions().mode() & 0o111 != 0
148    }
149    #[cfg(not(unix))]
150    true
151}
152
153/// Per-OS layout of a native Node install: unix puts `node` under
154/// `bin/`, Windows puts `node.exe` at the root (mise mirrors both).
155pub(crate) fn node_paths_in(dir: &Path) -> (PathBuf, PathBuf) {
156    if cfg!(windows) {
157        // Windows zips have node.exe at the archive root, but mise
158        // (and some mirrors' layouts) use bin\node.exe — accept both.
159        let root_exe = dir.join("node.exe");
160        if root_exe.is_file() {
161            return (dir.to_path_buf(), root_exe);
162        }
163        let bin = dir.join("bin");
164        let exe = bin.join("node.exe");
165        (bin, exe)
166    } else {
167        let bin = dir.join("bin");
168        let exe = bin.join("node");
169        (bin, exe)
170    }
171}
172
173/// Find `node` on PATH and probe its version (`node --version`).
174/// Memoized for the process: one spawn no matter how many resolution
175/// calls happen.
176pub fn probe_path_node() -> Option<(node_semver::Version, PathBuf)> {
177    static PROBED: std::sync::OnceLock<Option<(node_semver::Version, PathBuf)>> =
178        std::sync::OnceLock::new();
179    PROBED.get_or_init(probe_path_node_uncached).clone()
180}
181
182fn probe_path_node_uncached() -> Option<(node_semver::Version, PathBuf)> {
183    let exe = find_on_path(if cfg!(windows) { "node.exe" } else { "node" })?;
184    let output = std::process::Command::new(&exe)
185        .arg("--version")
186        .output()
187        .ok()?;
188    if !output.status.success() {
189        return None;
190    }
191    let raw = String::from_utf8(output.stdout).ok()?;
192    let version = node_semver::Version::parse(raw.trim().trim_start_matches('v')).ok()?;
193    Some((version, exe))
194}
195
196/// Minimal PATH walk (std-only). Returns the first existing,
197/// file-typed match.
198pub(crate) fn find_on_path(bin_name: &str) -> Option<PathBuf> {
199    let path = std::env::var_os("PATH")?;
200    for dir in std::env::split_paths(&path) {
201        if dir.as_os_str().is_empty() {
202            continue;
203        }
204        let candidate = dir.join(bin_name);
205        if candidate.is_file() {
206            return Some(candidate);
207        }
208        #[cfg(windows)]
209        {
210            // PATHEXT resolution for the bare name (mise is usually
211            // mise.exe; node is node.exe — callers pass the .exe name
212            // already, this is a fallback).
213            let with_exe = dir.join(format!("{bin_name}.exe"));
214            if with_exe.is_file() {
215                return Some(with_exe);
216            }
217        }
218    }
219    None
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn fab_install(root: &Path, version: &str) {
227        let dir = root.join(version);
228        let bin = if cfg!(windows) {
229            dir.clone()
230        } else {
231            dir.join("bin")
232        };
233        std::fs::create_dir_all(&bin).unwrap();
234        let exe = bin.join(if cfg!(windows) { "node.exe" } else { "node" });
235        std::fs::write(&exe, "#!/bin/sh\necho v0.0.0\n").unwrap();
236        #[cfg(unix)]
237        {
238            use std::os::unix::fs::PermissionsExt;
239            std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
240        }
241    }
242
243    #[cfg(unix)]
244    #[test]
245    fn non_executable_node_is_rejected() {
246        let tmp = tempfile::tempdir().unwrap();
247        fab_install(tmp.path(), "22.1.0");
248        use std::os::unix::fs::PermissionsExt;
249        let exe = tmp.path().join("22.1.0/bin/node");
250        std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o644)).unwrap();
251        assert!(scan_install_dir(tmp.path(), InstallOrigin::Aube).is_empty());
252    }
253
254    #[test]
255    fn scans_and_validates() {
256        let tmp = tempfile::tempdir().unwrap();
257        fab_install(tmp.path(), "22.1.0");
258        fab_install(tmp.path(), "24.4.1");
259        // Incomplete install: skipped.
260        fab_install(tmp.path(), "26.0.0");
261        std::fs::write(tmp.path().join("26.0.0/incomplete"), "").unwrap();
262        // Missing binary: skipped.
263        std::fs::create_dir_all(tmp.path().join("20.0.0")).unwrap();
264        // Non-version dir: skipped.
265        std::fs::create_dir_all(tmp.path().join(".downloads")).unwrap();
266
267        let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
268        let mut versions: Vec<String> = found.iter().map(|n| n.version.to_string()).collect();
269        versions.sort();
270        assert_eq!(versions, vec!["22.1.0", "24.4.1"]);
271    }
272
273    #[cfg(unix)]
274    #[test]
275    fn alias_symlinks_are_skipped() {
276        let tmp = tempfile::tempdir().unwrap();
277        fab_install(tmp.path(), "22.1.0");
278        std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("22")).unwrap();
279        std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("latest")).unwrap();
280        let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
281        assert_eq!(found.len(), 1, "{found:?}");
282    }
283}