From 0c3572d77951a3b9fea53db35a17bdf1a41b2480 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 6 Jun 2026 22:45:28 +0800 Subject: [PATCH] fix(mcp): detect node from user shell environment --- src-tauri/src/commands/mcp.rs | 150 ++++++++++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 5d8530895..f6cd04bc5 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -1,3 +1,5 @@ +use std::env; +use std::path::Path; use std::process::Command; use std::time::Duration; @@ -6,6 +8,7 @@ use serde::{Deserialize, Serialize}; const MCP_PACKAGE_NAME: &str = "@dbx-app/mcp-server"; const MCP_LATEST_URL: &str = "https://registry.npmjs.org/@dbx-app%2fmcp-server/latest"; const MCP_INSTALL_COMMAND: &str = "npm install -g @dbx-app/mcp-server@latest --registry=https://registry.npmjs.org"; +const SHELL_COMMAND_MARKER: &str = "__DBX_MCP_COMMAND_OUTPUT_START__"; #[derive(Debug, Serialize)] pub struct McpServerStatus { @@ -90,17 +93,154 @@ fn locate_mcp_bin() -> Option { } fn command_success(command: &str, args: &[&str]) -> bool { - Command::new(command).args(args).output().is_ok_and(|output| output.status.success()) + command_output(command, args).is_ok_and(|output| output.success) } fn command_stdout(command: &str, args: &[&str]) -> Result { - let output = Command::new(command).args(args).output().map_err(|e| e.to_string())?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + let output = command_output(command, args)?; + if !output.success { + return Err(output.stderr.trim().to_string()); } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + Ok(output.stdout.trim().to_string()) } fn first_non_empty_line(value: String) -> Option { value.lines().map(str::trim).find(|line| !line.is_empty()).map(ToOwned::to_owned) } + +#[derive(Debug)] +struct CommandOutput { + success: bool, + stdout: String, + stderr: String, +} + +fn command_output(command: &str, args: &[&str]) -> Result { + let direct = run_command(command, args); + if direct.as_ref().is_ok_and(|output| output.success) { + return direct; + } + + run_command_through_user_shell(command, args).or(direct) +} + +fn run_command(command: &str, args: &[&str]) -> Result { + let output = Command::new(command).args(args).output().map_err(|e| e.to_string())?; + Ok(CommandOutput { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) +} + +#[cfg(windows)] +fn run_command_through_user_shell(_command: &str, _args: &[&str]) -> Result { + Err("User shell fallback is not available on Windows.".to_string()) +} + +#[cfg(not(windows))] +fn run_command_through_user_shell(command: &str, args: &[&str]) -> Result { + let script = shell_command_script(command, args); + let (shell, shell_args) = user_shell_invocation_args(&script); + let shell_arg_refs = shell_args.iter().map(String::as_str).collect::>(); + let mut output = run_command(&shell, &shell_arg_refs)?; + output.stdout = stdout_after_shell_marker(&output.stdout); + Ok(output) +} + +#[cfg(not(windows))] +fn user_shell_invocation_args(script: &str) -> (String, Vec) { + let shell = env::var("SHELL").ok().filter(|value| !value.trim().is_empty()).unwrap_or_else(default_user_shell); + let shell_name = Path::new(&shell).file_name().and_then(|value| value.to_str()).unwrap_or_default(); + let args = match shell_name { + "fish" => vec!["-l".to_string(), "-i".to_string(), "-c".to_string(), script.to_string()], + "bash" => vec![ + "--noprofile".to_string(), + "--norc".to_string(), + "-i".to_string(), + "-c".to_string(), + bash_login_script(script), + ], + "sh" | "dash" => vec!["-ic".to_string(), script.to_string()], + "zsh" => vec!["-ilc".to_string(), script.to_string()], + _ => vec!["-lc".to_string(), script.to_string()], + }; + (shell, args) +} + +#[cfg(not(windows))] +fn bash_login_script(script: &str) -> String { + format!( + "for dbx_profile in ~/.bash_profile ~/.bash_login ~/.profile ~/.bashrc; do \ + [ -r \"$dbx_profile\" ] && . \"$dbx_profile\"; \ + done; unset dbx_profile; {script}" + ) +} + +#[cfg(not(windows))] +fn default_user_shell() -> String { + if Path::new("/bin/zsh").exists() { + "/bin/zsh".to_string() + } else { + "/bin/sh".to_string() + } +} + +fn shell_command_script(command: &str, args: &[&str]) -> String { + let mut words = Vec::with_capacity(args.len() + 1); + words.push(shell_quote(command)); + words.extend(args.iter().map(|arg| shell_quote(arg))); + format!("printf '%s\\n' {}; {}", shell_quote(SHELL_COMMAND_MARKER), words.join(" ")) +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn stdout_after_shell_marker(stdout: &str) -> String { + stdout + .find(SHELL_COMMAND_MARKER) + .map(|index| stdout[index + SHELL_COMMAND_MARKER.len()..].trim_start_matches(['\r', '\n']).to_string()) + .unwrap_or_else(|| stdout.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{ + bash_login_script, shell_command_script, shell_quote, stdout_after_shell_marker, SHELL_COMMAND_MARKER, + }; + + #[test] + fn shell_quote_handles_empty_and_single_quotes() { + assert_eq!(shell_quote(""), "''"); + assert_eq!(shell_quote("npm"), "'npm'"); + assert_eq!(shell_quote("can't"), "'can'\"'\"'t'"); + } + + #[test] + fn shell_command_script_marks_command_output_after_startup_noise() { + let script = shell_command_script("npm", &["list", "-g", "@dbx-app/mcp-server", "--json"]); + + assert!(script.contains(SHELL_COMMAND_MARKER)); + assert!(script.contains("'@dbx-app/mcp-server'")); + } + + #[test] + fn bash_login_script_sources_profile_and_rc_files() { + let script = bash_login_script("node --version"); + + assert!(script.contains("~/.bash_profile")); + assert!(script.contains("~/.bashrc")); + assert!(script.ends_with("node --version")); + } + + #[test] + fn stdout_after_shell_marker_ignores_shell_startup_output() { + let stdout = format!("loading profile\n{SHELL_COMMAND_MARKER}\n22.19.0\n"); + + assert_eq!(stdout_after_shell_marker(&stdout), "22.19.0\n"); + } +}