From 2136bcd8953596281145627403dd270766770df8 Mon Sep 17 00:00:00 2001 From: bealqiu Date: Wed, 8 Jul 2026 19:04:23 +0800 Subject: [PATCH 1/3] fix: resolve bundled cli node path --- packages/app/src-tauri/src/readany_cli.rs | 185 +++++++++++++++++++--- 1 file changed, 163 insertions(+), 22 deletions(-) diff --git a/packages/app/src-tauri/src/readany_cli.rs b/packages/app/src-tauri/src/readany_cli.rs index 2c2199f5..89bd1489 100644 --- a/packages/app/src-tauri/src/readany_cli.rs +++ b/packages/app/src-tauri/src/readany_cli.rs @@ -487,13 +487,98 @@ fn is_executable_file(path: &Path) -> bool { path.is_file() } +fn node_executable_name() -> &'static str { + if cfg!(windows) { + "node.exe" + } else { + "node" + } +} + +fn command_from_path(command: &str) -> Option { + let paths = env::var_os("PATH")?; + env::split_paths(&paths) + .map(|dir| dir.join(command)) + .find(|path| is_executable_file(path)) +} + +fn configured_node_program() -> Option { + for key in ["READANY_DESKTOP_NODE_BIN", "READANY_NODE_BIN"] { + if let Some(path) = env::var_os(key).map(PathBuf::from) { + if is_executable_file(&path) { + return Some(path); + } + } + } + None +} + +fn nvm_node_candidates() -> Vec { + let home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from); + let Some(home) = home else { + return Vec::new(); + }; + let versions_dir = home.join(".nvm/versions/node"); + let Ok(entries) = fs::read_dir(versions_dir) else { + return Vec::new(); + }; + + let mut candidates: Vec = entries + .flatten() + .map(|entry| entry.path().join("bin").join(node_executable_name())) + .filter(|path| is_executable_file(path)) + .collect(); + candidates.sort(); + candidates.reverse(); + candidates +} + +fn common_node_candidates() -> Vec { + let mut candidates = Vec::new(); + if cfg!(target_os = "macos") { + candidates.extend([ + PathBuf::from("/opt/homebrew/bin/node"), + PathBuf::from("/usr/local/bin/node"), + PathBuf::from("/opt/local/bin/node"), + ]); + } else if cfg!(target_os = "linux") { + candidates.extend([ + PathBuf::from("/usr/local/bin/node"), + PathBuf::from("/usr/bin/node"), + PathBuf::from("/snap/bin/node"), + ]); + } else if cfg!(windows) { + if let Some(program_files) = env::var_os("ProgramFiles") { + candidates.push(PathBuf::from(program_files).join("nodejs/node.exe")); + } + if let Some(program_files_x86) = env::var_os("ProgramFiles(x86)") { + candidates.push(PathBuf::from(program_files_x86).join("nodejs/node.exe")); + } + } + candidates.extend(nvm_node_candidates()); + candidates +} + +fn find_node_program() -> Option { + configured_node_program() + .or_else(|| command_from_path(node_executable_name())) + .or_else(|| { + common_node_candidates() + .into_iter() + .find(|path| is_executable_file(path)) + }) + .map(|path| path.to_string_lossy().to_string()) +} + fn node_cli_command(script_path: PathBuf, source: &str) -> Option { if !is_executable_file(&script_path) { return None; } Some(CliCommand { - program: "node".to_string(), + program: find_node_program().unwrap_or_else(|| "node".to_string()), prefix_args: vec![script_path.to_string_lossy().to_string()], source: source.to_string(), }) @@ -562,6 +647,33 @@ fn resolve_cli_command(action: &str, resource_dir: Option) -> CliComman path_cli_command() } +fn command_display(cli_command: &CliCommand) -> String { + if cli_command.prefix_args.is_empty() { + cli_command.program.clone() + } else { + format!( + "{} {}", + cli_command.program, + cli_command.prefix_args.join(" ") + ) + } +} + +fn format_cli_spawn_error(cli_command: &CliCommand, error: &std::io::Error) -> String { + if cli_command.source == "bundle" && error.kind() == std::io::ErrorKind::NotFound { + return format!( + "Failed to run bundled ReadAny CLI because Node.js was not found. Install Node.js or make it available in PATH, then try again. Tried command: {}. Original error: {}", + command_display(cli_command), + error + ); + } + + format!( + "Failed to run ReadAny CLI via {}: {}", + cli_command.source, error + ) +} + #[tauri::command] pub async fn readany_cli_run( app: AppHandle, @@ -603,19 +715,30 @@ pub async fn readany_cli_run( let cli_command = resolve_cli_command(&action, resource_dir); let action_for_result = action.clone(); tauri::async_runtime::spawn_blocking(move || { - let output = Command::new(&cli_command.program) + let command = command_display(&cli_command); + let output = match Command::new(&cli_command.program) .args(&cli_command.prefix_args) .args(&args) .output() - .map_err(|error| { + { + Ok(output) => output, + Err(error) => { if let Some(path) = temp_patch.as_deref() { let _ = fs::remove_file(path); } - format!( - "Failed to run ReadAny CLI via {}: {}", - cli_command.source, error - ) - })?; + let stderr = format_cli_spawn_error(&cli_command, &error); + return Ok(ReadAnyCliRunResult { + ok: false, + action: action_for_result, + command, + command_source: cli_command.source, + args, + status: None, + stdout: String::new(), + stderr, + }); + } + }; if let Some(path) = temp_patch.as_deref() { let _ = fs::remove_file(path); } @@ -623,15 +746,7 @@ pub async fn readany_cli_run( Ok(ReadAnyCliRunResult { ok: output.status.success(), action: action_for_result, - command: if cli_command.prefix_args.is_empty() { - cli_command.program - } else { - format!( - "{} {}", - cli_command.program, - cli_command.prefix_args.join(" ") - ) - }, + command, command_source: cli_command.source, args, status: output.status.code(), @@ -646,10 +761,11 @@ pub async fn readany_cli_run( #[cfg(test)] mod tests { use super::{ - args_for_action, bundled_cli_command, resolve_cli_command, ReadAnyCliRunOptions, - BUNDLED_CLI_RESOURCE_PATH, + args_for_action, bundled_cli_command, format_cli_spawn_error, resolve_cli_command, + CliCommand, ReadAnyCliRunOptions, BUNDLED_CLI_RESOURCE_PATH, }; use std::fs; + use std::io; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -663,6 +779,16 @@ mod tests { path } + fn assert_node_program(program: &str) { + assert!( + program == "node" + || program.ends_with("/node") + || program.ends_with("\\node.exe") + || program.ends_with("/node.exe"), + "expected node executable, got {program}", + ); + } + #[test] fn exposes_only_allowlisted_cli_actions() { assert_eq!( @@ -1218,7 +1344,7 @@ mod tests { fs::write(&cli, "#!/usr/bin/env node\n").expect("write cli"); let command = resolve_cli_command("install", Some(root.clone())); - assert_eq!(command.program, "node"); + assert_node_program(&command.program); assert_eq!(command.prefix_args, vec![cli.to_string_lossy().to_string()]); assert_eq!(command.source, "bundle"); let _ = fs::remove_dir_all(root); @@ -1232,7 +1358,7 @@ mod tests { fs::write(&cli, "#!/usr/bin/env node\n").expect("write cli"); let command = resolve_cli_command("agent_setup", Some(root.clone())); - assert_eq!(command.program, "node"); + assert_node_program(&command.program); assert_eq!(command.prefix_args, vec![cli.to_string_lossy().to_string()]); assert_eq!(command.source, "bundle"); let _ = fs::remove_dir_all(root); @@ -1258,7 +1384,7 @@ mod tests { "skill_uninstall", ] { let command = resolve_cli_command(action, Some(root.clone())); - assert_eq!(command.program, "node", "{action}"); + assert_node_program(&command.program); assert_eq!(command.prefix_args, vec![cli.to_string_lossy().to_string()]); assert_eq!(command.source, "bundle", "{action}"); } @@ -1288,6 +1414,21 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn explains_missing_node_for_bundled_cli_spawn_failures() { + let command = CliCommand { + program: "node".to_string(), + prefix_args: vec!["/Applications/ReadAny.app/readany-cli/bin/readany.js".to_string()], + source: "bundle".to_string(), + }; + let error = io::Error::from(io::ErrorKind::NotFound); + let message = format_cli_spawn_error(&command, &error); + + assert!(message.contains("bundled ReadAny CLI")); + assert!(message.contains("Node.js was not found")); + assert!(message.contains("readany.js")); + } + #[test] fn ignores_missing_bundled_cli() { let root = temp_test_dir("missing-bundle"); From 36c1c35b0281e26edca3812c2517075b51a24596 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Wed, 8 Jul 2026 22:38:14 +0800 Subject: [PATCH 2/3] fix: expose external AI uninstall actions --- .../settings/ExternalAISettings.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/app/src/components/settings/ExternalAISettings.tsx b/packages/app/src/components/settings/ExternalAISettings.tsx index 3e349343..68ef77ec 100644 --- a/packages/app/src/components/settings/ExternalAISettings.tsx +++ b/packages/app/src/components/settings/ExternalAISettings.tsx @@ -20,6 +20,7 @@ import { RefreshCw, ShieldCheck, Terminal, + Trash2, Wrench, XCircle, } from "lucide-react"; @@ -29,8 +30,10 @@ import { useTranslation } from "react-i18next"; type CliAction = | "version" | "install" + | "uninstall" | "repair" | "agent_setup" + | "agent_uninstall" | "doctor" | "mcp_config" | "tools_list" @@ -626,6 +629,16 @@ export function ExternalAISettings() { await refreshAll(); } + async function handleUninstallExternalAccess() { + await runCli("agent_uninstall"); + await refreshAll(); + } + + async function handleCliUninstall() { + await runCli("uninstall"); + await refreshAll(); + } + async function copyMcpConfig() { if (!canCopyMcpConfig) return; const result = await runCli("mcp_config", { mcpProfile, mcpClient }); @@ -719,6 +732,16 @@ export function ExternalAISettings() { {t("settings.externalAiSettings.actions.repairExternalAI")} + @@ -938,6 +961,16 @@ export function ExternalAISettings() { + @@ -968,7 +988,11 @@ export function ExternalAISettings() { disabled={busy} className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive" > - + {uninstallingCli ? ( + + ) : ( + + )} {t("settings.externalAiSettings.actions.uninstall")}