修复node全局安装时的权限问题
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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, ®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(())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user