修复node全局安装时的权限问题

This commit is contained in:
xintaofei
2026-03-31 16:00:04 +08:00
parent a9f6ce9105
commit 13c8deee84
4 changed files with 229 additions and 17 deletions

View File

@@ -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())

View File

@@ -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 <package_name> --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<String> {
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 <package_name> --json [--prefix=<p>]` 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<String> {
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, &registry_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, &registry_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(())

View File

@@ -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())

View File

@@ -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<PathBuf> {
}
/// 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<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);
}
}
}