修复node全局安装时的权限问题
This commit is contained in:
@@ -9,6 +9,8 @@ use codeg_lib::web::{
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
codeg_lib::process::ensure_user_npm_prefix_in_path();
|
||||||
|
|
||||||
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())
|
||||||
|
|||||||
@@ -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
|
/// Detect the actual installed version of an npm global package by running
|
||||||
/// `npm list -g <package_name> --json` and parsing the JSON output.
|
/// `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> {
|
async fn detect_npm_global_version(package_name: &str) -> Option<String> {
|
||||||
let npm_path = which::which("npm").ok()?;
|
let npm_path = which::which("npm").ok()?;
|
||||||
let output = crate::process::tokio_command(npm_path)
|
|
||||||
.arg("list")
|
// Try the default global prefix first.
|
||||||
.arg("-g")
|
if let Some(v) = npm_list_version(&npm_path, package_name, None).await {
|
||||||
.arg(package_name)
|
return Some(v);
|
||||||
.arg("--json")
|
}
|
||||||
.arg("--depth=0")
|
|
||||||
.output()
|
// Fallback: check the user-local prefix.
|
||||||
.await
|
if let Some(prefix) = crate::process::user_npm_prefix() {
|
||||||
.ok()?;
|
if prefix.exists() {
|
||||||
// npm list --json may exit non-zero when package is missing, but still
|
return npm_list_version(&npm_path, package_name, Some(&prefix)).await;
|
||||||
// outputs valid JSON with an empty dependencies object.
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
|
let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
|
||||||
let version = json
|
let version = json
|
||||||
@@ -153,6 +175,15 @@ async fn install_npm_global_package(package: &str) -> Result<(), AcpError> {
|
|||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
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
|
// EEXIST: file conflict — retry with --force to overwrite
|
||||||
if stderr.contains("EEXIST") {
|
if stderr.contains("EEXIST") {
|
||||||
let retry = crate::process::tokio_command("npm")
|
let retry = crate::process::tokio_command("npm")
|
||||||
@@ -165,7 +196,13 @@ async fn install_npm_global_package(package: &str) -> Result<(), AcpError> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AcpError::protocol(format!("failed to run npm install -g --force: {e}")))?;
|
.map_err(|e| AcpError::protocol(format!("failed to run npm install -g --force: {e}")))?;
|
||||||
if !retry.status.success() {
|
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() {
|
let msg = if err.is_empty() {
|
||||||
"failed to install npm package globally (with --force)".to_string()
|
"failed to install npm package globally (with --force)".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -188,10 +225,102 @@ async fn install_npm_global_package(package: &str) -> Result<(), AcpError> {
|
|||||||
Ok(())
|
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> {
|
async fn uninstall_npm_global_package(package: &str) -> Result<(), AcpError> {
|
||||||
let package_name = package_name_from_spec(package);
|
let package_name = package_name_from_spec(package);
|
||||||
|
|
||||||
if !package_name.is_empty() {
|
if !package_name.is_empty() {
|
||||||
|
// Try uninstalling from the default global prefix.
|
||||||
let output = crate::process::tokio_command("npm")
|
let output = crate::process::tokio_command("npm")
|
||||||
.arg("uninstall")
|
.arg("uninstall")
|
||||||
.arg("-g")
|
.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}")))?;
|
.map_err(|e| AcpError::protocol(format!("failed to run npm uninstall -g: {e}")))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
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() {
|
let msg = if err.is_empty() {
|
||||||
"failed to uninstall npm package globally".to_string()
|
"failed to uninstall npm package globally".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -209,6 +344,47 @@ async fn uninstall_npm_global_package(package: &str) -> Result<(), AcpError> {
|
|||||||
};
|
};
|
||||||
return Err(AcpError::protocol(msg));
|
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(())
|
Ok(())
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pub mod keyring_store;
|
|||||||
mod models;
|
mod models;
|
||||||
mod network;
|
mod network;
|
||||||
mod parsers;
|
mod parsers;
|
||||||
mod process;
|
pub mod process;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ mod tauri_app {
|
|||||||
eprintln!("[PATH] fix_path_env failed: {err}");
|
eprintln!("[PATH] fix_path_env failed: {err}");
|
||||||
}
|
}
|
||||||
process::ensure_node_in_path();
|
process::ensure_node_in_path();
|
||||||
|
process::ensure_user_npm_prefix_in_path();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_window_state::Builder::new().build())
|
.plugin(tauri_plugin_window_state::Builder::new().build())
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
#[cfg(feature = "tauri-runtime")]
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
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.
|
/// Prepend a directory to the process `PATH` environment variable.
|
||||||
#[cfg(feature = "tauri-runtime")]
|
pub(crate) fn prepend_to_path(dir: &std::path::Path) {
|
||||||
fn prepend_to_path(dir: &std::path::Path) {
|
|
||||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||||
let current = std::env::var_os("PATH").unwrap_or_default();
|
let current = std::env::var_os("PATH").unwrap_or_default();
|
||||||
let mut new_path = OsString::from(dir);
|
let mut new_path = OsString::from(dir);
|
||||||
@@ -187,3 +185,38 @@ fn prepend_to_path(dir: &std::path::Path) {
|
|||||||
new_path.push(current);
|
new_path.push(current);
|
||||||
std::env::set_var("PATH", new_path);
|
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