diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 329a14c..9ed8e5a 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -9,6 +9,8 @@ use codeg_lib::web::{ #[tokio::main] async fn main() { + codeg_lib::process::ensure_user_npm_prefix_in_path(); + let port: u16 = std::env::var("CODEG_PORT") .ok() .and_then(|v| v.parse().ok()) diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index e4728e2..a44e4af 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -93,19 +93,41 @@ pub(crate) fn is_cmd_available(cmd: &str) -> bool { /// Detect the actual installed version of an npm global package by running /// `npm list -g --json` and parsing the JSON output. +/// +/// Checks both the system global prefix and the user-local prefix +/// (`~/.codeg/npm-global/`) so packages installed via the EACCES fallback are +/// found as well. async fn detect_npm_global_version(package_name: &str) -> Option { let npm_path = which::which("npm").ok()?; - let output = crate::process::tokio_command(npm_path) - .arg("list") - .arg("-g") - .arg(package_name) - .arg("--json") - .arg("--depth=0") - .output() - .await - .ok()?; - // npm list --json may exit non-zero when package is missing, but still - // outputs valid JSON with an empty dependencies object. + + // Try the default global prefix first. + if let Some(v) = npm_list_version(&npm_path, package_name, None).await { + return Some(v); + } + + // Fallback: check the user-local prefix. + if let Some(prefix) = crate::process::user_npm_prefix() { + if prefix.exists() { + return npm_list_version(&npm_path, package_name, Some(&prefix)).await; + } + } + + None +} + +/// Run `npm list -g --json [--prefix=

]` and extract the +/// installed version string. +async fn npm_list_version( + npm_path: &std::path::Path, + package_name: &str, + prefix: Option<&std::path::Path>, +) -> Option { + let mut cmd = crate::process::tokio_command(npm_path); + cmd.arg("list").arg("-g").arg(package_name).arg("--json").arg("--depth=0"); + if let Some(p) = prefix { + cmd.arg(format!("--prefix={}", p.display())); + } + let output = cmd.output().await.ok()?; let stdout = String::from_utf8_lossy(&output.stdout); let json: serde_json::Value = serde_json::from_str(&stdout).ok()?; let version = json @@ -153,6 +175,15 @@ async fn install_npm_global_package(package: &str) -> Result<(), AcpError> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); + // EACCES: permission denied — retry with a user-local --prefix so + // we don't require root/sudo on macOS / Linux. + // Check EACCES first: an EEXIST error message may also contain EACCES + // context, and the --force retry would fail again without the prefix + // fallback. + if stderr.contains("EACCES") { + return install_npm_to_user_prefix(package, ®istry_arg).await; + } + // EEXIST: file conflict — retry with --force to overwrite if stderr.contains("EEXIST") { let retry = crate::process::tokio_command("npm") @@ -165,7 +196,13 @@ async fn install_npm_global_package(package: &str) -> Result<(), AcpError> { .await .map_err(|e| AcpError::protocol(format!("failed to run npm install -g --force: {e}")))?; if !retry.status.success() { - let err = String::from_utf8_lossy(&retry.stderr).trim().to_string(); + let retry_stderr = String::from_utf8_lossy(&retry.stderr); + // The --force retry itself may fail with EACCES on systems + // where the global prefix is not writable. + if retry_stderr.contains("EACCES") { + return install_npm_to_user_prefix(package, ®istry_arg).await; + } + let err = retry_stderr.trim().to_string(); let msg = if err.is_empty() { "failed to install npm package globally (with --force)".to_string() } else { @@ -188,10 +225,102 @@ async fn install_npm_global_package(package: &str) -> Result<(), AcpError> { Ok(()) } +/// Fallback: install an npm package into a user-local prefix (`~/.codeg/npm-global/`) +/// when the system global prefix is not writable (EACCES). +async fn install_npm_to_user_prefix(package: &str, registry_arg: &str) -> Result<(), AcpError> { + let prefix = crate::process::user_npm_prefix().ok_or_else(|| { + AcpError::protocol( + "npm install -g failed with EACCES and could not determine home directory for fallback" + .to_string(), + ) + })?; + + // Ensure the prefix directory exists. + tokio::fs::create_dir_all(&prefix).await.map_err(|e| { + AcpError::protocol(format!( + "failed to create user npm prefix {}: {e}", + prefix.display() + )) + })?; + + let prefix_arg = format!("--prefix={}", prefix.display()); + let output = crate::process::tokio_command("npm") + .arg("install") + .arg("-g") + .arg(&prefix_arg) + .arg(registry_arg) + .arg(package) + .output() + .await + .map_err(|e| { + AcpError::protocol(format!("failed to run npm install -g with user prefix: {e}")) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // EEXIST in the user prefix: retry with --force to overwrite stale files + // from a previous installation. + if stderr.contains("EEXIST") { + let force_retry = crate::process::tokio_command("npm") + .arg("install") + .arg("-g") + .arg("--force") + .arg(&prefix_arg) + .arg(registry_arg) + .arg(package) + .output() + .await + .map_err(|e| { + AcpError::protocol(format!( + "failed to run npm install -g --force with user prefix: {e}" + )) + })?; + if !force_retry.status.success() { + let err = String::from_utf8_lossy(&force_retry.stderr) + .trim() + .to_string(); + let msg = if err.is_empty() { + format!( + "failed to install npm package (user prefix {}, --force)", + prefix.display() + ) + } else { + format!( + "failed to install npm package (user prefix {}, --force): {err}", + prefix.display() + ) + }; + return Err(AcpError::protocol(msg)); + } + // --force succeeded, fall through to PATH setup below. + } else { + let err = stderr.trim().to_string(); + let msg = if err.is_empty() { + format!( + "failed to install npm package globally (user prefix {})", + prefix.display() + ) + } else { + format!( + "failed to install npm package globally (user prefix {}): {err}", + prefix.display() + ) + }; + return Err(AcpError::protocol(msg)); + } + } + + // Make sure the user prefix bin dir is in PATH for subsequent `which` lookups. + crate::process::ensure_user_npm_prefix_in_path(); + + Ok(()) +} + async fn uninstall_npm_global_package(package: &str) -> Result<(), AcpError> { let package_name = package_name_from_spec(package); if !package_name.is_empty() { + // Try uninstalling from the default global prefix. let output = crate::process::tokio_command("npm") .arg("uninstall") .arg("-g") @@ -201,7 +330,13 @@ async fn uninstall_npm_global_package(package: &str) -> Result<(), AcpError> { .map_err(|e| AcpError::protocol(format!("failed to run npm uninstall -g: {e}")))?; if !output.status.success() { - let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr); + // EACCES: the package may have been installed to the user-local + // prefix via the EACCES fallback — try uninstalling from there. + if stderr.contains("EACCES") { + return uninstall_npm_from_user_prefix(&package_name).await; + } + let err = stderr.trim().to_string(); let msg = if err.is_empty() { "failed to uninstall npm package globally".to_string() } else { @@ -209,6 +344,47 @@ async fn uninstall_npm_global_package(package: &str) -> Result<(), AcpError> { }; return Err(AcpError::protocol(msg)); } + + // Also try removing from the user prefix (best-effort) in case the + // package was installed in both locations. + let _ = uninstall_npm_from_user_prefix(&package_name).await; + } + + Ok(()) +} + +/// Uninstall an npm package from the user-local prefix (`~/.codeg/npm-global/`). +async fn uninstall_npm_from_user_prefix(package_name: &str) -> Result<(), AcpError> { + let prefix = match crate::process::user_npm_prefix() { + Some(p) if p.exists() => p, + _ => return Ok(()), + }; + + let prefix_arg = format!("--prefix={}", prefix.display()); + let output = crate::process::tokio_command("npm") + .arg("uninstall") + .arg("-g") + .arg(&prefix_arg) + .arg(package_name) + .output() + .await + .map_err(|e| { + AcpError::protocol(format!( + "failed to run npm uninstall -g with user prefix: {e}" + )) + })?; + + if !output.status.success() { + let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let msg = if err.is_empty() { + format!( + "failed to uninstall npm package from user prefix (exit code {})", + output.status.code().unwrap_or(-1) + ) + } else { + format!("failed to uninstall npm package from user prefix: {err}") + }; + return Err(AcpError::protocol(msg)); } Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b8448be..59e7b17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ pub mod keyring_store; mod models; mod network; mod parsers; -mod process; +pub mod process; mod terminal; pub mod web; @@ -44,6 +44,7 @@ mod tauri_app { eprintln!("[PATH] fix_path_env failed: {err}"); } process::ensure_node_in_path(); + process::ensure_user_npm_prefix_in_path(); tauri::Builder::default() .plugin(tauri_plugin_window_state::Builder::new().build()) diff --git a/src-tauri/src/process.rs b/src-tauri/src/process.rs index ab79e40..a327227 100644 --- a/src-tauri/src/process.rs +++ b/src-tauri/src/process.rs @@ -1,5 +1,4 @@ use std::ffi::{OsStr, OsString}; -#[cfg(feature = "tauri-runtime")] use std::path::PathBuf; use std::process::Command; @@ -178,8 +177,7 @@ fn find_node_bin_dir(home: &std::path::Path) -> Option { } /// Prepend a directory to the process `PATH` environment variable. -#[cfg(feature = "tauri-runtime")] -fn prepend_to_path(dir: &std::path::Path) { +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); @@ -187,3 +185,38 @@ fn prepend_to_path(dir: &std::path::Path) { 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 { + 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=

` places binaries in `

/bin/`. +/// On Windows, binaries are placed directly in `

/`. +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); + } + } +}