Skip to main content

mcp_common/
process_compat.rs

1//! 跨平台进程管理兼容层
2//!
3//! 提供统一的进程管理抽象,减少平台特定代码的侵入性。
4//!
5//! # 使用方法
6//!
7//! ## 命令检测
8//!
9//! ```ignore
10//! use mcp_common::process_compat::check_windows_command;
11//!
12//! check_windows_command(&config.command);
13//! ```
14//!
15//! ## 进程包装宏
16//!
17//! process-wrap 8.x (TokioCommandWrap):
18//! ```ignore
19//! use mcp_common::process_compat::wrap_process_v8;
20//!
21//! let mut wrapped_cmd = TokioCommandWrap::with_new(...);
22//! wrap_process_v8!(wrapped_cmd);
23//! wrapped_cmd.wrap(KillOnDrop);
24//! ```
25//!
26//! process-wrap 9.x (CommandWrap):
27//! ```ignore
28//! use mcp_common::process_compat::wrap_process_v9;
29//!
30//! let mut wrapped_cmd = CommandWrap::with_new(...);
31//! wrap_process_v9!(wrapped_cmd);
32//! wrapped_cmd.wrap(KillOnDrop);
33//! ```
34
35#[cfg(windows)]
36use tracing::{info, warn};
37
38/// 检测 Windows 平台上可能导致弹窗的命令格式
39///
40/// 在 Windows 上,运行 `.cmd`、`.bat` 文件或 `npx` 命令可能会弹出 CMD 窗口。
41/// 此函数会检测这些情况并输出警告,建议用户使用替代方案。
42///
43/// # Arguments
44///
45/// * `command` - 要执行的命令字符串
46///
47/// # Example
48///
49/// ```ignore
50/// use mcp_common::process_compat::check_windows_command;
51///
52/// check_windows_command("npx some-server");
53/// check_windows_command("mcp-server.cmd");
54/// ```
55#[cfg(windows)]
56pub fn check_windows_command(command: &str) {
57    use std::path::Path;
58
59    let cmd_ext = Path::new(command)
60        .extension()
61        .and_then(|e| e.to_str())
62        .map(|s| s.to_ascii_lowercase());
63
64    match cmd_ext.as_deref() {
65        Some("cmd" | "bat") => {
66            warn!(
67                "[MCP] Windows 检测到 .cmd/.bat 命令: {} - 可能会弹 CMD 窗口!",
68                command
69            );
70            warn!("[MCP] 建议改用 node.exe 直接运行 JS 文件,或在配置中使用完整路径");
71        }
72        None => {
73            // 无扩展名,检查是否是 npx 命令
74            if command.contains("npx") {
75                warn!(
76                    "[MCP] Windows 检测到 npx 命令: {} - 可能会弹 CMD 窗口!",
77                    command
78                );
79                warn!("[MCP] 建议改用 node.exe 直接运行 JS 文件");
80            }
81        }
82        _ => {
83            info!("[MCP] Windows 检测到命令格式: {}", command);
84        }
85    }
86}
87
88/// Unix/macOS 平台的空实现
89#[cfg(not(windows))]
90pub fn check_windows_command(_command: &str) {
91    // 非 Windows 平台无需检测
92}
93
94/// Windows 上解析命令路径,自动添加扩展名
95///
96/// 在 Windows 上,命令如 `npx` 实际上是 `npx.cmd` 批处理文件。
97/// `std::process::Command` 不会自动查找 `.cmd` 扩展名,需要手动指定。
98/// 此函数尝试在 PATH 中查找命令,并返回带扩展名的完整路径或原始命令。
99///
100/// # Arguments
101///
102/// * `command` - 要解析的命令字符串
103///
104/// # Returns
105///
106/// 如果找到,返回带扩展名的命令;否则返回原始命令
107///
108/// # Example
109///
110/// ```ignore
111/// use mcp_common::process_compat::resolve_windows_command;
112///
113/// let resolved = resolve_windows_command("npx");
114/// // 返回 "npx.cmd" 或 "C:\Program Files\nodejs\npx.cmd"
115/// ```
116#[cfg(target_os = "windows")]
117pub fn resolve_windows_command(command: &str) -> String {
118    use std::path::Path;
119
120    // 如果已经有扩展名,直接返回
121    if Path::new(command).extension().is_some() {
122        return command.to_string();
123    }
124
125    // 如果是绝对路径,直接返回
126    if Path::new(command).is_absolute() {
127        return command.to_string();
128    }
129
130    // 获取 PATH 环境变量
131    let path_env = match std::env::var("PATH") {
132        Ok(p) => p,
133        Err(_) => return command.to_string(),
134    };
135
136    // Windows 可执行文件扩展名(按优先级)
137    let extensions = [".cmd", ".exe", ".bat", ".ps1"];
138
139    // 遍历 PATH 中的每个目录
140    for dir in path_env.split(';') {
141        let dir = dir.trim();
142        if dir.is_empty() {
143            continue;
144        }
145
146        // 尝试每个扩展名
147        for ext in &extensions {
148            let full_path = Path::new(dir).join(format!("{}{}", command, ext));
149            if full_path.exists() {
150                tracing::debug!(
151                    "[MCP] Windows 命令解析: {} -> {}",
152                    command,
153                    full_path.display()
154                );
155                // 返回带扩展名的命令(不是完整路径,保持简洁)
156                return format!("{}{}", command, ext);
157            }
158        }
159    }
160
161    // 未找到,返回原始命令
162    command.to_string()
163}
164
165/// 非 Windows 平台的空实现
166#[cfg(not(target_os = "windows"))]
167pub fn resolve_windows_command(command: &str) -> String {
168    command.to_string()
169}
170
171/// 确保应用内置运行时路径(NUWAX_APP_RUNTIME_PATH)在 PATH 最前面。
172///
173/// 当应用捆绑了 node/uv 等运行时时,通过 `NUWAX_APP_RUNTIME_PATH` 传递其路径。
174/// 此函数将这些路径插入到给定 PATH 的最前面,确保优先使用应用内置版本,
175/// 即使用户在 MCP 配置的 `env` 中指定了自定义 PATH。
176///
177/// **按段去重**:将 runtime_path 和现有 PATH 拆分为独立条目,
178/// 先放 runtime 段,再追加 PATH 中不在 runtime 里的段,彻底避免重复。
179///
180/// 如果 `NUWAX_APP_RUNTIME_PATH` 未设置或为空,直接返回原始 PATH。
181pub fn ensure_runtime_path(path: &str) -> String {
182    if let Ok(runtime_path) = std::env::var("NUWAX_APP_RUNTIME_PATH") {
183        let runtime_path = runtime_path.trim();
184        if !runtime_path.is_empty() {
185            let sep = if cfg!(windows) { ";" } else { ":" };
186
187            // 将 runtime_path 拆成各段
188            let runtime_segments: Vec<&str> =
189                runtime_path.split(sep).filter(|s| !s.is_empty()).collect();
190
191            // 将现有 PATH 拆成各段,去掉已在 runtime 中的
192            let existing_segments: Vec<&str> = path
193                .split(sep)
194                .filter(|s| !s.is_empty() && !runtime_segments.contains(s))
195                .collect();
196
197            let merged: Vec<&str> = runtime_segments
198                .iter()
199                .copied()
200                .chain(existing_segments)
201                .collect();
202
203            let result = merged.join(sep);
204            if result != path {
205                tracing::info!(
206                    "[ProcessCompat] 前置应用内置运行时到 PATH: {}",
207                    runtime_path
208                );
209            }
210            return result;
211        }
212    }
213    path.to_string()
214}
215
216/// 为 stdio 子进程准备最终的 PATH 和过滤后的环境变量。
217///
218/// 统一处理:
219/// 1. 从 config env 或父进程确定基础 PATH
220/// 2. Windows 上追加 npm 全局 bin 目录
221/// 3. 通过 `ensure_runtime_path` 按段去重前置应用内置运行时
222/// 4. 从 config env 中过滤掉 PATH(已单独处理)
223///
224/// 返回 `(Option<final_path>, filtered_env)`,调用方只需 apply 到 `cmd` 即可。
225pub fn prepare_stdio_env(
226    env: &Option<std::collections::HashMap<String, String>>,
227) -> (Option<String>, Option<Vec<(String, String)>>) {
228    // 1. 确定基础 PATH
229    let base_path = if env.as_ref().is_none_or(|e| !e.contains_key("PATH")) {
230        std::env::var("PATH").ok()
231    } else {
232        env.as_ref().and_then(|e| e.get("PATH").cloned())
233    };
234
235    // 2. Windows: 追加 npm 全局 bin + 3. ensure_runtime_path
236    let final_path = base_path.map(|path| {
237        #[cfg(target_os = "windows")]
238        let path = {
239            if let Ok(appdata) = std::env::var("APPDATA") {
240                let npm_path = format!(r"{}\npm", appdata);
241                if !path.contains(&npm_path) {
242                    format!("{};{}", path, npm_path)
243                } else {
244                    path
245                }
246            } else {
247                tracing::warn!("Windows: APPDATA not found, skipping npm global bin");
248                path
249            }
250        };
251        ensure_runtime_path(&path)
252    });
253
254    // 4. 过滤掉 PATH(已单独处理)
255    let filtered_env = env.as_ref().map(|vars| {
256        vars.iter()
257            .filter(|(k, _)| k.as_str() != "PATH")
258            .map(|(k, v)| (k.clone(), v.clone()))
259            .collect()
260    });
261
262    (final_path, filtered_env)
263}
264
265/// 为 process-wrap 8.x 的 TokioCommandWrap 应用平台特定的包装
266///
267/// 此宏会根据目标平台自动应用正确的进程包装:
268/// - Windows: `CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)` + `JobObject`
269/// - Unix: `ProcessGroup::leader()`
270///
271/// # Arguments
272///
273/// * `$cmd` - 可变的 TokioCommandWrap 实例
274///
275/// # Example
276///
277/// ```ignore
278/// use process_wrap::tokio::{TokioCommandWrap, KillOnDrop};
279/// use mcp_common::process_compat::wrap_process_v8;
280///
281/// let mut wrapped_cmd = TokioCommandWrap::with_new("node", |cmd| {
282///     cmd.arg("server.js");
283/// });
284/// wrap_process_v8!(wrapped_cmd);
285/// wrapped_cmd.wrap(KillOnDrop);
286/// ```
287#[cfg(unix)]
288#[macro_export]
289macro_rules! wrap_process_v8 {
290    ($cmd:expr) => {{
291        use process_wrap::tokio::ProcessGroup;
292        $cmd.wrap(ProcessGroup::leader());
293    }};
294}
295
296#[cfg(windows)]
297#[macro_export]
298macro_rules! wrap_process_v8 {
299    ($cmd:expr) => {{
300        use process_wrap::tokio::{CreationFlags, JobObject};
301        use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
302        $cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
303        $cmd.wrap(JobObject);
304    }};
305}
306
307/// 为 process-wrap 9.x 的 CommandWrap 应用平台特定的包装
308///
309/// 此宏会根据目标平台自动应用正确的进程包装:
310/// - Windows: `CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)` + `JobObject`
311/// - Unix: `ProcessGroup::leader()`
312///
313/// # Arguments
314///
315/// * `$cmd` - 可变的 CommandWrap 实例
316///
317/// # Example
318///
319/// ```ignore
320/// use process_wrap::tokio::{CommandWrap, KillOnDrop};
321/// use mcp_common::process_compat::wrap_process_v9;
322///
323/// let mut wrapped_cmd = CommandWrap::with_new("node", |cmd| {
324///     cmd.arg("server.js");
325/// });
326/// wrap_process_v9!(wrapped_cmd);
327/// wrapped_cmd.wrap(KillOnDrop);
328/// ```
329#[cfg(unix)]
330#[macro_export]
331macro_rules! wrap_process_v9 {
332    ($cmd:expr) => {{
333        use process_wrap::tokio::ProcessGroup;
334        $cmd.wrap(ProcessGroup::leader());
335    }};
336}
337
338#[cfg(windows)]
339#[macro_export]
340macro_rules! wrap_process_v9 {
341    ($cmd:expr) => {{
342        use process_wrap::tokio::{CreationFlags, JobObject};
343        use windows::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
344        $cmd.wrap(CreationFlags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP));
345        $cmd.wrap(JobObject);
346    }};
347}
348
349/// 启动 stderr 日志读取任务
350///
351/// 创建一个异步任务来读取子进程的 stderr 输出并记录到日志。
352/// 这个函数封装了通用的 stderr 读取逻辑。
353///
354/// # Arguments
355///
356/// * `stderr` - stderr 管道(实现 AsyncRead + Unpin + Send)
357/// * `service_name` - MCP 服务名称(用于日志标识)
358///
359/// # Returns
360///
361/// 返回 `JoinHandle<()>`,任务会在 stderr 关闭时自动结束
362///
363/// # Example
364///
365/// ```ignore
366/// use mcp_common::process_compat::spawn_stderr_reader;
367///
368/// let (tokio_process, child_stderr) = TokioChildProcess::builder(wrapped_cmd)
369///     .stderr(Stdio::piped())
370///     .spawn()?;
371///
372/// if let Some(stderr) = child_stderr {
373///     spawn_stderr_reader(stderr, "my-mcp-service".to_string());
374/// }
375/// ```
376pub fn spawn_stderr_reader<T>(stderr: T, service_name: String) -> tokio::task::JoinHandle<()>
377where
378    T: tokio::io::AsyncRead + Unpin + Send + 'static,
379{
380    tokio::spawn(async move {
381        use tokio::io::{AsyncBufReadExt, BufReader};
382
383        let mut reader = BufReader::new(stderr);
384        let mut line = String::new();
385        loop {
386            line.clear();
387            match reader.read_line(&mut line).await {
388                Ok(0) => {
389                    // EOF - stderr 已关闭
390                    tracing::debug!("[子进程 stderr][{}] 读取结束 (EOF)", service_name);
391                    break;
392                }
393                Ok(_) => {
394                    let trimmed = line.trim();
395                    if !trimmed.is_empty() {
396                        tracing::warn!("[子进程 stderr][{}] {}", service_name, trimmed);
397                    }
398                }
399                Err(e) => {
400                    tracing::debug!("[子进程 stderr][{}] 读取错误: {}", service_name, e);
401                    break;
402                }
403            }
404        }
405    })
406}
407
408/// Windows 上将 Unix 风格路径转换为 Windows 风格
409///
410/// 转换规则:
411/// - `/c/Program Files/...` -> `C:\Program Files\...`
412/// - `/cygdrive/c/...` -> `C:\...`
413/// - 已经是 Windows 格式的路径保持不变
414///
415/// # Arguments
416///
417/// * `path` - 要转换的路径字符串
418///
419/// # Example
420///
421/// ```ignore
422/// use mcp_common::process_compat::convert_unix_path_to_windows;
423///
424/// assert_eq!(convert_unix_path_to_windows("/c/Program Files/nodejs"), "C:\\Program Files\\nodejs");
425/// assert_eq!(convert_unix_path_to_windows("C:\\Windows"), "C:\\Windows");
426/// ```
427#[cfg(target_os = "windows")]
428pub fn convert_unix_path_to_windows(path: &str) -> String {
429    let path = path.trim();
430
431    // 检查是否已经是 Windows 格式 (如 C:\...)
432    if path.len() >= 2 && path.chars().nth(1) == Some(':') {
433        return path.to_string();
434    }
435
436    // 处理 /c/ 格式(Git Bash, MSYS2)
437    if path.starts_with('/') && path.len() > 2 {
438        let chars: Vec<char> = path.chars().collect();
439        if chars[2] == '/' {
440            let drive = chars[1].to_ascii_uppercase();
441            let rest = &path[3..];
442            return format!("{}:\\{}", drive, rest.replace('/', "\\"));
443        }
444    }
445
446    // 处理 /cygdrive/c/ 格式
447    if path.starts_with("/cygdrive/") && path.len() > 11 {
448        let rest = &path[10..];
449        let chars: Vec<char> = rest.chars().collect();
450        if chars.len() >= 2 && chars[1] == '/' {
451            let drive = chars[0].to_ascii_uppercase();
452            let rest_path = &rest[2..];
453            return format!("{}:\\{}", drive, rest_path.replace('/', "\\"));
454        }
455    }
456
457    path.to_string()
458}
459
460/// 非 Windows 平台的空实现
461#[cfg(not(target_os = "windows"))]
462pub fn convert_unix_path_to_windows(path: &str) -> String {
463    path.to_string()
464}
465
466/// Windows 上将整个 PATH 环境变量转换为 Windows 格式
467///
468/// 遍历 PATH 中的每个路径段,将 Unix 风格路径(如 Git Bash/MSYS2 格式)
469/// 转换为 Windows 格式。
470///
471/// # Arguments
472///
473/// * `path_env` - PATH 环境变量字符串
474///
475/// # Example
476///
477/// ```ignore
478/// use mcp_common::process_compat::convert_path_to_windows_format;
479///
480/// let path = "/c/Program Files/nodejs;C:\\Windows\\System32;/d/tools";
481/// let result = convert_path_to_windows_format(path);
482/// assert_eq!(result, "C:\\Program Files\\nodejs;C:\\Windows\\System32;D:\\tools");
483/// ```
484#[cfg(target_os = "windows")]
485pub fn convert_path_to_windows_format(path_env: &str) -> String {
486    path_env
487        .split(';')
488        .map(convert_unix_path_to_windows)
489        .collect::<Vec<_>>()
490        .join(";")
491}
492
493/// 非 Windows 平台的空实现
494#[cfg(not(target_os = "windows"))]
495pub fn convert_path_to_windows_format(path_env: &str) -> String {
496    path_env.to_string()
497}
498
499/// Windows 上预处理 npx 命令,避免 .cmd 文件导致窗口闪烁
500///
501/// 将 `npx -y package@version` 转换为直接的 `node` 命令。
502///
503/// # Arguments
504///
505/// * `command` - 原始命令
506/// * `args` - 原始参数
507///
508/// # Returns
509///
510/// 返回 `(new_command, new_args)` 元组。如果无法转换,返回原始值。
511///
512/// # Example
513///
514/// ```ignore
515/// use mcp_common::process_compat::preprocess_npx_command_windows;
516///
517/// let (cmd, args) = preprocess_npx_command_windows(
518///     "npx",
519///     Some(vec!["-y".to_string(), "chrome-devtools-mcp@latest".to_string()])
520/// );
521/// // cmd 可能是 "node.exe",args 可能是 ["path/to/chrome-devtools-mcp/bin/mcp.js"]
522/// ```
523#[cfg(target_os = "windows")]
524pub fn preprocess_npx_command_windows(
525    command: &str,
526    args: Option<&[String]>,
527) -> (String, Option<Vec<String>>) {
528    use tracing::info;
529
530    // 检测 npx 命令
531    let is_npx = command == "npx"
532        || command == "npx.cmd"
533        || command.ends_with("/npx")
534        || command.ends_with("\\npx")
535        || command.ends_with("/npx.cmd")
536        || command.ends_with("\\npx.cmd");
537
538    if !is_npx {
539        return (command.to_string(), args.map(|a| a.to_vec()));
540    }
541
542    let args = match args {
543        Some(a) => a,
544        None => return (command.to_string(), None),
545    };
546
547    // 提取包名(跳过 -y 标志等)
548    // 支持: chrome-devtools-mcp@latest, @scope/package@1.0.0
549    let package_spec = args
550        .iter()
551        .find(|s| !s.starts_with('-') && (s.contains('@') || s.starts_with('@')));
552
553    let Some(pkg) = package_spec else {
554        return (command.to_string(), Some(args.to_vec()));
555    };
556
557    // 解析包名(去掉版本号,处理 scoped packages)
558    let package_name = if pkg.starts_with('@') {
559        // Scoped package: @scope/name@version
560        let parts: Vec<&str> = pkg.splitn(3, '@').collect();
561        if parts.len() >= 3 {
562            // @scope/name@version -> @scope/name
563            format!("@{}", parts[1])
564        } else if parts.len() == 2 && parts[1].contains('/') {
565            // @scope/name (no version)
566            pkg.to_string()
567        } else {
568            pkg.to_string()
569        }
570    } else {
571        // Regular package: name@version
572        pkg.split('@').next().unwrap_or(pkg).to_string()
573    };
574
575    // 尝试找到已安装的包
576    if let Some((node_exe, js_entry)) = find_npx_package_entry_windows(&package_name) {
577        info!(
578            "[MCP] Windows npx 转换: npx {} -> node {}",
579            pkg,
580            js_entry.display()
581        );
582
583        // 构建新参数
584        let mut new_args = vec![js_entry.to_string_lossy().to_string()];
585        for arg in args {
586            // 跳过 -y 和包名
587            if arg != "-y" && arg != pkg {
588                new_args.push(arg.clone());
589            }
590        }
591
592        return (node_exe.to_string_lossy().to_string(), Some(new_args));
593    }
594
595    // 未找到已安装的包,保持原样
596    info!("[MCP] Windows npx 未找到已安装的包: {},保持原命令", pkg);
597    (command.to_string(), Some(args.to_vec()))
598}
599
600/// 非 Windows 平台的空实现
601#[cfg(not(target_os = "windows"))]
602pub fn preprocess_npx_command_windows(
603    command: &str,
604    args: Option<&[String]>,
605) -> (String, Option<Vec<String>>) {
606    (command.to_string(), args.map(|a| a.to_vec()))
607}
608
609/// 查找 npx 包的 node 可执行文件和 JS 入口(Windows)
610#[cfg(target_os = "windows")]
611fn find_npx_package_entry_windows(
612    package_name: &str,
613) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
614    use tracing::info;
615
616    // 查找 node.exe
617    let node_exe = find_node_exe_windows()?;
618
619    // 在多个可能的位置查找已安装的包
620    let base_search_paths = get_npx_cache_paths_windows();
621
622    for base_path in base_search_paths {
623        // 收集所有可能的 node_modules 目录
624        let mut node_modules_dirs = Vec::new();
625
626        if base_path.ends_with("_npx") {
627            // npx 缓存目录结构: _npx/<hash>/node_modules/<package>
628            // 需要遍历 hash 目录
629            if let Ok(entries) = std::fs::read_dir(&base_path) {
630                for entry in entries.flatten() {
631                    let hash_dir = entry.path();
632                    let node_modules = hash_dir.join("node_modules");
633                    if node_modules.exists() {
634                        node_modules_dirs.push(node_modules);
635                    }
636                }
637            }
638        } else {
639            // 直接是 node_modules 目录或包含 node_modules 的目录
640            if base_path.ends_with("node_modules") {
641                node_modules_dirs.push(base_path.clone());
642            } else if base_path.join("node_modules").exists() {
643                node_modules_dirs.push(base_path.join("node_modules"));
644            }
645        }
646
647        // 在每个 node_modules 目录中查找包
648        for node_modules_dir in node_modules_dirs {
649            let package_dir = node_modules_dir.join(package_name);
650            if !package_dir.exists() {
651                continue;
652            }
653
654            // 读取 package.json 查找入口
655            let package_json_path = package_dir.join("package.json");
656            if let Ok(content) = std::fs::read_to_string(&package_json_path) {
657                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
658                    // 查找 bin 字段
659                    let bin_entry = json.get("bin").and_then(|b| {
660                        if let Some(s) = b.as_str() {
661                            Some(s.to_string())
662                        } else if let Some(obj) = b.as_object() {
663                            // 对于 bin: { "pkg-name": "./bin/mcp.js" } 的情况
664                            // 尝试匹配包名或取第一个
665                            obj.get(package_name)
666                                .or_else(|| obj.values().next())
667                                .and_then(|v| v.as_str())
668                                .map(str::to_string)
669                        } else {
670                            None
671                        }
672                    });
673
674                    if let Some(bin_entry) = bin_entry {
675                        let js_entry = package_dir.join(bin_entry);
676                        if js_entry.exists() {
677                            info!(
678                                "[MCP] Windows 找到包入口: {} -> {}",
679                                package_name,
680                                js_entry.display()
681                            );
682                            return Some((node_exe.clone(), js_entry));
683                        }
684                    }
685                }
686            }
687        }
688    }
689
690    None
691}
692
693/// 查找 node.exe 路径(Windows)
694#[cfg(target_os = "windows")]
695fn find_node_exe_windows() -> Option<std::path::PathBuf> {
696    use std::path::PathBuf;
697
698    // 1. 检查环境变量
699    if let Ok(node_from_env) = std::env::var("NUWAX_NODE_EXE") {
700        let path = PathBuf::from(node_from_env);
701        if path.exists() {
702            return Some(path);
703        }
704    }
705
706    // 2. 检查应用资源目录
707    if let Ok(exe_path) = std::env::current_exe() {
708        if let Some(exe_dir) = exe_path.parent() {
709            let resource_paths = [
710                exe_dir
711                    .join("resources")
712                    .join("node")
713                    .join("bin")
714                    .join("node.exe"),
715                exe_dir
716                    .parent()
717                    .unwrap_or(exe_dir)
718                    .join("resources")
719                    .join("node")
720                    .join("bin")
721                    .join("node.exe"),
722            ];
723
724            for path in resource_paths {
725                if path.exists() {
726                    return Some(path);
727                }
728            }
729        }
730    }
731
732    // 3. 在 PATH 中查找
733    which::which("node.exe").ok()
734}
735
736/// 获取 npx 缓存搜索路径(Windows)
737#[cfg(target_os = "windows")]
738fn get_npx_cache_paths_windows() -> Vec<std::path::PathBuf> {
739    use std::path::PathBuf;
740
741    let mut paths = Vec::new();
742
743    // npx 缓存目录(npm 8.16+)- 优先检查 LOCALAPPDATA
744    // 这是 npx 实际使用的缓存位置
745    if let Ok(local_appdata) = std::env::var("LOCALAPPDATA") {
746        let local_appdata_path = PathBuf::from(&local_appdata);
747        // npx 缓存目录格式: LOCALAPPDATA\npm-cache\_npx\<hash>\node_modules
748        paths.push(local_appdata_path.join("npm-cache").join("_npx"));
749    }
750
751    // npm 全局 node_modules - APPDATA
752    if let Ok(appdata) = std::env::var("APPDATA") {
753        let appdata_path = PathBuf::from(&appdata);
754
755        // npm 全局目录
756        paths.push(appdata_path.join("npm").join("node_modules"));
757
758        // 应用私有目录
759        paths.push(
760            appdata_path
761                .join("com.nuwax.agent-tauri-client")
762                .join("node_modules"),
763        );
764
765        // 旧版 npm 缓存位置(备用)
766        paths.push(appdata_path.join("npm-cache").join("_npx"));
767    }
768
769    // 应用资源目录
770    if let Ok(exe_path) = std::env::current_exe() {
771        if let Some(exe_dir) = exe_path.parent() {
772            let resource_paths = [
773                exe_dir.join("resources").join("node").join("node_modules"),
774                exe_dir
775                    .parent()
776                    .unwrap_or(exe_dir)
777                    .join("resources")
778                    .join("node")
779                    .join("node_modules"),
780            ];
781
782            for path in resource_paths {
783                if path.exists() {
784                    paths.push(path);
785                }
786            }
787        }
788    }
789
790    paths
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    #[test]
798    fn test_check_windows_command_non_windows() {
799        // 在非 Windows 平台上,此函数应该不执行任何操作
800        check_windows_command("npx some-server");
801        check_windows_command("test.cmd");
802    }
803
804    #[test]
805    fn test_ensure_runtime_path_no_env() {
806        // NUWAX_APP_RUNTIME_PATH 未设置时,返回原始 PATH
807        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
808        let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
809        assert_eq!(result, "/usr/bin:/usr/local/bin");
810    }
811
812    #[test]
813    fn test_ensure_runtime_path_prepend() {
814        unsafe {
815            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
816        }
817        let result = ensure_runtime_path("/usr/bin:/usr/local/bin");
818        assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin:/usr/local/bin");
819        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
820    }
821
822    #[test]
823    fn test_ensure_runtime_path_dedup() {
824        // 模拟:PATH 中已有 runtime 的部分段 → 不应重复
825        unsafe {
826            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
827        }
828        let result = ensure_runtime_path("/app/node/bin:/opt/homebrew/bin:/usr/bin");
829        assert_eq!(
830            result,
831            "/app/node/bin:/app/uv/bin:/opt/homebrew/bin:/usr/bin"
832        );
833        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
834    }
835
836    #[test]
837    fn test_ensure_runtime_path_all_present() {
838        // PATH 已含全部 runtime 段 → 仅调整顺序确保 runtime 在前
839        unsafe {
840            std::env::set_var("NUWAX_APP_RUNTIME_PATH", "/app/node/bin:/app/uv/bin");
841        }
842        let result = ensure_runtime_path("/app/uv/bin:/usr/bin:/app/node/bin");
843        assert_eq!(result, "/app/node/bin:/app/uv/bin:/usr/bin");
844        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
845    }
846
847    #[test]
848    fn test_ensure_runtime_path_double_node() {
849        // 模拟日志中的问题:node/bin 出现两次
850        unsafe {
851            std::env::set_var(
852                "NUWAX_APP_RUNTIME_PATH",
853                "/app/node/bin:/app/uv/bin:/app/debug",
854            );
855        }
856        let result = ensure_runtime_path(
857            "/app/node/bin:/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin",
858        );
859        assert_eq!(
860            result,
861            "/app/node/bin:/app/uv/bin:/app/debug:/opt/homebrew/bin"
862        );
863        unsafe { std::env::remove_var("NUWAX_APP_RUNTIME_PATH") };
864    }
865
866    #[test]
867    #[cfg(target_os = "windows")]
868    fn test_convert_unix_path_to_windows() {
869        // Git Bash 格式
870        assert_eq!(
871            convert_unix_path_to_windows("/c/Program Files/nodejs"),
872            "C:\\Program Files\\nodejs"
873        );
874        // MSYS2/Cygwin 格式
875        assert_eq!(
876            convert_unix_path_to_windows("/cygdrive/c/Windows"),
877            "C:\\Windows"
878        );
879        // 已经是 Windows 格式
880        assert_eq!(
881            convert_unix_path_to_windows("C:\\Windows\\System32"),
882            "C:\\Windows\\System32"
883        );
884        // 小写驱动器号
885        assert_eq!(
886            convert_unix_path_to_windows("/d/tools/bin"),
887            "D:\\tools\\bin"
888        );
889        // 根路径
890        assert_eq!(convert_unix_path_to_windows("/c/"), "C:\\");
891        // 空字符串
892        assert_eq!(convert_unix_path_to_windows(""), "");
893        // 空白字符串
894        assert_eq!(convert_unix_path_to_windows("  "), "");
895    }
896
897    #[test]
898    #[cfg(target_os = "windows")]
899    fn test_convert_path_to_windows_format() {
900        // 混合格式 PATH
901        let path = "/c/Program Files/nodejs;C:\\Windows\\System32;/d/tools";
902        let result = convert_path_to_windows_format(path);
903        assert_eq!(
904            result,
905            "C:\\Program Files\\nodejs;C:\\Windows\\System32;D:\\tools"
906        );
907
908        // 纯 Unix 风格 PATH
909        let unix_path = "/c/Program Files/nodejs;/d/tools/bin;/e/dev";
910        let result = convert_path_to_windows_format(unix_path);
911        assert_eq!(result, "C:\\Program Files\\nodejs;D:\\tools\\bin;E:\\dev");
912
913        // 纯 Windows 风格 PATH(保持不变)
914        let win_path = "C:\\Windows\\System32;D:\\tools\\bin";
915        let result = convert_path_to_windows_format(win_path);
916        assert_eq!(result, win_path);
917
918        // 空字符串
919        assert_eq!(convert_path_to_windows_format(""), "");
920
921        // 单个路径
922        assert_eq!(
923            convert_path_to_windows_format("/c/Program Files/nodejs"),
924            "C:\\Program Files\\nodejs"
925        );
926    }
927}