511 lines
21 KiB
Rust
511 lines
21 KiB
Rust
use std::ffi::{OsStr, OsString};
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
#[cfg(windows)]
|
|
use std::path::Path;
|
|
|
|
#[cfg(windows)]
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
|
|
pub fn configure_std_command(command: &mut Command) -> &mut Command {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
set_utf8_env(command);
|
|
command
|
|
}
|
|
|
|
pub fn std_command<S>(program: S) -> Command
|
|
where
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let mut command = Command::new(normalized_program(program));
|
|
configure_std_command(&mut command);
|
|
command
|
|
}
|
|
|
|
pub fn configure_tokio_command(
|
|
command: &mut tokio::process::Command,
|
|
) -> &mut tokio::process::Command {
|
|
#[cfg(windows)]
|
|
{
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
set_utf8_env(command);
|
|
command
|
|
}
|
|
|
|
/// Force child processes to emit English, UTF-8 output.
|
|
///
|
|
/// Why: downstream code classifies errors by substring-matching English
|
|
/// git/coreutils stderr (e.g. "unknown revision or path not in the working
|
|
/// tree"). Without pinning the locale, those matches silently fail under
|
|
/// non-English system locales and legitimate empty-repo cases bubble up as
|
|
/// red error banners.
|
|
fn set_utf8_env<C: SetEnv>(command: &mut C) {
|
|
// Python
|
|
command.env("PYTHONUTF8", "1");
|
|
command.env("PYTHONIOENCODING", "utf-8");
|
|
// POSIX locale — honored by git, coreutils, MSYS2/Git-for-Windows.
|
|
command.env("LANG", "C.UTF-8");
|
|
command.env("LC_ALL", "C.UTF-8");
|
|
}
|
|
|
|
/// Abstraction over the `.env()` method shared by std and tokio Command types.
|
|
trait SetEnv {
|
|
fn env(&mut self, key: &str, val: &str) -> &mut Self;
|
|
}
|
|
|
|
impl SetEnv for Command {
|
|
fn env(&mut self, key: &str, val: &str) -> &mut Self {
|
|
Command::env(self, key, val)
|
|
}
|
|
}
|
|
|
|
impl SetEnv for tokio::process::Command {
|
|
fn env(&mut self, key: &str, val: &str) -> &mut Self {
|
|
tokio::process::Command::env(self, key, val)
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn maybe_windows_cmd_shim(program: &OsStr) -> Option<OsString> {
|
|
let path = Path::new(program);
|
|
if path.components().count() != 1 || path.extension().is_some() {
|
|
return None;
|
|
}
|
|
|
|
let raw = program.to_string_lossy();
|
|
let normalized = raw.to_ascii_lowercase();
|
|
let needs_cmd_shim = matches!(
|
|
normalized.as_str(),
|
|
"npm" | "npx" | "pnpm" | "pnpx" | "yarn" | "yarnpkg" | "corepack"
|
|
);
|
|
|
|
if needs_cmd_shim {
|
|
Some(OsString::from(format!("{raw}.cmd")))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn normalized_program<S>(program: S) -> OsString
|
|
where
|
|
S: AsRef<OsStr>,
|
|
{
|
|
#[cfg(windows)]
|
|
{
|
|
if let Some(shimmed) = maybe_windows_cmd_shim(program.as_ref()) {
|
|
return shimmed;
|
|
}
|
|
}
|
|
|
|
program.as_ref().to_os_string()
|
|
}
|
|
|
|
pub fn tokio_command<S>(program: S) -> tokio::process::Command
|
|
where
|
|
S: AsRef<OsStr>,
|
|
{
|
|
let mut command = tokio::process::Command::new(normalized_program(program));
|
|
configure_tokio_command(&mut command);
|
|
command
|
|
}
|
|
|
|
/// If `node` is not already in PATH, detect common Node.js version manager
|
|
/// installations and prepend the best matching bin directory to the process
|
|
/// PATH so that **all** downstream code (`which`, `Command`, child processes)
|
|
/// can find node/npm/npx without any special handling.
|
|
///
|
|
/// Only ONE directory is ever added (the first candidate that contains a
|
|
/// real `node` binary), so PATH pollution is minimal.
|
|
///
|
|
/// # Call site requirements
|
|
///
|
|
/// * Call **once** at startup, **before** any multi-threaded work begins.
|
|
/// `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.
|
|
/// * In the Tauri desktop binary: call from `run()` before `tauri::Builder`.
|
|
/// * 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")`
|
|
/// succeeds because `node` is installed to a standard PATH directory.
|
|
pub fn ensure_node_in_path() {
|
|
// Already reachable — nothing to do.
|
|
if which::which("node").is_ok() {
|
|
return;
|
|
}
|
|
|
|
let home = dirs::home_dir();
|
|
if home.is_none() {
|
|
eprintln!("[PATH] HOME not set; env-var-only Node.js search (no home-relative paths)");
|
|
}
|
|
|
|
if let Some(bin_dir) = find_node_bin_dir(home.as_deref()) {
|
|
prepend_to_path(&bin_dir);
|
|
eprintln!("[PATH] node not in PATH, prepended {}", bin_dir.display());
|
|
}
|
|
}
|
|
|
|
/// Search common Node.js version manager directories for a `node` binary and
|
|
/// 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:
|
|
/// - **nvm** (Unix) — `$NVM_DIR` or `~/.nvm`
|
|
/// - **nvm-windows** — `%NVM_SYMLINK%`, `%NVM_HOME%` or `%APPDATA%\nvm`
|
|
/// - **fnm** (cross-platform) — `$FNM_MULTISHELL_PATH`, `$FNM_DIR` or platform default
|
|
/// - **volta** (cross-platform) — `$VOLTA_HOME` or `~/.volta`
|
|
/// - **asdf** (Unix) — `$ASDF_DATA_DIR` or `~/.asdf`
|
|
/// - **mise / rtx** (cross-platform) — `$MISE_DATA_DIR` or platform default
|
|
/// - **n** (Unix) — `$N_PREFIX` or `/usr/local`
|
|
/// - **Homebrew** (macOS) — `/opt/homebrew/opt/node` or `/usr/local/opt/node`
|
|
/// - **Scoop** (Windows) — `%SCOOP%\apps\nodejs*\current`
|
|
fn find_node_bin_dir(home: Option<&std::path::Path>) -> Option<PathBuf> {
|
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
|
|
|
let node_bin = if cfg!(windows) { "node.exe" } else { "node" };
|
|
|
|
/// Extract a (major, minor, patch) tuple from a version directory name
|
|
/// like `v20.11.1` or `20.11.1` for correct numeric sorting.
|
|
/// Falls back to (0,0,0) for unparseable names so they sort last.
|
|
fn semver_key(path: &std::path::Path) -> (u32, u32, u32) {
|
|
let name = path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.trim_start_matches('v')
|
|
.to_string();
|
|
let mut parts = name.split('.').filter_map(|s| s.parse::<u32>().ok());
|
|
(
|
|
parts.next().unwrap_or(0),
|
|
parts.next().unwrap_or(0),
|
|
parts.next().unwrap_or(0),
|
|
)
|
|
}
|
|
|
|
/// 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) ───────────────────────────────────────────────────────
|
|
// Standard nvm for macOS/Linux. nvm-windows is a separate tool (below).
|
|
if cfg!(not(windows)) {
|
|
if let Some(nvm_dir) = resolve_dir(&[("NVM_DIR", &[])], home, &[".nvm"]) {
|
|
if nvm_dir.is_dir() {
|
|
let versions_dir = nvm_dir.join("versions").join("node");
|
|
let mut alias_matched = false;
|
|
|
|
// Try to match the "default" alias to a concrete version.
|
|
// The alias may be a partial version (e.g. "18", "20.11"),
|
|
// a full version, or a symbolic name ("lts/*", "node").
|
|
// We only attempt matching for numeric prefixes — symbolic
|
|
// aliases require full nvm resolution we cannot replicate.
|
|
let default_alias = nvm_dir.join("alias").join("default");
|
|
if let Ok(raw_alias) = std::fs::read_to_string(&default_alias) {
|
|
let alias = raw_alias.trim();
|
|
let is_numeric = alias
|
|
.trim_start_matches('v')
|
|
.starts_with(|c: char| c.is_ascii_digit());
|
|
if is_numeric {
|
|
let alias_stripped = alias.trim_start_matches('v');
|
|
if let Ok(entries) = std::fs::read_dir(&versions_dir) {
|
|
let mut matched: Vec<PathBuf> = entries
|
|
.flatten()
|
|
.filter(|e| {
|
|
let name = e.file_name().to_string_lossy().to_string();
|
|
let stripped = name.trim_start_matches('v');
|
|
stripped.starts_with(alias_stripped)
|
|
})
|
|
.map(|e| e.path())
|
|
.collect();
|
|
if !matched.is_empty() {
|
|
matched.sort_by_key(|p| semver_key(p));
|
|
matched.reverse();
|
|
alias_matched = true;
|
|
candidates.extend(matched.into_iter().map(|p| p.join("bin")));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back: all installed versions, newest first.
|
|
// Skipped when alias resolution already produced candidates.
|
|
if !alias_matched {
|
|
if let Ok(mut entries) = std::fs::read_dir(&versions_dir)
|
|
.map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
|
|
{
|
|
entries.sort_by_key(|p| semver_key(p));
|
|
entries.reverse();
|
|
for entry in entries {
|
|
candidates.push(entry.join("bin"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── nvm-windows ──────────────────────────────────────────────────────
|
|
// nvm-windows is a completely separate tool from Unix nvm with a
|
|
// different directory layout: %NVM_HOME%\v<version>\node.exe (no bin/).
|
|
// The active version is symlinked at %NVM_SYMLINK%.
|
|
if cfg!(windows) {
|
|
// The active symlinked version directory (e.g. C:\Program Files\nodejs)
|
|
if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") {
|
|
let symlink_path = PathBuf::from(&nvm_symlink);
|
|
if symlink_path.is_dir() {
|
|
candidates.push(symlink_path);
|
|
}
|
|
}
|
|
|
|
// All installed versions, newest first.
|
|
if let Some(nvm_home) = resolve_dir(&[("NVM_HOME", &[]), ("APPDATA", &["nvm"])], None, &[])
|
|
{
|
|
if nvm_home.is_dir() {
|
|
if let Ok(mut entries) = std::fs::read_dir(&nvm_home).map(|rd| {
|
|
rd.flatten()
|
|
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
|
|
.filter(|e| e.file_name().to_string_lossy().starts_with('v'))
|
|
.map(|e| e.path())
|
|
.collect::<Vec<_>>()
|
|
}) {
|
|
entries.sort_by_key(|p| semver_key(p));
|
|
entries.reverse();
|
|
// nvm-windows places node.exe directly in the version dir
|
|
candidates.extend(entries);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── fnm ──────────────────────────────────────────────────────────────
|
|
// FNM_MULTISHELL_PATH is set by `eval "$(fnm env)"` in the user's
|
|
// shell RC. It points to a temporary directory that only exists during
|
|
// an active shell session. In a GUI app (Tauri) this is typically
|
|
// NOT set because the process inherits from the window manager, not a
|
|
// shell. It mainly helps the *server binary* launched from a terminal.
|
|
if let Ok(fnm_multishell_path) = std::env::var("FNM_MULTISHELL_PATH") {
|
|
let path = PathBuf::from(fnm_multishell_path);
|
|
if path.is_dir() {
|
|
candidates.push(path);
|
|
}
|
|
}
|
|
|
|
// Platform-specific default for FNM_DIR:
|
|
// Unix: $FNM_DIR → $XDG_DATA_HOME/fnm → ~/.local/share/fnm
|
|
// Windows: $FNM_DIR → %APPDATA%/fnm → ~/.fnm
|
|
let fnm_dir = if cfg!(windows) {
|
|
resolve_dir(&[("FNM_DIR", &[]), ("APPDATA", &["fnm"])], home, &[".fnm"])
|
|
} else {
|
|
resolve_dir(
|
|
&[("FNM_DIR", &[]), ("XDG_DATA_HOME", &["fnm"])],
|
|
home,
|
|
&[".local", "share", "fnm"],
|
|
)
|
|
};
|
|
if let Some(fnm_dir) = fnm_dir {
|
|
let fnm_versions = fnm_dir.join("node-versions");
|
|
if fnm_versions.is_dir() {
|
|
if let Ok(mut entries) = std::fs::read_dir(&fnm_versions)
|
|
.map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
|
|
{
|
|
entries.sort_by_key(|p| semver_key(p));
|
|
entries.reverse();
|
|
for entry in entries {
|
|
let installation = entry.join("installation");
|
|
// On Unix fnm places binaries under installation/bin;
|
|
// on Windows they sit directly in the installation dir.
|
|
let bin = installation.join("bin");
|
|
candidates.push(if bin.is_dir() { bin } else { installation });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── volta ────────────────────────────────────────────────────────────
|
|
// Volta's bin/ directory contains *shims* — they exist even if no Node
|
|
// version has been installed (`volta install node`). Only add the
|
|
// shim directory when at least one concrete Node image is present,
|
|
// otherwise downstream `node` invocations would get a cryptic Volta
|
|
// error instead of a clean "node not found".
|
|
if let Some(volta_home) = resolve_dir(&[("VOLTA_HOME", &[])], home, &[".volta"]) {
|
|
let volta_node_images = volta_home.join("tools").join("image").join("node");
|
|
let has_volta_node = volta_node_images
|
|
.is_dir()
|
|
.then(|| std::fs::read_dir(&volta_node_images).ok())
|
|
.flatten()
|
|
.is_some_and(|mut rd| rd.next().is_some());
|
|
if has_volta_node {
|
|
let volta_bin = volta_home.join("bin");
|
|
if volta_bin.is_dir() {
|
|
candidates.push(volta_bin);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── asdf (Unix) ──────────────────────────────────────────────────────
|
|
// asdf does not officially support Windows.
|
|
if cfg!(not(windows)) {
|
|
if let Some(asdf_dir) = resolve_dir(&[("ASDF_DATA_DIR", &[])], home, &[".asdf"]) {
|
|
let asdf_nodejs = asdf_dir.join("installs").join("nodejs");
|
|
if asdf_nodejs.is_dir() {
|
|
if let Ok(mut entries) = std::fs::read_dir(&asdf_nodejs)
|
|
.map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
|
|
{
|
|
entries.sort_by_key(|p| semver_key(p));
|
|
entries.reverse();
|
|
for entry in entries {
|
|
candidates.push(entry.join("bin"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── mise / rtx (cross-platform) ─────────────────────────────────────
|
|
// mise respects MISE_DATA_DIR > XDG_DATA_HOME > dirs::data_dir() > home.
|
|
let mise_dir = resolve_dir(
|
|
&[("MISE_DATA_DIR", &[]), ("XDG_DATA_HOME", &["mise"])],
|
|
None,
|
|
&[],
|
|
)
|
|
.or_else(|| {
|
|
dirs::data_dir()
|
|
.or_else(|| home.map(|h| h.join(".local").join("share")))
|
|
.map(|d| d.join("mise"))
|
|
});
|
|
if let Some(mise_dir) = mise_dir {
|
|
let mise_node = mise_dir.join("installs").join("node");
|
|
if mise_node.is_dir() {
|
|
if let Ok(mut entries) = std::fs::read_dir(&mise_node)
|
|
.map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
|
|
{
|
|
entries.sort_by_key(|p| semver_key(p));
|
|
entries.reverse();
|
|
for entry in entries {
|
|
// mise on Unix places binaries under <version>/bin/;
|
|
// on Windows they may sit directly in the version dir.
|
|
let bin = entry.join("bin");
|
|
candidates.push(if bin.is_dir() { bin } else { entry });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── n (Unix) ─────────────────────────────────────────────────────────
|
|
// `n` stores versions under $N_PREFIX/n/versions/node/<version>/bin/.
|
|
// N_PREFIX defaults to /usr/local (no home dependency).
|
|
if cfg!(not(windows)) {
|
|
let n_prefix = std::env::var("N_PREFIX")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|_| PathBuf::from("/usr/local"));
|
|
let n_versions = n_prefix.join("n").join("versions").join("node");
|
|
if n_versions.is_dir() {
|
|
if let Ok(mut entries) = std::fs::read_dir(&n_versions)
|
|
.map(|rd| rd.flatten().map(|e| e.path()).collect::<Vec<_>>())
|
|
{
|
|
entries.sort_by_key(|p| semver_key(p));
|
|
entries.reverse();
|
|
for entry in entries {
|
|
candidates.push(entry.join("bin"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Homebrew (macOS) ─────────────────────────────────────────────────
|
|
if cfg!(target_os = "macos") {
|
|
// Apple Silicon (/opt/homebrew) and Intel (/usr/local)
|
|
for prefix in &["/opt/homebrew", "/usr/local"] {
|
|
let brew_node = PathBuf::from(prefix).join("opt").join("node").join("bin");
|
|
if brew_node.is_dir() {
|
|
candidates.push(brew_node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Scoop (Windows) ─────────────────────────────────────────────────
|
|
if cfg!(windows) {
|
|
if let Some(scoop_dir) = resolve_dir(&[("SCOOP", &[])], home, &["scoop"]) {
|
|
// Scoop may install as "nodejs-lts" or "nodejs".
|
|
for app_name in &["nodejs-lts", "nodejs"] {
|
|
let scoop_node = scoop_dir.join("apps").join(app_name).join("current");
|
|
if scoop_node.is_dir() {
|
|
candidates.push(scoop_node);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the first candidate that actually contains a `node` binary.
|
|
candidates
|
|
.into_iter()
|
|
.find(|dir| dir.join(node_bin).is_file())
|
|
}
|
|
|
|
/// Prepend a directory to the process `PATH` environment variable.
|
|
pub(crate) fn prepend_to_path(dir: &std::path::Path) {
|
|
let sep = if cfg!(windows) { ";" } else { ":" };
|
|
let current = std::env::var_os("PATH").unwrap_or_default();
|
|
let mut new_path = OsString::from(dir);
|
|
new_path.push(sep);
|
|
new_path.push(current);
|
|
std::env::set_var("PATH", new_path);
|
|
}
|
|
|
|
/// Return the user-local npm prefix directory (`~/.codeg/npm-global/`).
|
|
///
|
|
/// Used as a fallback when `npm install -g` fails with EACCES because the
|
|
/// system global prefix (e.g. `/usr/local/lib/node_modules/`) is not writable.
|
|
pub(crate) fn user_npm_prefix() -> Option<PathBuf> {
|
|
dirs::home_dir().map(|h| h.join(".codeg").join("npm-global"))
|
|
}
|
|
|
|
/// Ensure the user-local npm prefix `bin/` directory is in `PATH` so that
|
|
/// binaries installed via the EACCES fallback can be found by `which` and
|
|
/// child processes. Safe to call even if the directory does not exist yet.
|
|
///
|
|
/// On Unix, `npm install -g --prefix=<p>` places binaries in `<p>/bin/`.
|
|
/// On Windows, binaries are placed directly in `<p>/`.
|
|
pub fn ensure_user_npm_prefix_in_path() {
|
|
if let Some(prefix) = user_npm_prefix() {
|
|
let bin_dir = if cfg!(windows) {
|
|
prefix
|
|
} else {
|
|
prefix.join("bin")
|
|
};
|
|
// Avoid adding duplicates.
|
|
let current = std::env::var_os("PATH").unwrap_or_default();
|
|
let bin_str = bin_dir.to_string_lossy();
|
|
let sep = if cfg!(windows) { ";" } else { ":" };
|
|
if !current
|
|
.to_string_lossy()
|
|
.split(sep)
|
|
.any(|p| p == bin_str.as_ref())
|
|
{
|
|
prepend_to_path(&bin_dir);
|
|
}
|
|
}
|
|
}
|