fix(process): harden Node.js path discovery for production readiness
- Fix thread safety: replace #[tokio::main] in codeg_server with manual runtime construction so set_var runs before any worker threads exist - Fix nvm alias branch: sort matched candidates (read_dir order is undefined) and skip non-numeric aliases like lts/* that cannot be resolved without full nvm evaluation - Fix HOME missing: accept Option<&Path> for home so env-var-only discovery (NVM_HOME, VOLTA_HOME, FNM_DIR, etc.) still works in Docker / systemd environments without HOME set - Refactor resolve_dir to accept an env var chain, eliminating all inline Option chains for fnm, nvm-windows, mise, and scoop
This commit is contained in:
@@ -7,8 +7,7 @@ use codeg_lib::web::{
|
|||||||
find_static_dir_standalone, generate_random_token, get_local_addresses, WebServerState,
|
find_static_dir_standalone, generate_random_token, get_local_addresses, WebServerState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
fn main() {
|
||||||
async fn main() {
|
|
||||||
// Support --version flag
|
// Support --version flag
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||||
@@ -16,9 +15,20 @@ async fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PATH initialisation MUST happen before the tokio runtime is created.
|
||||||
|
// std::env::set_var is not thread-safe (unsafe in Rust edition 2024);
|
||||||
|
// #[tokio::main] would spawn worker threads before we reach this point.
|
||||||
codeg_lib::process::ensure_node_in_path();
|
codeg_lib::process::ensure_node_in_path();
|
||||||
codeg_lib::process::ensure_user_npm_prefix_in_path();
|
codeg_lib::process::ensure_user_npm_prefix_in_path();
|
||||||
|
|
||||||
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build tokio runtime")
|
||||||
|
.block_on(async_main());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn async_main() {
|
||||||
let port: u16 = std::env::var("CODEG_PORT")
|
let port: u16 = std::env::var("CODEG_PORT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ where
|
|||||||
/// `std::env::set_var` is not thread-safe (`unsafe` in Rust edition 2024);
|
/// `std::env::set_var` is not thread-safe (`unsafe` in Rust edition 2024);
|
||||||
/// calling it while other threads may read `PATH` is a data race.
|
/// calling it while other threads may read `PATH` is a data race.
|
||||||
/// * In the Tauri desktop binary: call from `run()` before `tauri::Builder`.
|
/// * In the Tauri desktop binary: call from `run()` before `tauri::Builder`.
|
||||||
/// * In the standalone server binary: call from `main()` before `#[tokio::main]`.
|
/// * In the standalone server binary: call from `main()` before building the
|
||||||
|
/// tokio runtime (do **not** use `#[tokio::main]` which spawns threads first).
|
||||||
/// * In Docker / systemd services: typically a no-op — `which("node")`
|
/// * In Docker / systemd services: typically a no-op — `which("node")`
|
||||||
/// succeeds because `node` is installed to a standard PATH directory.
|
/// succeeds because `node` is installed to a standard PATH directory.
|
||||||
pub fn ensure_node_in_path() {
|
pub fn ensure_node_in_path() {
|
||||||
@@ -142,15 +143,12 @@ pub fn ensure_node_in_path() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let home = match dirs::home_dir() {
|
let home = dirs::home_dir();
|
||||||
Some(h) => h,
|
if home.is_none() {
|
||||||
None => {
|
eprintln!("[PATH] HOME not set; env-var-only Node.js search (no home-relative paths)");
|
||||||
eprintln!("[PATH] node not in PATH and HOME not set; cannot search for Node.js");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(bin_dir) = find_node_bin_dir(&home) {
|
if let Some(bin_dir) = find_node_bin_dir(home.as_deref()) {
|
||||||
prepend_to_path(&bin_dir);
|
prepend_to_path(&bin_dir);
|
||||||
eprintln!("[PATH] node not in PATH, prepended {}", bin_dir.display());
|
eprintln!("[PATH] node not in PATH, prepended {}", bin_dir.display());
|
||||||
}
|
}
|
||||||
@@ -159,6 +157,11 @@ pub fn ensure_node_in_path() {
|
|||||||
/// Search common Node.js version manager directories for a `node` binary and
|
/// Search common Node.js version manager directories for a `node` binary and
|
||||||
/// return the containing bin directory.
|
/// return the containing bin directory.
|
||||||
///
|
///
|
||||||
|
/// `home` may be `None` in minimal environments (Docker, systemd without HOME).
|
||||||
|
/// When `None`, only version managers whose location is determined by an
|
||||||
|
/// explicit environment variable are searched; home-relative default paths
|
||||||
|
/// (e.g. `~/.nvm`) are skipped.
|
||||||
|
///
|
||||||
/// Supported version managers / installation methods:
|
/// Supported version managers / installation methods:
|
||||||
/// - **nvm** (Unix) — `$NVM_DIR` or `~/.nvm`
|
/// - **nvm** (Unix) — `$NVM_DIR` or `~/.nvm`
|
||||||
/// - **nvm-windows** — `%NVM_SYMLINK%`, `%NVM_HOME%` or `%APPDATA%\nvm`
|
/// - **nvm-windows** — `%NVM_SYMLINK%`, `%NVM_HOME%` or `%APPDATA%\nvm`
|
||||||
@@ -169,26 +172,41 @@ pub fn ensure_node_in_path() {
|
|||||||
/// - **n** (Unix) — `$N_PREFIX` or `/usr/local`
|
/// - **n** (Unix) — `$N_PREFIX` or `/usr/local`
|
||||||
/// - **Homebrew** (macOS) — `/opt/homebrew/opt/node` or `/usr/local/opt/node`
|
/// - **Homebrew** (macOS) — `/opt/homebrew/opt/node` or `/usr/local/opt/node`
|
||||||
/// - **Scoop** (Windows) — `%SCOOP%\apps\nodejs*\current`
|
/// - **Scoop** (Windows) — `%SCOOP%\apps\nodejs*\current`
|
||||||
fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
fn find_node_bin_dir(home: Option<&std::path::Path>) -> Option<PathBuf> {
|
||||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
let node_bin = if cfg!(windows) { "node.exe" } else { "node" };
|
let node_bin = if cfg!(windows) { "node.exe" } else { "node" };
|
||||||
|
|
||||||
|
/// Try each `(env_var, suffix_segments)` in order; return as soon as one
|
||||||
|
/// env var is set. If none match, fall back to `home / home_relative`.
|
||||||
|
/// Returns `None` when no env var is set **and** `home` is `None` —
|
||||||
|
/// the caller should skip that version manager entirely.
|
||||||
|
fn resolve_dir(
|
||||||
|
env_chain: &[(&str, &[&str])],
|
||||||
|
home: Option<&std::path::Path>,
|
||||||
|
home_relative: &[&str],
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
for (key, suffixes) in env_chain {
|
||||||
|
if let Ok(val) = std::env::var(key) {
|
||||||
|
return Some(suffixes.iter().fold(PathBuf::from(val), |p, s| p.join(s)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
home.map(|h| home_relative.iter().fold(h.to_path_buf(), |p, s| p.join(s)))
|
||||||
|
}
|
||||||
|
|
||||||
// ── nvm (Unix) ───────────────────────────────────────────────────────
|
// ── nvm (Unix) ───────────────────────────────────────────────────────
|
||||||
// Standard nvm for macOS/Linux. nvm-windows is a separate tool (below).
|
// Standard nvm for macOS/Linux. nvm-windows is a separate tool (below).
|
||||||
if cfg!(not(windows)) {
|
if cfg!(not(windows)) {
|
||||||
let nvm_dir = std::env::var("NVM_DIR")
|
if let Some(nvm_dir) = resolve_dir(&[("NVM_DIR", &[])], home, &[".nvm"]) {
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home.join(".nvm"));
|
|
||||||
if nvm_dir.is_dir() {
|
if nvm_dir.is_dir() {
|
||||||
let versions_dir = nvm_dir.join("versions").join("node");
|
let versions_dir = nvm_dir.join("versions").join("node");
|
||||||
let mut alias_matched = false;
|
let mut alias_matched = false;
|
||||||
|
|
||||||
// Try to match the "default" alias to a concrete version.
|
// Try to match the "default" alias to a concrete version.
|
||||||
// The alias may be a partial version (e.g. "18", "20.11"), a full
|
// The alias may be a partial version (e.g. "18", "20.11"),
|
||||||
// version, or a symbolic name ("lts/*", "lts/hydrogen", "node").
|
// a full version, or a symbolic name ("lts/*", "node").
|
||||||
// We only attempt matching for numeric prefixes — symbolic aliases
|
// We only attempt matching for numeric prefixes — symbolic
|
||||||
// require full nvm resolution which we cannot replicate here.
|
// aliases require full nvm resolution we cannot replicate.
|
||||||
let default_alias = nvm_dir.join("alias").join("default");
|
let default_alias = nvm_dir.join("alias").join("default");
|
||||||
if let Ok(raw_alias) = std::fs::read_to_string(&default_alias) {
|
if let Ok(raw_alias) = std::fs::read_to_string(&default_alias) {
|
||||||
let alias = raw_alias.trim();
|
let alias = raw_alias.trim();
|
||||||
@@ -198,13 +216,20 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
if is_numeric {
|
if is_numeric {
|
||||||
let alias_stripped = alias.trim_start_matches('v');
|
let alias_stripped = alias.trim_start_matches('v');
|
||||||
if let Ok(entries) = std::fs::read_dir(&versions_dir) {
|
if let Ok(entries) = std::fs::read_dir(&versions_dir) {
|
||||||
for entry in entries.flatten() {
|
let mut matched: Vec<PathBuf> = entries
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
.flatten()
|
||||||
|
.filter(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
let stripped = name.trim_start_matches('v');
|
let stripped = name.trim_start_matches('v');
|
||||||
if stripped.starts_with(alias_stripped) {
|
stripped.starts_with(alias_stripped)
|
||||||
candidates.push(entry.path().join("bin"));
|
})
|
||||||
|
.map(|e| e.path().join("bin"))
|
||||||
|
.collect();
|
||||||
|
if !matched.is_empty() {
|
||||||
|
matched.sort();
|
||||||
|
matched.reverse();
|
||||||
alias_matched = true;
|
alias_matched = true;
|
||||||
}
|
candidates.append(&mut matched);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,6 +252,7 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── nvm-windows ──────────────────────────────────────────────────────
|
// ── nvm-windows ──────────────────────────────────────────────────────
|
||||||
// nvm-windows is a completely separate tool from Unix nvm with a
|
// nvm-windows is a completely separate tool from Unix nvm with a
|
||||||
@@ -243,12 +269,9 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
|
|
||||||
// All installed versions, newest first (lexicographic — see note
|
// All installed versions, newest first (lexicographic — see note
|
||||||
// in the nvm section about semver edge cases).
|
// in the nvm section about semver edge cases).
|
||||||
let nvm_home = std::env::var("NVM_HOME")
|
if let Some(nvm_home) =
|
||||||
.map(PathBuf::from)
|
resolve_dir(&[("NVM_HOME", &[]), ("APPDATA", &["nvm"])], None, &[])
|
||||||
.or_else(|_| {
|
{
|
||||||
std::env::var("APPDATA").map(|appdata| PathBuf::from(appdata).join("nvm"))
|
|
||||||
});
|
|
||||||
if let Ok(nvm_home) = nvm_home {
|
|
||||||
if nvm_home.is_dir() {
|
if nvm_home.is_dir() {
|
||||||
if let Ok(mut entries) = std::fs::read_dir(&nvm_home).map(|rd| {
|
if let Ok(mut entries) = std::fs::read_dir(&nvm_home).map(|rd| {
|
||||||
rd.flatten()
|
rd.flatten()
|
||||||
@@ -280,19 +303,18 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Platform-specific default for FNM_DIR:
|
// Platform-specific default for FNM_DIR:
|
||||||
// Unix: $XDG_DATA_HOME/fnm or ~/.local/share/fnm
|
// Unix: $FNM_DIR → $XDG_DATA_HOME/fnm → ~/.local/share/fnm
|
||||||
// Windows: %APPDATA%/fnm
|
// Windows: $FNM_DIR → %APPDATA%/fnm → ~/.fnm
|
||||||
let fnm_dir = std::env::var("FNM_DIR").map(PathBuf::from).unwrap_or_else(|_| {
|
let fnm_dir = if cfg!(windows) {
|
||||||
if cfg!(windows) {
|
resolve_dir(&[("FNM_DIR", &[]), ("APPDATA", &["fnm"])], home, &[".fnm"])
|
||||||
std::env::var("APPDATA")
|
|
||||||
.map(|appdata| PathBuf::from(appdata).join("fnm"))
|
|
||||||
.unwrap_or_else(|_| home.join(".fnm"))
|
|
||||||
} else {
|
} else {
|
||||||
std::env::var("XDG_DATA_HOME")
|
resolve_dir(
|
||||||
.map(|xdg| PathBuf::from(xdg).join("fnm"))
|
&[("FNM_DIR", &[]), ("XDG_DATA_HOME", &["fnm"])],
|
||||||
.unwrap_or_else(|_| home.join(".local").join("share").join("fnm"))
|
home,
|
||||||
}
|
&[".local", "share", "fnm"],
|
||||||
});
|
)
|
||||||
|
};
|
||||||
|
if let Some(fnm_dir) = fnm_dir {
|
||||||
let fnm_versions = fnm_dir.join("node-versions");
|
let fnm_versions = fnm_dir.join("node-versions");
|
||||||
if fnm_versions.is_dir() {
|
if fnm_versions.is_dir() {
|
||||||
if let Ok(mut entries) = std::fs::read_dir(&fnm_versions)
|
if let Ok(mut entries) = std::fs::read_dir(&fnm_versions)
|
||||||
@@ -309,6 +331,7 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── volta ────────────────────────────────────────────────────────────
|
// ── volta ────────────────────────────────────────────────────────────
|
||||||
// Volta's bin/ directory contains *shims* — they exist even if no Node
|
// Volta's bin/ directory contains *shims* — they exist even if no Node
|
||||||
@@ -316,9 +339,7 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
// shim directory when at least one concrete Node image is present,
|
// shim directory when at least one concrete Node image is present,
|
||||||
// otherwise downstream `node` invocations would get a cryptic Volta
|
// otherwise downstream `node` invocations would get a cryptic Volta
|
||||||
// error instead of a clean "node not found".
|
// error instead of a clean "node not found".
|
||||||
let volta_home = std::env::var("VOLTA_HOME")
|
if let Some(volta_home) = resolve_dir(&[("VOLTA_HOME", &[])], home, &[".volta"]) {
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home.join(".volta"));
|
|
||||||
let volta_node_images = volta_home.join("tools").join("image").join("node");
|
let volta_node_images = volta_home.join("tools").join("image").join("node");
|
||||||
let has_volta_node = volta_node_images
|
let has_volta_node = volta_node_images
|
||||||
.is_dir()
|
.is_dir()
|
||||||
@@ -331,13 +352,12 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
candidates.push(volta_bin);
|
candidates.push(volta_bin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── asdf (Unix) ──────────────────────────────────────────────────────
|
// ── asdf (Unix) ──────────────────────────────────────────────────────
|
||||||
// asdf does not officially support Windows.
|
// asdf does not officially support Windows.
|
||||||
if cfg!(not(windows)) {
|
if cfg!(not(windows)) {
|
||||||
let asdf_dir = std::env::var("ASDF_DATA_DIR")
|
if let Some(asdf_dir) = resolve_dir(&[("ASDF_DATA_DIR", &[])], home, &[".asdf"]) {
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home.join(".asdf"));
|
|
||||||
let asdf_nodejs = asdf_dir.join("installs").join("nodejs");
|
let asdf_nodejs = asdf_dir.join("installs").join("nodejs");
|
||||||
if asdf_nodejs.is_dir() {
|
if asdf_nodejs.is_dir() {
|
||||||
if let Ok(mut entries) = std::fs::read_dir(&asdf_nodejs)
|
if let Ok(mut entries) = std::fs::read_dir(&asdf_nodejs)
|
||||||
@@ -351,17 +371,21 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── mise / rtx (cross-platform) ─────────────────────────────────────
|
// ── mise / rtx (cross-platform) ─────────────────────────────────────
|
||||||
// mise respects MISE_DATA_DIR > XDG_DATA_HOME > platform data dir.
|
// mise respects MISE_DATA_DIR > XDG_DATA_HOME > dirs::data_dir() > home.
|
||||||
let mise_dir = std::env::var("MISE_DATA_DIR")
|
let mise_dir = resolve_dir(
|
||||||
.or_else(|_| std::env::var("XDG_DATA_HOME").map(|xdg| format!("{}/mise", xdg)))
|
&[("MISE_DATA_DIR", &[]), ("XDG_DATA_HOME", &["mise"])],
|
||||||
.map(PathBuf::from)
|
None,
|
||||||
.unwrap_or_else(|_| {
|
&[],
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
.unwrap_or_else(|| home.join(".local").join("share"))
|
.or_else(|| home.map(|h| h.join(".local").join("share")))
|
||||||
.join("mise")
|
.map(|d| d.join("mise"))
|
||||||
});
|
});
|
||||||
|
if let Some(mise_dir) = mise_dir {
|
||||||
let mise_node = mise_dir.join("installs").join("node");
|
let mise_node = mise_dir.join("installs").join("node");
|
||||||
if mise_node.is_dir() {
|
if mise_node.is_dir() {
|
||||||
if let Ok(mut entries) = std::fs::read_dir(&mise_node)
|
if let Ok(mut entries) = std::fs::read_dir(&mise_node)
|
||||||
@@ -377,9 +401,11 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── n (Unix) ─────────────────────────────────────────────────────────
|
// ── n (Unix) ─────────────────────────────────────────────────────────
|
||||||
// `n` stores versions under $N_PREFIX/n/versions/node/<version>/bin/.
|
// `n` stores versions under $N_PREFIX/n/versions/node/<version>/bin/.
|
||||||
|
// N_PREFIX defaults to /usr/local (no home dependency).
|
||||||
if cfg!(not(windows)) {
|
if cfg!(not(windows)) {
|
||||||
let n_prefix = std::env::var("N_PREFIX")
|
let n_prefix = std::env::var("N_PREFIX")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -411,9 +437,7 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
|
|
||||||
// ── Scoop (Windows) ─────────────────────────────────────────────────
|
// ── Scoop (Windows) ─────────────────────────────────────────────────
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
let scoop_dir = std::env::var("SCOOP")
|
if let Some(scoop_dir) = resolve_dir(&[("SCOOP", &[])], home, &["scoop"]) {
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| home.join("scoop"));
|
|
||||||
// Scoop may install as "nodejs-lts" or "nodejs".
|
// Scoop may install as "nodejs-lts" or "nodejs".
|
||||||
for app_name in &["nodejs-lts", "nodejs"] {
|
for app_name in &["nodejs-lts", "nodejs"] {
|
||||||
let scoop_node = scoop_dir.join("apps").join(app_name).join("current");
|
let scoop_node = scoop_dir.join("apps").join(app_name).join("current");
|
||||||
@@ -422,6 +446,7 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return the first candidate that actually contains a `node` binary.
|
// Return the first candidate that actually contains a `node` binary.
|
||||||
candidates.into_iter().find(|dir| dir.join(node_bin).is_file())
|
candidates.into_iter().find(|dir| dir.join(node_bin).is_file())
|
||||||
|
|||||||
Reference in New Issue
Block a user