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/// The platform's `node` executable file name: `node.exe` on Windows,
154/// `node` everywhere else. Single source of truth for the spots that
155/// build or look up the node binary by name.
156pub(crate) const fn node_exe_name() -> &'static str {
157    if cfg!(windows) { "node.exe" } else { "node" }
158}
159
160/// Per-OS layout of a native Node install: unix puts `node` under
161/// `bin/`, Windows puts `node.exe` at the root (mise mirrors both).
162pub(crate) fn node_paths_in(dir: &Path) -> (PathBuf, PathBuf) {
163    let exe_name = node_exe_name();
164    // Windows zips put node.exe at the archive root; mise (and some
165    // mirror layouts) use bin\node.exe instead. Prefer the root copy
166    // when it exists, otherwise fall through to the shared bin/ layout
167    // used on every OS.
168    if cfg!(windows) {
169        let root_exe = dir.join(exe_name);
170        if root_exe.is_file() {
171            return (dir.to_path_buf(), root_exe);
172        }
173    }
174    let bin = dir.join("bin");
175    let exe = bin.join(exe_name);
176    (bin, exe)
177}
178
179/// Find `node` on PATH and probe its version (`node --version`).
180/// Memoized for the process: one spawn no matter how many resolution
181/// calls happen.
182pub fn probe_path_node() -> Option<(node_semver::Version, PathBuf)> {
183    static PROBED: std::sync::OnceLock<Option<(node_semver::Version, PathBuf)>> =
184        std::sync::OnceLock::new();
185    PROBED.get_or_init(probe_path_node_uncached).clone()
186}
187
188fn probe_path_node_uncached() -> Option<(node_semver::Version, PathBuf)> {
189    let exe = find_on_path(node_exe_name())?;
190    let output = std::process::Command::new(&exe)
191        .arg("--version")
192        .output()
193        .ok()?;
194    if !output.status.success() {
195        return None;
196    }
197    let raw = String::from_utf8(output.stdout).ok()?;
198    let version = node_semver::Version::parse(raw.trim().trim_start_matches('v')).ok()?;
199    Some((version, exe))
200}
201
202/// Locate a `node` executable on `PATH` without spawning it (unlike
203/// [`probe_path_node`], which runs `node --version`). Cheap enough to
204/// call on the hot install/run path to populate `npm_node_execpath` /
205/// `NODE` for lifecycle scripts when no runtime switch is active.
206/// Returns the first `node` (`node.exe` on Windows) on `PATH` as an
207/// absolute, executable path, or `None` if none qualifies.
208pub fn node_on_path() -> Option<PathBuf> {
209    let node = find_on_path(node_exe_name())?;
210    // `npm_node_execpath` / `NODE` are contracted to be an absolute,
211    // runnable node. `find_on_path` only guarantees an existing file,
212    // and a relative `PATH` segment yields a relative match, so make
213    // the path absolute and require the exec bit — better to leave the
214    // vars unset than hand tools a path they can't run.
215    let node = if node.is_absolute() {
216        node
217    } else {
218        std::env::current_dir().ok()?.join(node)
219    };
220    is_executable_file(&node).then_some(node)
221}
222
223/// Minimal PATH walk (std-only). Returns the first existing,
224/// file-typed match.
225pub(crate) fn find_on_path(bin_name: &str) -> Option<PathBuf> {
226    let path = std::env::var_os("PATH")?;
227    for dir in std::env::split_paths(&path) {
228        if dir.as_os_str().is_empty() {
229            continue;
230        }
231        let candidate = dir.join(bin_name);
232        if candidate.is_file() {
233            return Some(candidate);
234        }
235        #[cfg(windows)]
236        {
237            // PATHEXT resolution for the bare name (mise is usually
238            // mise.exe; node is node.exe — callers pass the .exe name
239            // already, this is a fallback).
240            let with_exe = dir.join(format!("{bin_name}.exe"));
241            if with_exe.is_file() {
242                return Some(with_exe);
243            }
244        }
245    }
246    None
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn fab_install(root: &Path, version: &str) {
254        let dir = root.join(version);
255        let bin = if cfg!(windows) {
256            dir.clone()
257        } else {
258            dir.join("bin")
259        };
260        std::fs::create_dir_all(&bin).unwrap();
261        let exe = bin.join(node_exe_name());
262        std::fs::write(&exe, "#!/bin/sh\necho v0.0.0\n").unwrap();
263        #[cfg(unix)]
264        {
265            use std::os::unix::fs::PermissionsExt;
266            std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
267        }
268    }
269
270    #[cfg(unix)]
271    #[test]
272    fn non_executable_node_is_rejected() {
273        let tmp = tempfile::tempdir().unwrap();
274        fab_install(tmp.path(), "22.1.0");
275        use std::os::unix::fs::PermissionsExt;
276        let exe = tmp.path().join("22.1.0/bin/node");
277        std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o644)).unwrap();
278        assert!(scan_install_dir(tmp.path(), InstallOrigin::Aube).is_empty());
279    }
280
281    #[test]
282    fn scans_and_validates() {
283        let tmp = tempfile::tempdir().unwrap();
284        fab_install(tmp.path(), "22.1.0");
285        fab_install(tmp.path(), "24.4.1");
286        // Incomplete install: skipped.
287        fab_install(tmp.path(), "26.0.0");
288        std::fs::write(tmp.path().join("26.0.0/incomplete"), "").unwrap();
289        // Missing binary: skipped.
290        std::fs::create_dir_all(tmp.path().join("20.0.0")).unwrap();
291        // Non-version dir: skipped.
292        std::fs::create_dir_all(tmp.path().join(".downloads")).unwrap();
293
294        let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
295        let mut versions: Vec<String> = found.iter().map(|n| n.version.to_string()).collect();
296        versions.sort();
297        assert_eq!(versions, vec!["22.1.0", "24.4.1"]);
298    }
299
300    #[cfg(unix)]
301    #[test]
302    fn alias_symlinks_are_skipped() {
303        let tmp = tempfile::tempdir().unwrap();
304        fab_install(tmp.path(), "22.1.0");
305        std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("22")).unwrap();
306        std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("latest")).unwrap();
307        let found = scan_install_dir(tmp.path(), InstallOrigin::Aube);
308        assert_eq!(found.len(), 1, "{found:?}");
309    }
310}