feat(settings): anchor tool upgrades to the actual install

Rework the About tool-upgrade flow so upgrades target the install the
command line is actually using, instead of running bare `npm i -g` and
landing on PATH's first npm — which used to move upgrades to the wrong
location or fail outright when the resolved bin had no sibling npm.

Anchored upgrade command (update path, non-Windows):
- claude native installer (~/.local/share/claude/versions/) -> `claude update`
- Homebrew formula (canonical path under /Cellar/) -> `brew upgrade <formula>`
- volta / bun -> `volta install` / `bun add -g`
- Node managers with sibling npm (nvm/fnm/mise/homebrew-npm) -> that
  directory's npm with `i -g <pkg>@latest`
- system / unknown sources (e.g. opencode install.sh under ~/.opencode/bin,
  ~/go/bin) -> fall back to the bare command, surfaced as anchored=false
  so the UI shows an honest "default entry not determinable" hint instead
  of pretending the upgrade is targeted.

Multi-install confirmation:
- When the probe finds >=2 installs, pop ToolUpgradeConfirmDialog showing
  each install (source badge + path + version + Default badge) and the
  resolved command before executing. Top-level text only asserts what is
  universally true ("won't update all of them; see each tool below");
  per-card text adapts to anchored vs unanchored.
- Pending upgrade state stores the full upgrade set; the dialog only
  shows the subset that needs confirmation, so non-conflicting tools in
  a batch still get upgraded after confirm.

Unified probe command:
- Merge the previous diagnose_tool_installations and plan_tool_upgrades
  into a single probe_tool_installations returning installs + is_conflict
  + needs_confirmation + command + anchored. Diagnose button, post-upgrade
  auto-diagnose, and pre-upgrade confirmation all share one IPC. The
  diagnose button's previous Promise.all of 6 IPCs is now a single call.

resolve_path_default robustness:
- The previous lines().next() was poisoned by interactive .zshrc output
  (welcome banners, p10k instant prompts, etc.), causing default-install
  detection to fail and dropping multi-install scenarios into the
  unanchored fallback even when one install was clearly the native one.
- Replaced with first_abs_path_line that scans for the first
  absolute-path line and ignores noise, mirroring how try_get_version
  already tolerates banner noise via a regex.

Diagnostic staleness fix:
- diagnoseToolSilently now clears stale diagnostics in the else branch
  when the conflict no longer holds (previously only wrote on conflict,
  never cleared, so externally resolving a conflict left the card stuck
  on a stale list until the user clicked Diagnose).
- Auto-diagnose now triggers after any successful update, not only when
  the version didn't change, so the card reflects the latest conflict
  state even when an anchored upgrade succeeds while another install
  remains.

Other:
- Shared ToolInstallRow between the conflict list and the confirmation
  dialog removes the duplicated per-install row JSX.
- Drop the new shell_quote_path helper in favor of reusing the file's
  existing POSIX-correct shell_single_quote, gated on whitespace.
- Collapse displayName lookup into a stable module-level helper to remove
  the `as ToolName` cast and avoid recreating the closure each render.
- 17 backend unit tests covering anchored command generation across each
  source, brew formula extraction, is_conflicting thresholds, default
  install resolution, and the welcome-banner scenario.
- i18n (zh/en/ja): new keys for confirmation dialog
  title/hint/will-run/confirm-button and the unanchored hint.
This commit is contained in:
Jason
2026-05-23 09:04:14 +08:00
parent ce232a1452
commit 014c82d28b
9 changed files with 741 additions and 98 deletions
+477 -30
View File
@@ -189,14 +189,18 @@ pub async fn run_tool_lifecycle_action(
return Err("No supported tools selected".to_string());
}
let command_line =
build_tool_lifecycle_command(&requested, action, wsl_shell_by_tool.as_ref())?;
let label = match action {
ToolLifecycleAction::Install => "tool_install",
ToolLifecycleAction::Update => "tool_update",
};
tokio::task::spawn_blocking(move || run_tool_lifecycle_silently(&command_line, label))
// build 阶段含锚定探测(对每个工具跑 `--version` 定位命令行实际命中那处),
// 与执行一并放进 blocking 线程,避免阻塞 async runtime。
tokio::task::spawn_blocking(move || {
let command_line =
build_tool_lifecycle_command(&requested, action, wsl_shell_by_tool.as_ref())?;
run_tool_lifecycle_silently(&command_line, label)
})
.await
.map_err(|e| format!("tool lifecycle task join error: {e}"))?
}
@@ -371,22 +375,38 @@ fn build_tool_action_line(
wsl_shell: Option<&str>,
wsl_shell_flag: Option<&str>,
) -> Result<String, String> {
let command = tool_action_shell_command(tool, action)
.ok_or_else(|| format!("Unsupported tool action target: {tool}"))?;
#[cfg(target_os = "windows")]
{
// Windows:保持静态命令(WSL 走 wsl.exe;否则裸 npm 加 call),不锚定、零回归。
let command = tool_action_shell_command(tool, action)
.ok_or_else(|| format!("Unsupported tool action target: {tool}"))?;
if let Some(distro) = wsl_distro_for_tool(tool) {
return build_wsl_tool_action_line(&distro, command, wsl_shell, wsl_shell_flag);
}
if command.starts_with("npm ") {
return Ok(format!("call {command}"));
}
return Ok(command.to_string());
}
#[cfg(not(target_os = "windows"))]
{
let _ = (wsl_shell, wsl_shell_flag);
Ok(command.to_string())
// update 锚定到命令行实际命中的那处(写回同一个 node / brew / 原生安装器),
// 而非裸 `npm` 落到 PATH 第一个 npminstall 或探不到默认安装则用静态裸命令。
let command = match action {
ToolLifecycleAction::Update => {
let installs = enumerate_tool_installations(tool);
installs_anchored_command(tool, &installs)
.unwrap_or_else(|| static_fallback_command(tool))
}
ToolLifecycleAction::Install => static_fallback_command(tool),
};
if command.is_empty() {
return Err(format!("Unsupported tool action target: {tool}"));
}
Ok(command)
}
}
#[cfg(target_os = "windows")]
@@ -1224,6 +1244,13 @@ fn infer_install_source(path: &Path) -> &'static str {
}
}
/// 从 shell 输出里挑出第一个绝对路径行(trim 后以 `/` 开头),跳过交互式登录 shell
/// `-lic`)里 .zshrc 打印的欢迎语/提示符等噪音。canonicalize 由调用方做(碰 FS)。
#[cfg(not(target_os = "windows"))]
fn first_abs_path_line(raw: &str) -> Option<&str> {
raw.lines().map(str::trim).find(|l| l.starts_with('/'))
}
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
#[cfg(not(target_os = "windows"))]
@@ -1243,10 +1270,9 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let first = raw.lines().next()?.trim();
if first.is_empty() {
return None;
}
// 不能死取第一行:交互式 .zshrc 可能先打印欢迎语(如 "🚀 Welcome back"),
// command -v 的真实路径在其后;取第一个 `/` 开头的行才稳。
let first = first_abs_path_line(&raw)?;
std::fs::canonicalize(first).ok()
}
@@ -1357,36 +1383,207 @@ fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
installs
}
/// 诊断某工具是否存在多处互相打架的安装。懒触发:前端仅在卡片 broken 或
/// "升级后版本未变"时调用,正常版本刷新不受影响。
/// 工具对应的 npm 包名(hermes 走 pip,不在此表)。锚定升级据此拼 `npm i -g`。
#[cfg(not(target_os = "windows"))]
fn npm_package_for(tool: &str) -> Option<&'static str> {
match tool {
"claude" => Some("@anthropic-ai/claude-code"),
"codex" => Some("@openai/codex"),
"gemini" => Some("@google/gemini-cli"),
"opencode" => Some("opencode-ai"),
"openclaw" => Some("openclaw"),
_ => None,
}
}
/// 取 unix 路径的父目录(`/a/b/npm` → `/a/b`);无父目录返回空串。
#[cfg(not(target_os = "windows"))]
fn parent_dir(p: &str) -> String {
match p.rfind('/') {
Some(i) if i > 0 => p[..i].to_string(),
_ => String::new(),
}
}
/// 从 canonicalize 后的真身路径提取 Homebrew formula 名:
/// `/opt/homebrew/Cellar/gemini-cli/0.13.0/...` → `Some("gemini-cli")`。
/// 非 Cellar 路径(= 不是 formula,可能是 Homebrew 的 node 装的 npm 全局包)返回 None。
/// 关键区分:formula 即便内部用 node,真身也落在 `Cellar/<formula>/` 下;而 Homebrew
/// npm 全局包落在 `/opt/homebrew/lib/node_modules`(不含 Cellar)。两者升级命令不同。
#[cfg(not(target_os = "windows"))]
fn brew_formula_from_path(real: &str) -> Option<String> {
let mut segs = real.split('/');
while let Some(seg) = segs.next() {
if seg.eq_ignore_ascii_case("Cellar") {
return segs.next().filter(|s| !s.is_empty()).map(|s| s.to_string());
}
}
None
}
/// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径,
/// 推断"写回同一处"的锚定升级命令。纯函数(不碰 FS),真实 canonicalize 由调用方做,
/// 便于单测覆盖各包管理器分支。hermes(pip)不在此处:`npm_package_for` 返回 None → None。
///
/// 无冲突时返回空 `Vec`:仅一处安装,或多处但版本与可运行状态完全一致
/// (例如同一版本经多个包管理器装了两遍但都能跑)——这类不打扰用户。
#[tauri::command]
pub async fn diagnose_tool_installations(tool: String) -> Result<Vec<ToolInstallation>, String> {
if !VALID_TOOLS.contains(&tool.as_str()) {
return Err(format!("Unsupported tool: {tool}"));
/// 判定顺序(命中即返回):
/// ① Claude 原生安装器(`~/.local/share/claude/versions/`)→ `claude update` 自更新;
/// 它不归 npm 管,用 npm 升级会装到别处且被原生那份遮蔽(PATH 更靠前)。
/// ② Homebrew formula(真身在 `Cellar/<formula>/`)→ `brew upgrade <formula>`
/// formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli)。
/// ③ volta / bun → 各自的全局安装命令。
/// ④ 其余 npm 全局包(nvm / homebrew-npm / mise / fnm / system)→ 锚定到"那处 bin
/// 目录的 npm",确保升级写回命令行实际在用的那个 node,而非 PATH 第一个 npm。
#[cfg(not(target_os = "windows"))]
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
let pkg = npm_package_for(tool)?;
let real_lower = real_target.to_ascii_lowercase();
if tool == "claude"
&& (real_lower.contains("/.local/share/claude/")
|| real_lower.contains("/claude/versions/"))
{
return Some("claude update".to_string());
}
if let Some(formula) = brew_formula_from_path(real_target) {
return Some(format!("brew upgrade {formula}"));
}
match infer_install_source(Path::new(bin_path)) {
"volta" => return Some(format!("volta install {pkg}")),
"bun" => return Some(format!("bun add -g {pkg}@latest")),
// 自带同级 npm 的 node 管理器:落到下面锚定到那处的 npm。
"nvm" | "fnm" | "mise" | "homebrew" => {}
// system / 未知来源(如 opencode install.sh 装的 ~/.opencode/bin、~/go/bin
// 通常没有同级 npm,锚定会拼出 `<dir>/npm` 这种必失败命令;返回 None 让调用方
// 回退静态裸命令(至少能跑),并由前端据 anchored=false 给出诚实文案。
_ => return None,
}
let dir = parent_dir(bin_path);
let npm = if dir.is_empty() {
"npm".to_string()
} else {
format!("{dir}/npm")
};
// 含空格才单引号包裹(复用 shell_single_quote 的 POSIX 转义),否则保持裸路径,
// 命令展示更干净。
let npm = if npm.contains(' ') {
shell_single_quote(&npm)
} else {
npm
};
Some(format!("{npm} i -g {pkg}@latest"))
}
let installs = tokio::task::spawn_blocking(move || enumerate_tool_installations(&tool))
.await
.map_err(|e| format!("diagnose task join error: {e}"))?;
/// 从枚举结果里取"命令行实际命中的那处":优先 `is_path_default`;否则(解析不到
/// PATH 默认、但只有一处)取唯一那处;多处且无默认标记 → None(无从锚定)。
#[cfg(not(target_os = "windows"))]
fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
installs.iter().find(|i| i.is_path_default).or_else(|| {
if installs.len() == 1 {
installs.first()
} else {
None
}
})
}
/// 基于已枚举的安装列表生成锚定升级命令(复用 enumerate 结果,避免二次探测)。
/// 对默认那处的原始 bin 路径再 canonicalize 一次拿真身,喂给纯函数判定。
#[cfg(not(target_os = "windows"))]
fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> {
let inst = default_install(installs)?;
let real = std::fs::canonicalize(&inst.path)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| inst.path.clone());
anchored_command_from_paths(tool, &inst.path, &real)
}
/// 静态裸命令(= 现有的 `npm i -g <pkg>@latest` / pip 升级)。
/// 锚定探不到默认安装时回退到它,等同于"装到 PATH 第一个 npm"的旧行为。
fn static_fallback_command(tool: &str) -> String {
tool_action_shell_command(tool, ToolLifecycleAction::Update)
.map(|s| s.to_string())
.unwrap_or_default()
}
/// 计算某工具的升级命令与"是否需确认"。非 Windows 走锚定;Windows 保持静态命令、
/// 永不弹确认(避免引入 Windows 路径/批处理复杂度,零回归)。
#[cfg(not(target_os = "windows"))]
fn plan_command_for(tool: &str, installs: &[ToolInstallation]) -> (String, bool, bool) {
match installs_anchored_command(tool, installs) {
Some(command) => (command, installs.len() >= 2, true),
None => (static_fallback_command(tool), installs.len() >= 2, false),
}
}
#[cfg(target_os = "windows")]
fn plan_command_for(tool: &str, installs: &[ToolInstallation]) -> (String, bool, bool) {
let _ = installs;
(static_fallback_command(tool), false, false)
}
/// 多处安装是否构成"真冲突":≥2 处,且(版本分歧 或 有的能跑有的跑不起来)。
/// 同版本装两份且都能跑不算冲突(不打扰用户)。诊断展示据此判定。
fn is_conflicting(installs: &[ToolInstallation]) -> bool {
if installs.len() < 2 {
return Ok(Vec::new());
return false;
}
// 仅当存在分歧才算冲突:版本不唯一,或"有能跑的也有跑不起来的"。
let distinct_versions: std::collections::HashSet<&Option<String>> =
installs.iter().map(|i| &i.version).collect();
let runnable_mixed =
installs.iter().any(|i| i.runnable) && installs.iter().any(|i| !i.runnable);
distinct_versions.len() > 1 || runnable_mixed
}
if distinct_versions.len() > 1 || runnable_mixed {
Ok(installs)
} else {
Ok(Vec::new())
/// 一次"探测工具安装分布"的结果:枚举到的所有安装 + 各项衍生判定。同时服务两条
/// 路径——诊断展示(`is_conflict`)与升级确认(`needs_confirmation`/`command`/`anchored`)。
/// 字段保持 snake_case(与 `ToolInstallation` 一致),前端按同名读取。
#[derive(Debug, serde::Serialize)]
pub struct ToolInstallationReport {
tool: String,
/// 该工具枚举到的所有安装。
installs: Vec<ToolInstallation>,
/// 严阈值:≥2 且(版本分歧或运行态混合)。诊断按钮/自动补诊据此展示冲突。
is_conflict: bool,
/// 宽阈值:≥2 处。升级确认据此弹窗(升级只动一处,任何多处都该让用户知情)。
needs_confirmation: bool,
/// 锚定后将执行的升级命令(仅展示;真正执行时后端会重新生成,不信任前端回传)。
command: String,
/// 是否成功锚定到某处具体安装。false = 退到裸 fallback 命令(无法确定命令行实际
/// 命中哪处,或该处无同级 npm);前端据此给出"默认入口无法确定"的诚实文案。
anchored: bool,
}
/// 探测各工具的安装分布:枚举所有安装、标记冲突、生成锚定升级命令。只读、无副作用。
/// 诊断按钮、升级前确认、升级后补诊共用此命令,各取所需字段——避免对同一份枚举结果
/// 散落多套下游判定。
#[tauri::command]
pub async fn probe_tool_installations(
tools: Vec<String>,
) -> Result<Vec<ToolInstallationReport>, String> {
let requested = normalize_requested_tools(&tools);
if requested.is_empty() {
return Err("No supported tools selected".to_string());
}
tokio::task::spawn_blocking(move || {
requested
.into_iter()
.map(|tool| {
let installs = enumerate_tool_installations(tool);
let (command, needs_confirmation, anchored) = plan_command_for(tool, &installs);
let is_conflict = is_conflicting(&installs);
ToolInstallationReport {
tool: tool.to_string(),
installs,
is_conflict,
needs_confirmation,
command,
anchored,
}
})
.collect()
})
.await
.map_err(|e| format!("probe task join error: {e}"))
}
#[cfg(target_os = "windows")]
@@ -2332,6 +2529,256 @@ mod tests {
assert_eq!(extract_version("no version here"), "no version here");
}
/// 锚定升级命令生成:用真实勘察到的安装路径固化为回归断言——
/// 一台机器上 4 个工具恰好对应 4 种升级方式(原生 self-update / brew / nvm npm /
/// homebrew npm),任何改动若打破其中一种都会立刻被这些用例拦下。
#[cfg(not(target_os = "windows"))]
mod anchored_upgrade {
use super::super::*;
use std::path::Path;
fn inst(path: &str, is_default: bool) -> ToolInstallation {
ToolInstallation {
path: path.to_string(),
version: None,
runnable: true,
error: None,
source: infer_install_source(Path::new(path)).to_string(),
is_path_default: is_default,
}
}
#[test]
fn claude_native_installer_uses_self_update() {
// ~/.local/bin/claude → 真身在 ~/.local/share/claude/versions/,自带 self-update
// 它不归 npm 管,且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级纯属白装。
let cmd = anchored_command_from_paths(
"claude",
"/Users/me/.local/bin/claude",
"/Users/me/.local/share/claude/versions/2.1.146",
);
assert_eq!(cmd.as_deref(), Some("claude update"));
}
#[test]
fn gemini_homebrew_formula_uses_brew_upgrade() {
// /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包,
// 且 formula 名(gemini-cli) ≠ npm 包名(@google/gemini-cli)。
let cmd = anchored_command_from_paths(
"gemini",
"/opt/homebrew/bin/gemini",
"/opt/homebrew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js",
);
assert_eq!(cmd.as_deref(), Some("brew upgrade gemini-cli"));
}
#[test]
fn codex_nvm_anchors_to_that_npm() {
// 命令行命中 nvm 那处 → 升级写回同一个 node 的 npm,而非 PATH 第一个 npm。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@openai/codex/bin/codex.js",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
#[test]
fn homebrew_npm_global_package_anchors_not_brew() {
// openclaw 装在 Homebrew node 的全局目录(lib/node_modules,非 Cellar)
// 是 npm 全局包,走 npm 锚定而非 brew upgrade。
let cmd = anchored_command_from_paths(
"openclaw",
"/opt/homebrew/bin/openclaw",
"/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs",
);
assert_eq!(
cmd.as_deref(),
Some("/opt/homebrew/bin/npm i -g openclaw@latest")
);
}
#[test]
fn volta_uses_volta_install() {
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.volta/bin/codex",
"/Users/me/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex",
);
assert_eq!(cmd.as_deref(), Some("volta install @openai/codex"));
}
#[test]
fn bun_uses_bun_add() {
let cmd = anchored_command_from_paths(
"opencode",
"/Users/me/.bun/bin/opencode",
"/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode",
);
assert_eq!(cmd.as_deref(), Some("bun add -g opencode-ai@latest"));
}
#[test]
fn hermes_has_no_npm_anchor() {
// hermes 走 pip,不在 npm 包表 → 锚定返回 None,调用方回退到静态 pip 升级命令。
let cmd = anchored_command_from_paths(
"hermes",
"/usr/local/bin/hermes",
"/usr/local/bin/hermes",
);
assert_eq!(cmd, None);
}
#[test]
fn system_install_without_sibling_npm_is_not_anchored() {
// opencode install.sh 装到 ~/.opencode/bin(独立二进制、无同级 npm):
// 不能锚定到 `<dir>/npm`(必失败),返回 None 让调用方回退静态命令。
let cmd = anchored_command_from_paths(
"opencode",
"/Users/me/.opencode/bin/opencode",
"/Users/me/.opencode/bin/opencode",
);
assert_eq!(cmd, None);
}
#[test]
fn go_bin_install_is_not_anchored() {
// ~/go/bin 同理:无同级 npm。
let cmd = anchored_command_from_paths(
"opencode",
"/Users/me/go/bin/opencode",
"/Users/me/go/bin/opencode",
);
assert_eq!(cmd, None);
}
#[test]
fn fnm_install_anchors_to_that_npm() {
// fnm 是自带同级 npm 的 node 管理器 → 锚定到那处的 npm。
let cmd = anchored_command_from_paths(
"codex",
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex",
"/Users/me/.local/share/fnm_multishells/12345_abc/lib/node_modules/@openai/codex/bin/codex.js",
);
assert_eq!(
cmd.as_deref(),
Some(
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
)
);
}
#[test]
fn path_with_space_is_quoted() {
let cmd = anchored_command_from_paths(
"codex",
"/Users/my name/.nvm/versions/node/v22/bin/codex",
"/Users/my name/.nvm/versions/node/v22/lib/node_modules/@openai/codex/bin/codex.js",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
);
}
#[test]
fn brew_formula_extraction() {
assert_eq!(
brew_formula_from_path("/opt/homebrew/Cellar/gemini-cli/0.13.0/bin/gemini")
.as_deref(),
Some("gemini-cli")
);
// node 全局包不在 Cellar 下 → 不是 formula。
assert_eq!(
brew_formula_from_path("/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs"),
None
);
assert_eq!(
brew_formula_from_path("/Users/me/.nvm/versions/node/v22/lib/node_modules/x"),
None
);
}
#[test]
fn default_install_prefers_path_default() {
let installs = vec![
inst("/opt/homebrew/bin/openclaw", false),
inst("/Users/me/.nvm/versions/node/v22/bin/openclaw", true),
];
assert_eq!(
default_install(&installs).map(|i| i.path.as_str()),
Some("/Users/me/.nvm/versions/node/v22/bin/openclaw")
);
}
#[test]
fn default_install_falls_back_to_sole_entry() {
let installs = vec![inst("/opt/homebrew/bin/gemini", false)];
assert_eq!(
default_install(&installs).map(|i| i.path.as_str()),
Some("/opt/homebrew/bin/gemini")
);
}
#[test]
fn default_install_none_when_ambiguous() {
let installs = vec![
inst("/opt/homebrew/bin/openclaw", false),
inst("/Users/me/.nvm/versions/node/v22/bin/openclaw", false),
];
assert!(default_install(&installs).is_none());
}
#[test]
fn first_abs_path_line_skips_shell_noise() {
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
// command -v 的真实路径在其后 → 跳过噪音取真路径。
assert_eq!(
first_abs_path_line("🚀 Welcome back!\n/Users/me/.local/bin/claude\n"),
Some("/Users/me/.local/bin/claude")
);
// 无噪音时取第一行。
assert_eq!(
first_abs_path_line("/opt/homebrew/bin/gemini\n"),
Some("/opt/homebrew/bin/gemini")
);
// 输出里没有任何绝对路径 → None。
assert_eq!(first_abs_path_line("welcome\nbye\n"), None);
}
#[test]
fn is_conflicting_thresholds() {
let make = |version: Option<&str>, runnable: bool| ToolInstallation {
path: "/x".to_string(),
version: version.map(str::to_string),
runnable,
error: None,
source: "nvm".to_string(),
is_path_default: false,
};
// 单处 → 不冲突。
assert!(!is_conflicting(&[make(Some("1.0.0"), true)]));
// 两处同版本、都能跑 → 不冲突(同版本装两遍不打扰)。
assert!(!is_conflicting(&[
make(Some("1.0.0"), true),
make(Some("1.0.0"), true)
]));
// 版本分歧 → 冲突。
assert!(is_conflicting(&[
make(Some("1.0.0"), true),
make(Some("2.0.0"), true)
]));
// 同版本但运行态混合(一个能跑、一个跑不起来)→ 冲突。
assert!(is_conflicting(&[
make(Some("1.0.0"), true),
make(Some("1.0.0"), false)
]));
}
}
#[cfg(target_os = "windows")]
mod wsl_helpers {
use super::super::*;
+1 -1
View File
@@ -1283,7 +1283,7 @@ pub fn run() {
commands::launch_session_terminal,
commands::get_tool_versions,
commands::run_tool_lifecycle_action,
commands::diagnose_tool_installations,
commands::probe_tool_installations,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management
+89 -61
View File
@@ -28,7 +28,10 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getVersion } from "@tauri-apps/api/app";
import { settingsApi } from "@/lib/api";
import type { ToolInstallation } from "@/lib/api/settings";
import type {
ToolInstallation,
ToolInstallationReport,
} from "@/lib/api/settings";
import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
@@ -37,6 +40,8 @@ import appIcon from "@/assets/icons/app-icon.png";
import { APP_ICON_MAP } from "@/config/appConfig";
import type { AppId } from "@/lib/api/types";
import { extractErrorMessage } from "@/utils/errorUtils";
import { ToolUpgradeConfirmDialog } from "./ToolUpgradeConfirmDialog";
import { ToolInstallRow } from "./ToolInstallRow";
interface AboutSectionProps {
isPortable: boolean;
@@ -122,6 +127,12 @@ const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
hermes: "Hermes",
};
// 后端返回的 tool 是 string;这里收敛唯一的 ToolName 断言与兜底,供升级确认
// 对话框按工具名展示(避免在 JSX 里内联 cast、且每次渲染都新建闭包)。
function toolDisplayName(tool: string): string {
return TOOL_DISPLAY_NAMES[tool as ToolName] ?? tool;
}
const TOOL_APP_IDS: Record<ToolName, AppId> = {
claude: "claude",
codex: "codex",
@@ -217,6 +228,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
Partial<Record<ToolName, ToolInstallation[]>>
>({});
const [isDiagnosingAll, setIsDiagnosingAll] = useState(false);
// 升级前探测到「多处安装需确认」时暂存:toolNames=本次要升级的全部工具,
// plans=其中需要确认的(≥2 处)那些。用户确认后对 toolNames 整体执行升级。
const [pendingUpgrade, setPendingUpgrade] = useState<{
toolNames: ToolName[];
plans: ToolInstallationReport[];
} | null>(null);
const toolVersionByName = useMemo(() => {
return new Map(toolVersions.map((tool) => [tool.name, tool]));
@@ -450,14 +467,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
}, [t]);
// 升级后自动补诊单个工具:静默后台执行,仅在确有冲突写入结果
// 无冲突不弹 toast、不报错打扰——与用户主动点的全量诊断区别对待。
// 升级后自动补诊单个工具:静默后台执行有冲突写入结果;无冲突则清掉该工具可能残留
// 的过期冲突展示(外部卸载/修复后冲突可能已消失,不清会一直显示旧列表)。不弹 toast、
// 不报错打扰——与用户主动点的全量诊断区别对待。
const diagnoseToolSilently = useCallback(async (toolName: ToolName) => {
try {
const installs = await settingsApi.diagnoseToolInstallations(toolName);
if (installs.length > 0) {
setToolDiagnostics((prev) => ({ ...prev, [toolName]: installs }));
const [report] = await settingsApi.probeToolInstallations([toolName]);
setToolDiagnostics((prev) => {
if (report?.is_conflict) {
return { ...prev, [toolName]: report.installs };
}
// 无冲突:清掉残留;无旧结果则返回同引用,避免无谓 re-render。
if (!(toolName in prev)) return prev;
const next = { ...prev };
delete next[toolName];
return next;
});
} catch (error) {
console.error(
`[AboutSection] Auto-diagnose failed for ${toolName}`,
@@ -471,17 +496,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const handleDiagnoseAll = useCallback(async () => {
setIsDiagnosingAll(true);
try {
const entries = await Promise.all(
TOOL_NAMES.map(
async (name) =>
[name, await settingsApi.diagnoseToolInstallations(name)] as const,
),
);
const reports = await settingsApi.probeToolInstallations([...TOOL_NAMES]);
const next: Partial<Record<ToolName, ToolInstallation[]>> = {};
let conflicts = 0;
for (const [name, installs] of entries) {
next[name] = installs;
if (installs.length > 0) conflicts += 1;
for (const report of reports) {
if (report.is_conflict) {
next[report.tool as ToolName] = report.installs;
conflicts += 1;
}
}
setToolDiagnostics(next);
if (conflicts === 0) {
@@ -498,10 +520,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
}, [t]);
const handleRunToolAction = useCallback(
// 实际执行安装/升级的串行循环(已通过任何必要的确认后才调用)。
const executeRun = useCallback(
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
if (toolNames.length === 0) return;
const isBatch = toolNames.length > 1;
if (isBatch) {
setBatchAction(action);
@@ -517,9 +538,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
for (const toolName of toolNames) {
setToolActions((prev) => ({ ...prev, [toolName]: action }));
// 记录执行前的版本:用于识别"升级跑完但版本没变"——这正是多处安装互相
// 打架的头号症状(升级写入 A 处、PATH 实际仍跑 B 处),需自动触发诊断。
const beforeVersion = toolVersionByName.get(toolName)?.version ?? null;
try {
await settingsApi.runToolLifecycleAction(
[toolName],
@@ -534,12 +552,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const tool = refreshed.find((t) => t.name === toolName);
if (tool?.version) {
succeeded += 1;
// 升级命令成功、版本号却没动:大概率被另一处安装遮蔽,自动诊断。
if (
action === "update" &&
beforeVersion &&
tool.version === beforeVersion
) {
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
if (action === "update") {
void diagnoseToolSilently(toolName);
}
} else {
@@ -625,15 +640,46 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
);
}
},
[
t,
wslShellByTool,
refreshToolVersions,
toolVersionByName,
diagnoseToolSilently,
],
[t, wslShellByTool, refreshToolVersions, diagnoseToolSilently],
);
// 升级前先探测安装分布:检测到多处安装(命令行只命中其中一处)时弹确认,
// 让用户在「升级只动默认那处、其余不动」这件事上知情。install 无锚点,直接执行。
const handleRunToolAction = useCallback(
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
if (toolNames.length === 0) return;
if (action === "install") {
await executeRun(toolNames, action);
return;
}
let reports: ToolInstallationReport[];
try {
reports = await settingsApi.probeToolInstallations(toolNames);
} catch (error) {
// 探测失败不应阻断升级:退回直接执行(等同旧行为)。
console.error("[AboutSection] probeToolInstallations failed", error);
await executeRun(toolNames, action);
return;
}
const needConfirm = reports.filter((r) => r.needs_confirmation);
if (needConfirm.length === 0) {
await executeRun(toolNames, action);
return;
}
setPendingUpgrade({ toolNames, plans: needConfirm });
},
[executeRun],
);
const handleConfirmUpgrade = useCallback(() => {
if (pendingUpgrade) {
void executeRun(pendingUpgrade.toolNames, "update");
}
setPendingUpgrade(null);
}, [pendingUpgrade, executeRun]);
const handleCancelUpgrade = useCallback(() => setPendingUpgrade(null), []);
const displayVersion = version ?? t("common.unknown");
// 任一安装/升级进行中(批量或单工具)即视为忙碌:用于禁用所有操作按钮,
@@ -998,33 +1044,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
);
return (
<li key={inst.path} className="space-y-1">
<div className="flex items-center gap-1.5 text-[10px]">
<span className="shrink-0 rounded bg-background/80 px-1 py-0.5 font-mono text-muted-foreground">
{inst.source}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-muted-foreground"
title={inst.path}
>
{inst.path}
</span>
<span
className={
inst.runnable
? "shrink-0 font-mono text-foreground"
: "shrink-0 text-yellow-600 dark:text-yellow-400"
}
>
{inst.runnable
? inst.version
: t("settings.toolConflictNotRunnable")}
</span>
{inst.is_path_default && (
<span className="shrink-0 rounded-full border border-primary/30 bg-primary/10 px-1 py-0.5 text-[9px] text-primary">
{t("settings.toolConflictDefault")}
</span>
)}
</div>
<ToolInstallRow inst={inst} />
{/* 卸载建议命令:仅供复制,绝不代执行。前置红色垃圾桶图标
明示这是卸载(破坏性)命令,避免误以为是普通信息。 */}
<div className="flex items-center gap-1.5">
@@ -1132,6 +1152,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div>
)}
</motion.div>
<ToolUpgradeConfirmDialog
isOpen={pendingUpgrade !== null}
plans={pendingUpgrade?.plans ?? []}
displayName={toolDisplayName}
onConfirm={handleConfirmUpgrade}
onCancel={handleCancelUpgrade}
/>
</motion.section>
);
}
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import type { ToolInstallation } from "@/lib/api/settings";
/**
* 单处工具安装的信息行:来源徽章 + 路径 + 版本(或「无法运行」)+「默认」标记。
* 冲突诊断列表与升级确认对话框共用,确保两处的视觉与「默认」判定始终一致。
*/
export function ToolInstallRow({ inst }: { inst: ToolInstallation }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1.5 text-[10px]">
<span className="shrink-0 rounded bg-background/80 px-1 py-0.5 font-mono text-muted-foreground">
{inst.source}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-muted-foreground"
title={inst.path}
>
{inst.path}
</span>
<span
className={
inst.runnable
? "shrink-0 font-mono text-foreground"
: "shrink-0 text-yellow-600 dark:text-yellow-400"
}
>
{inst.runnable ? inst.version : t("settings.toolConflictNotRunnable")}
</span>
{inst.is_path_default && (
<span className="shrink-0 rounded-full border border-primary/30 bg-primary/10 px-1 py-0.5 text-[9px] text-primary">
{t("settings.toolConflictDefault")}
</span>
)}
</div>
);
}
@@ -0,0 +1,102 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ToolInstallationReport } from "@/lib/api/settings";
import { ToolInstallRow } from "./ToolInstallRow";
interface ToolUpgradeConfirmDialogProps {
isOpen: boolean;
plans: ToolInstallationReport[];
displayName: (tool: string) => string;
onConfirm: () => void;
onCancel: () => void;
}
/**
* 升级前的「多处安装确认」。仅当某工具检测到 ≥2 处安装时弹出:展示命令行实际命中
* 哪处(标「默认」= 升级目标)、各处版本,以及锚定后将执行的命令,让用户在
* 「升级只动其中一处、其余不动」这件事上知情后再确认。单处安装不会走到这里。
*/
export function ToolUpgradeConfirmDialog({
isOpen,
plans,
displayName,
onConfirm,
onCancel,
}: ToolUpgradeConfirmDialogProps) {
const { t } = useTranslation();
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<DialogContent className="max-w-md" zIndex="alert">
<DialogHeader className="space-y-2 border-b-0 bg-transparent pb-0">
<DialogTitle className="flex items-center gap-2 text-base font-semibold">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
{t("settings.toolUpgradeConfirmTitle")}
</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
{t("settings.toolUpgradeConfirmHint")}
</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] space-y-3 overflow-y-auto">
{plans.map((plan) => (
<div
key={plan.tool}
className="space-y-1.5 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-2.5"
>
<div className="text-xs font-medium">
{displayName(plan.tool)}
</div>
{!plan.anchored && (
<div className="text-[10px] leading-snug text-yellow-600 dark:text-yellow-400">
{t("settings.toolUpgradeUnanchoredHint")}
</div>
)}
<ul className="space-y-1">
{plan.installs.map((inst) => (
<li key={inst.path}>
<ToolInstallRow inst={inst} />
</li>
))}
</ul>
<div className="space-y-0.5">
<div className="text-[10px] text-muted-foreground">
{t("settings.toolUpgradeWillRun")}
</div>
<code
className="block truncate rounded bg-background/80 px-1.5 py-0.5 font-mono text-[10px] text-foreground"
title={plan.command}
>
{plan.command}
</code>
</div>
</div>
))}
</div>
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm}>
{t("settings.toolUpgradeConfirmBtn")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+5
View File
@@ -686,6 +686,11 @@
"toolDiagnoseFailed": "Diagnosis failed",
"toolUninstallCopyHint": "Copy uninstall command (won't run it)",
"toolUninstallCopied": "Uninstall command copied",
"toolUpgradeConfirmTitle": "Confirm upgrade target",
"toolUpgradeConfirmHint": "Multiple installations detected. This upgrade won't update all of them; refer to each tool below for what it actually targets.",
"toolUpgradeWillRun": "Will run:",
"toolUpgradeConfirmBtn": "Confirm upgrade",
"toolUpgradeUnanchoredHint": "Can't determine which install your command line uses; will run the default upgrade command (may install to the first npm on PATH).",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+5
View File
@@ -686,6 +686,11 @@
"toolDiagnoseFailed": "診断に失敗しました",
"toolUninstallCopyHint": "アンインストールコマンドをコピー(自動実行しません)",
"toolUninstallCopied": "アンインストールコマンドをコピーしました",
"toolUpgradeConfirmTitle": "アップグレード先の確認",
"toolUpgradeConfirmHint": "複数のインストールを検出しました。今回のアップグレードはすべてを更新するわけではありません。実際に作用する箇所は下記の各項目をご確認ください。",
"toolUpgradeWillRun": "実行内容:",
"toolUpgradeConfirmBtn": "アップグレードを実行",
"toolUpgradeUnanchoredHint": "コマンドラインが実際に使用する箇所を特定できません。既定のアップグレードコマンドを実行します(PATH 上の最初の npm にインストールされる場合があります)。",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+5
View File
@@ -686,6 +686,11 @@
"toolDiagnoseFailed": "诊断失败",
"toolUninstallCopyHint": "复制卸载命令(不会自动执行)",
"toolUninstallCopied": "卸载命令已复制",
"toolUpgradeConfirmTitle": "确认升级位置",
"toolUpgradeConfirmHint": "检测到多处安装。本次升级不会全部更新,具体作用的位置以下方各项为准。",
"toolUpgradeWillRun": "将执行:",
"toolUpgradeConfirmBtn": "确认升级",
"toolUpgradeUnanchoredHint": "无法确定命令行实际使用哪处,将执行默认升级命令(可能装到 PATH 上第一个 npm)。",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+16 -2
View File
@@ -208,8 +208,12 @@ export const settingsApi = {
});
},
async diagnoseToolInstallations(tool: string): Promise<ToolInstallation[]> {
return await invoke("diagnose_tool_installations", { tool });
/** 探测各工具安装分布:枚举所有安装、标记冲突、生成锚定升级命令。
* 诊断按钮、升级前确认、升级后补诊共用此命令,各取所需字段。 */
async probeToolInstallations(
tools: string[],
): Promise<ToolInstallationReport[]> {
return await invoke("probe_tool_installations", { tools });
},
async getRectifierConfig(): Promise<RectifierConfig> {
@@ -247,6 +251,16 @@ export interface ToolInstallation {
is_path_default: boolean;
}
/** 一次"探测工具安装分布"的结果。字段对应后端 ToolInstallationReport。 */
export interface ToolInstallationReport {
tool: string;
installs: ToolInstallation[];
is_conflict: boolean;
needs_confirmation: boolean;
command: string;
anchored: boolean;
}
export interface RectifierConfig {
enabled: boolean;
requestThinkingSignature: boolean;