From 3a77861dd9cdfddfa1046c950ecc20d4e171c6cb Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 24 May 2026 18:55:35 +0800 Subject: [PATCH] feat(settings): backend-generated source-aware uninstall command hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move uninstall hint command generation from frontend (which used a lossy `source` string and couldn't tell brew formula from homebrew-npm-global apart) into the Rust backend, where `brew_formula_from_path` + `infer_install_source` together produce correct commands for each install. Conflict UI now reads `inst.uninstall_command` directly. Backend (src-tauri/src/commands/misc.rs): - New `uninstall_command_from_paths` (POSIX + Windows) mirrors the `anchored_command_from_paths` family. POSIX branches on brew formula / volta / bun / pnpm / nvm|fnm|mise|homebrew / system. Windows branches on volta / pnpm / npm. - New `quoted_sibling_or_bare` (POSIX) + `quoted_sibling_or_bare_with_ext` (Windows) deduplicate the `sibling_bin().map(quote).unwrap_or_else(bare)` pattern across all uninstall branches. - New `posix_quote_for_user_shell` whitelists `[A-Za-z0-9._/+=:@-]`; any other character routes through `shell_single_quote`. Replaces `quote_path_if_spaced` on the uninstall side because the fallback chain is destructive (`rm -rf `), so a `;` / `$` / backtick in the home dir would have let the chain inject extra commands. - New `win_quote_path_for_user_shell` (no `%` 4x escape) splits from `win_quote_path_for_batch`. Uninstall hints are copy-paste targets, not `.bat + call` payloads, so the two-round percent expansion that motivates the 4x escape doesn't apply. - POSIX `nvm|fnm|mise|homebrew` branch appends physical-delete fallback `; [ -e ] && rm -f && rm -rf ` because nvm v18.20.8 was observed to silently no-op `npm rm -g` (exit 0 with `up to date` message, no filesystem change). Anchored `/{bin,lib}` layout assumption documented as branch-specific. - `ToolInstallation` gains `uninstall_command: String` and `uninstall_command_needs_cmd_hint: bool`. The bool is computed as `cfg!(target_os = "windows") && uninstall_command.contains('"')` — a compile-time platform gate avoids false positives from POSIX `shell_single_quote`'s `'"'"'` escape form, which also contains `"`. - `enumerate_tool_installations` computes `source` once and threads it into `uninstall_command_from_paths`, removing a redundant `infer_install_source` call (review N1). Frontend (src/components/settings/AboutSection.tsx + src/lib/api/settings.ts): - Remove the JS helpers `buildUninstallCommand`, `dirOfPath`, `isWindowsPath`, `shellQuote`, `TOOL_NPM_PACKAGES`. Conflict rows now read `inst.uninstall_command` directly. - Render a PowerShell-call-operator hint when `inst.uninstall_command_needs_cmd_hint` is true. Avoids string-match inference from the command (which fails on macOS user names containing `'`, e.g. `o'leary`, because `shell_single_quote`'s escape contains `"`). - `ToolInstallation` TS interface gains the two new fields. i18n (zh/en/ja): - New key `settings.toolUninstallPwshHint` documents the PowerShell `&` (call operator) requirement for quoted paths. Tests: 30 new cases under `commands::misc::tests::anchored_upgrade` cover each POSIX branch (brew formula extraction, npm anchored with fallback, pnpm, volta, bun, hermes), strong-quoting under `;` / `'` paths, the fallback-only-for-anchored-branches inverse guard, plus mirror Windows helpers for the user-shell quoter and a `path%foo%` integration case demonstrating the literal `%` preservation contract. Total `commands::misc` test count: 68. --- src-tauri/src/commands/misc.rs | 788 ++++++++++++++++++++++- src/components/settings/AboutSection.tsx | 72 +-- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/api/settings.ts | 14 + 6 files changed, 819 insertions(+), 58 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index e65da994e..0b5163d94 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1264,10 +1264,37 @@ pub struct ToolInstallation { runnable: bool, /// 跑不起来时的诊断信息末尾若干行。 error: Option, - /// 由路径前缀推断的安装来源(nvm/homebrew/...),驱动 UI 徽章与卸载建议。 + /// 由路径前缀推断的安装来源(nvm/homebrew/...),驱动 UI 徽章。 source: String, /// 是否为 PATH 解析到的那处(= 命令行默认,也是升级会作用的目标)。 is_path_default: bool, + /// 卸载该处的命令(仅展示给用户复制,UI 不代执行)。源感知 + 真身感知: + /// brew formula → `brew uninstall `、volta → `volta uninstall`、bun → + /// `bun rm -g`、pnpm → `pnpm rm -g`、其余 npm 全局 → 锚定那处同目录 npm 的 + /// `npm rm -g`(附物理删除兜底)。复用 `brew_formula_from_path`、`infer_install_source`、 + /// `sibling_bin`,与升级路径(`anchored_command_from_paths`)共享真身判定——避免 + /// 前端按 `source` 字符串拼命令时把"homebrew formula"和"homebrew node 的 npm 全局包" + /// 混为一谈。 + /// + /// **eager 计算(与升级命令按需算的不对称是 by design)**:升级命令在 + /// `installs_anchored_command` 里只为 default install 算一次;卸载命令则**每处 + /// install 都预计算**进字段,因为冲突诊断 UI 需要为每处展示对应的卸载 hint—— + /// 延后到二次 IPC 会增加"诊断 → 显示命令"的端到端延迟。计算成本是纯字符串 + /// 扫描(评估:8 install × ~2μs ≈ 16μs,可忽略),不影响 probe 1-3s 主时延。 + uninstall_command: String, + /// `uninstall_command` 是否是 Windows cmd 兼容形式(含 quoted 路径,PowerShell + /// 用户需 `&` call operator 前缀)。前端据此**条件渲染**一行 PowerShell 限制提示。 + /// + /// **必须由后端提供结构化信号、前端不能 string match**:POSIX 的 + /// `shell_single_quote` 转义形式是 `'"'"'`(关单引号 + 双引号包字面单引号 + + /// 重开)——含双引号!所以 macOS 用户名带 `'`(如 `o'leary`)时,POSIX 卸载命令 + /// **巧合地也含 `"`**,前端 `cmd.includes('"')` 会**误判为 Windows cmd 形式**, + /// 给 POSIX 用户错误显示 PowerShell 提示。string-based 平台推断在这里 false-positive。 + /// + /// 后端用 `cfg!(target_os = "windows") && uninstall_command.contains('"')` 算: + /// `cfg!` 是**编译时 ground truth**(不是字符串猜测),POSIX 编译时整体短路为 + /// false,与 POSIX 单引号 escape 含 `"` 的形态完全无关。 + uninstall_command_needs_cmd_hint: bool, /// canonicalize 解析后的真身路径(brew 形如 `Cellar//...`、claude 原生形如 /// `~/.local/share/claude/versions/...`),用于 `anchored_command_from_paths` 的真身 /// 判定。`enumerate_tool_installations` 已经为去重算过一次,这里复用避免上游 @@ -1437,14 +1464,33 @@ fn enumerate_tool_installations(tool: &str) -> Vec { }; let is_path_default = path_default.as_ref() == Some(&real); + let path_str = tool_path.display().to_string(); + // **source 在这里算一次,同时供 ToolInstallation.source 字段和 + // uninstall_command_from_paths 的 match 用**(review N1):函数签名上 + // source 作为参数传入,避免函数内部再 `infer_install_source(Path::new(bin_path))` + // 二次扫描 + 重复 lower-case 分配。同时把"路径分类"的契约外化——上下游 + // 都以同一标签作判定,不会因函数内外重算意外漂移。 + let source = infer_install_source(&tool_path); + // 卸载提示与升级锚定共用同一份 (bin_path, real) —— 见 + // `uninstall_command_from_paths` 的 doc:**前端按 source 字符串拼无法 + // 区分 brew formula vs homebrew npm 全局包**,只能在后端拿真身判定。 + let uninstall_command = + uninstall_command_from_paths(tool, &path_str, &real.to_string_lossy(), source); + // PowerShell hint 触发位:Windows + 命令含 quoted 路径。`cfg!` 是编译时 + // 常量,POSIX 编译时整体短路为 false——这就把 POSIX 单引号 escape `'"'"'` + // 也含 `"` 的字面巧合从触发域里排除掉(见字段 doc)。 + let uninstall_command_needs_cmd_hint = + cfg!(target_os = "windows") && uninstall_command.contains('"'); installs.push(ToolInstallation { - path: tool_path.display().to_string(), + path: path_str, version, runnable, error, - source: infer_install_source(&tool_path).to_string(), + source: source.to_string(), is_path_default, + uninstall_command, + uninstall_command_needs_cmd_hint, // 复用上面 line ~1357 已 canonicalize 的真身,避免下游 // installs_anchored_command 再 canonicalize 一遍同一文件。 real: real.clone(), @@ -1520,6 +1566,43 @@ fn quote_path_if_spaced(p: &str) -> String { } } +/// **卸载 hint 专用 POSIX quoting**——白名单判定:全字符都在 +/// `[A-Za-z0-9._/+=:@-]` 安全集合内 → 保持裸路径(常规 nvm/brew/volta/bun 路径 +/// 以及 npm scope 包名 `@scope/pkg` 都命中,命令展示干净);任何不在集合的字符 +/// (包括空格、`;`、`$`、`` ` ``、`'`、`"`、`&`、`|`、`<`、`>`、`(`、`)`、`*`、 +/// `?`、`[`、`]`、`{`、`}`、`!`、`#`、换行、tab 等)→ 走 `shell_single_quote` +/// 无条件强 quote。 +/// +/// **白名单含 `@`** :npm scope 包名形如 `@anthropic-ai/claude-code` / +/// `@google/gemini-cli`,pkg_dir 必然含 `@`;且 `@` 在 bash/zsh/dash/sh 都无特殊 +/// 含义(zsh 的 `${var@P}` 是 parameter expansion 的修饰符,需要前缀 `$`,单独 `@` +/// 字符在路径里无害),纳入白名单不引入风险。 +/// +/// **为什么不复用 `quote_path_if_spaced`**(P2 修复):后者只查空格,但卸载命令 +/// 是 destructive(`rm -rf `)——含 `;` / `` ` `` / `$(...)` 的路径会让 +/// shell 把卸载链拆成多条命令、把 rm 落到错的路径。升级命令"出错"是 inconvenient, +/// 但卸载侧的 quoting 漏洞是 destructive risk,**安全门槛必须更高**。 +/// +/// **为什么用白名单而非黑名单**:shell 元字符列表长且 shell-specific(bash vs zsh +/// vs dash 略有差异),黑名单容易漏。白名单只允许"绝对安全字符",其他一律 quote +/// ——default-quoting 比 default-bare 更适合 destructive 命令。常规 POSIX 安装 +/// 路径 + npm 包名只用安全字符,白名单不影响展示美观。 +/// +/// 路径含安全集合外字符(如 home dir 名带 `;` `'` `$`)时,`shell_single_quote` +/// 把单引号 escape 为 `'"'"'`(关引号 + 双引号包单引号 + 重开引号),跨 +/// bash/zsh/dash/sh 全部 POSIX 兼容。 +#[cfg(not(target_os = "windows"))] +fn posix_quote_for_user_shell(p: &str) -> String { + let is_safe = |c: char| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '+' | '=' | ':' | '-' | '@') + }; + if p.chars().all(is_safe) { + p.to_string() + } else { + shell_single_quote(p) + } +} + /// 锚定路径走 `.bat` 文件且**被 `call` 调用**,需要为 batch 特殊字符做两层防御: /// /// **(1) `%` 经历两轮 percent expansion → 用 4 个 `%` 转义**。.bat 中字面 `%` 的 @@ -1564,6 +1647,51 @@ fn win_quote_path_for_batch(p: &str) -> String { } } +/// **复制给用户的命令**专用 Windows quoting,**目标语法为 cmd**——与 +/// `win_quote_path_for_batch` 的关键差异是**不做 `%` 转义**(本 helper 服务的 +/// 命令不进 .bat + call,只一轮 expansion 不需要 4 倍)。 +/// +/// **PowerShell 用户的已知限制**(P3, 2026-05-24):本 helper 生成的 quoted 路径 +/// (含空格时 `"C:\Program Files\nodejs\npm.cmd"`)在 PowerShell 下**当字符串字面 +/// 值,不会执行**——PowerShell 对含引号的可执行路径需要 call operator `&` 前缀: +/// +/// ```text +/// cmd: "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← OK +/// PowerShell: "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← 当字符串字面 +/// PowerShell: & "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← OK +/// cmd: & "C:\Program Files\nodejs\npm.cmd" rm -g pkg ← `&` 是命令分隔符,报错 +/// ``` +/// +/// 两个 shell 在引号路径上语法完全不兼容,无法用一份 quoting 同时满足。本 helper +/// 选 cmd 兼容形式(Windows 历史默认 shell;路径不含空格时 PowerShell 也能直接跑, +/// 因为无引号路径在 PowerShell 下走 unquoted invocation)。**含空格路径的 PowerShell +/// 用户需手动在命令前加 `&`** ——前端 UI 文案应明示此限制(否则用户照 hint 复制 +/// 粘到 PowerShell 会看到"字符串回显但什么都没执行"的迷惑现象)。 +/// +/// **`%` 字符的语义对比**:cmd 一轮展开会把 `%foo%` 当变量;PowerShell / bash on +/// Windows / WSL 都无 % 展开(字面识别)。三套语义无法共存,**保持原始 `%`** +/// 是最诚实的兜底——cmd 用户在含 `%` 路径下仍会被变量展开困住(cmd 固有限制, +/// 双引号内 `%` 仍展开,任何 quoting 救不了);若用 batch quoter 的 4 倍 `%`, +/// 则三个 shell 全部得到错路径(cmd 一轮变 `%%`、PowerShell/bash 字面 `%%%%`)。 +/// +/// **本 helper 与 `win_quote_path_for_batch` 必须保持镜像的部分**:token 边界字符 +/// (空格 & ( ) ^ ; < > | ,)的双引号包裹规则一致——这些字符在三个 shell 下都 +/// 用引号包裹保持字面,跨 shell 一致。 +/// +/// 使用场景仅限"卸载提示之类只展示不代执行的命令";.bat + call 链路坚决用 +/// `win_quote_path_for_batch`,否则两轮 expansion 下 % 字面会丢失。 +#[cfg(target_os = "windows")] +fn win_quote_path_for_user_shell(p: &str) -> String { + let needs_quote = p + .chars() + .any(|c| matches!(c, ' ' | '&' | '(' | ')' | '^' | ';' | '<' | '>' | '|' | ',')); + if needs_quote { + win_double_quote(p) + } else { + p.to_string() + } +} + /// Windows 版 sibling 推导:在 `` 下按 `ext_candidates` 顺序找 /// 第一个存在的 `.` 文件,返回该绝对路径。 /// @@ -1627,6 +1755,42 @@ fn sibling_bin(bin_path: &str, exe: &str) -> Option { } } +/// 卸载 hint 专用:取 sibling exe 绝对路径并 quote;推不出 sibling 时退化为**裸命令名** +/// (让用户终端 PATH 默认那个工具处理)。 +/// +/// **与 anchored upgrade 不返 None 的差异**:`anchored_command_from_paths` 的 POSIX 版 +/// 用 `?` 短路返 `None`,让上游退化到静态命令并标 `anchored=false`;卸载 hint 是直接 +/// 给用户复制粘贴的——返 None 等于"不展示命令",诚实但 UX 差。退化为裸命令(无路径 +/// 前缀)反而是"明确意图 + 让用户终端自决"的合理兜底,与 `static_fallback_command` 同款。 +/// +/// **quoting 用 `posix_quote_for_user_shell` 而非 `quote_path_if_spaced`**(P2 修复): +/// 卸载命令是 destructive(部分分支带 `rm -rf` 兜底),含 `;` / `` ` `` / `$` 的路径 +/// 必须强 quote 防止 shell 拆词出错——detail 见 `posix_quote_for_user_shell` doc。 +/// +/// 仅在 POSIX uninstall 的 brew/volta/bun/pnpm/npm 五条分支共用,消除"sibling_bin → +/// map → unwrap_or_else 裸字面" 三行 boilerplate 的重复出现。 +#[cfg(not(target_os = "windows"))] +fn quoted_sibling_or_bare(bin_path: &str, exe: &str) -> String { + sibling_bin(bin_path, exe) + .map(|b| posix_quote_for_user_shell(&b)) + .unwrap_or_else(|| exe.to_string()) +} + +/// Windows 版镜像 `quoted_sibling_or_bare`,quoter 用 `win_quote_path_for_user_shell` +/// (不做 4× % 转义,目标是复制到用户 shell 而非 .bat + call)。fallback 是 exe_basename +/// 裸名(`"npm"`/`"pnpm"`/`"volta"`),与原 `format!("npm rm -g {pkg}")` 兜底一致—— +/// 不返回带扩展名的 `npm.cmd`,因为用户 shell 上 `npm` 已被 PATH 解析时自动加扩展。 +#[cfg(target_os = "windows")] +fn quoted_sibling_or_bare_with_ext( + bin_path: &str, + exe_basename: &str, + ext_candidates: &[&str], +) -> String { + sibling_bin_with_ext(bin_path, exe_basename, ext_candidates) + .map(|p| win_quote_path_for_user_shell(&p)) + .unwrap_or_else(|| exe_basename.to_string()) +} + /// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径, /// 推断"写回同一处"的锚定升级命令。**POSIX 版是纯函数(不碰 FS)**——真实 canonicalize /// 由调用方做(`installs_anchored_command` 复用 enumerate 时算出的 `inst.real`), @@ -1634,6 +1798,10 @@ fn sibling_bin(bin_path: &str, exe: &str) -> Option { /// 是刻意保留的平台差异(详见 Windows 版本 doc)。hermes(pip)不在此处: /// `npm_package_for` 返回 None → None。 /// +/// **对偶函数**:`uninstall_command_from_paths` 是本函数在卸载侧的镜像,共享 brew +/// formula 真身判定 + sibling 锚定 + source 等价类。任一边新增/移除等价类都得 +/// 同步另一边——卸载侧 doc 对此点有更详细的不变量说明。 +/// /// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。 /// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行—— /// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含** @@ -1692,6 +1860,200 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Some(format!("{} i -g {pkg}@latest", quote_path_if_spaced(&npm))) } +/// 给定一处具体安装的 bin 路径、真身路径与 source 标签,推断**卸载该处**的命令 +/// (纯函数、不碰 FS)。`source` 由调用方传入(`enumerate_tool_installations` 复用 +/// 已为 `ToolInstallation.source` 字段算过的那次 `infer_install_source` 结果,本函数 +/// 不再二次调用,见 review N1)。与 `anchored_command_from_paths` 共享真身判定: +/// formula 走 `brew uninstall`、volta/bun/pnpm 走各自 uninstaller、其余 npm 全局走 +/// sibling npm 的 `rm -g`。 +/// +/// **为什么不让前端拼**:前端只拿到 `source` 字符串,但 `infer_install_source` 把 +/// `/Cellar/` 与 `/opt/homebrew/lib/node_modules/` 都归为 `"homebrew"`,**有损分类**—— +/// 前端区分不出 formula 和 npm 全局包,只能统一拼成 `npm rm -g`,对 brew formula +/// 会 `no such package` 失败(实测案例:`/opt/homebrew/bin/gemini` → `Cellar/gemini-cli/`, +/// 前端给的 `npm rm -g @google/gemini-cli` 跑出来报"not installed")。把卸载命令 +/// 搬到后端、复用 `brew_formula_from_path(real)` 即可正确区分。 +/// +/// **不变量**:与升级路径一致,尽量绝对路径锚定(避免命令拷贝到非登录 shell 里 +/// PATH 缺 `/opt/homebrew/bin` 等用户级目录而 `command not found`)。同目录推不出 +/// brew/volta/bun/pnpm/npm 时退化到裸命令(`brew uninstall ` 等),与 +/// `static_fallback_command` 同款"诚实兜底"语义。`quoted_sibling_or_bare` 封装此模式。 +/// +/// 判定顺序(命中即返回): +/// ① hermes → pip(包名固定,不参与锚定) +/// ② brew formula(真身 `Cellar//`)→ sibling `brew uninstall `; +/// formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli),用 `npm_package_for(pkg)` +/// 会卸错对象。 +/// ③ volta → sibling `volta uninstall `;bun → sibling `bun rm -g `。 +/// ④ pnpm → sibling `pnpm rm -g `。**pnpm 必须独占分支**,不能落到 ⑤ npm 兜底: +/// pnpm 全局包**不在 npm prefix 下**,`npm rm -g` 会报"not installed"必失败。 +/// POSIX 上 `~/.local/share/pnpm/` 是常见形态(review M1 实际命中)。 +/// ⑤ nvm/fnm/mise/homebrew(npm 全局)→ sibling `npm rm -g ` + 物理删除兜底; +/// "homebrew npm 全局"在 ② 之后落到这里——它是 `/opt/homebrew/lib/node_modules/` +/// 下的包(真身**不含 Cellar**),正确卸载就是 `npm rm`,与 nvm 共享同一分支。 +/// ⑥ 其余(system / unknown / scoop / pip 等无可靠锚定的来源)→ 无前缀 `npm rm -g`, +/// 让用户终端 PATH 默认那个 npm 处理。 +#[cfg(not(target_os = "windows"))] +fn uninstall_command_from_paths( + tool: &str, + bin_path: &str, + real_target: &str, + source: &str, +) -> String { + if tool == "hermes" { + return "python3 -m pip uninstall hermes-agent".to_string(); + } + let Some(pkg) = npm_package_for(tool) else { + return String::new(); + }; + if let Some(formula) = brew_formula_from_path(real_target) { + let brew = quoted_sibling_or_bare(bin_path, "brew"); + return format!("{brew} uninstall {formula}"); + } + match source { + "volta" => { + let volta = quoted_sibling_or_bare(bin_path, "volta"); + format!("{volta} uninstall {pkg}") + } + "bun" => { + let bun = quoted_sibling_or_bare(bin_path, "bun"); + format!("{bun} rm -g {pkg}") + } + "pnpm" => { + // 与 ⑤ 分支故意不共享 npm 路径:pnpm 全局 prefix 与 npm 不同 + // (`~/.local/share/pnpm/global` vs `/lib/node_modules`), + // 用 `npm rm` 卸 pnpm 包会"package not installed"。Windows 版 anchored + // upgrade 已有 pnpm 分支,POSIX 卸载端补齐对偶。 + let pnpm = quoted_sibling_or_bare(bin_path, "pnpm"); + format!("{pnpm} rm -g {pkg}") + } + // 自带同级 npm 的 node 管理器:锚定到那处的 npm,保证卸载作用于命令行 + // 实际命中的那个 node。等价类与 anchored_command_from_paths 的 POSIX 版 + // 一一对应,任何一边新增/移除等价类都要同步另一边。 + // + // **附加物理删除兜底(Jason 2026-05-23 实测 bug 修复)**:nvm node v18.20.8 + // 上 `npm rm -g ` 静默 no-op——返回 `up to date in 91ms`、exit 0、 + // 文案像 `npm install`、bin 软链与 `lib/node_modules/` 都没动,用户 + // 照 hint 复制后"以为成功了"但版本没变。可能根因:老 node 的 npm 全局 + // 元数据/package-lock 损坏。 + // + // 兜底形态:` rm -g ; [ -e ] && rm -f && rm -rf ` + // - npm rm 真生效 → bin 已不存在 → `[ -e bin ]` 失败 → 短路,物理删除不触发(幂等) + // - npm rm 静默 no-op → bin 仍在 → 物理删除接管,与"卸载意图"对齐 + // - 命令变长 2-3× 是可接受成本,换用户复制粘贴一次到位 + // + // **rm -rf 红线**:目标是 `/lib/node_modules/` 这一具体 + // 包目录,**不是** `/lib/node_modules` 整个 node_modules。`` + // 是 `@anthropic-ai/claude-code`/`opencode-ai` 等 npm 包全名(含 scope), + // npm 包名规则严格无注入面;但 pkg_dir 算不出(parent_dir 返空)时**不加 + // 兜底**,保留原 `npm rm -g`,避免拼出 `/lib/node_modules/` 这种 + // 误指根目录的命令。 + // + // **本分支专用 `/{bin,lib}` 标准 layout 假设**:nvm/fnm/mise/ + // homebrew(npm 全局)四者都把 node 装到 `/{bin,lib,...}` 标准 + // 布局下,所以 `` 的祖父就是 node 安装根。volta/bun/scoop 的 + // 包目录布局完全不同(volta `~/.volta/tools/image/packages/`、bun + // `~/.bun/install/global/node_modules/`),它们走各自分支不进这里。未来 + // 扩广本分支到新来源时,务必先校验 `/../../lib/node_modules/` + // 假设是否成立。 + "nvm" | "fnm" | "mise" | "homebrew" => { + let npm = quoted_sibling_or_bare(bin_path, "npm"); + // = parent(bin_path), = parent(bin_dir) + // 即 nvm:`~/.nvm/versions/node/v18/bin/gemini` → bin_dir `~/.nvm/.../v18/bin` + // → node_root `~/.nvm/.../v18` → pkg_dir `~/.nvm/.../v18/lib/node_modules/@google/gemini-cli` + let bin_dir = parent_dir(bin_path); + let node_root = parent_dir(&bin_dir); + if node_root.is_empty() { + // parent_dir 推不出 node_root(理论不会发生:bin_path 是绝对路径) → + // 不加兜底,与原行为一致,避免拼错 pkg_dir。 + format!("{npm} rm -g {pkg}") + } else { + let pkg_dir = format!("{node_root}/lib/node_modules/{pkg}"); + // **强 quote 而非 quote_path_if_spaced**(P2 修复):兜底链含 `;` 和 + // `&&` 多语句分隔,bin/pkg_dir 路径若含 `;` / `` ` `` / `$` 等元字符, + // 弱 quoting 会让 shell 拆出额外命令——destructive `rm -rf` 落到错的 + // 路径。`posix_quote_for_user_shell` 白名单判定:常规路径不加引号 + // (展示干净),特殊字符触发 `shell_single_quote` 无条件强 quote。 + let bin_q = posix_quote_for_user_shell(bin_path); + let pkg_dir_q = posix_quote_for_user_shell(&pkg_dir); + format!("{npm} rm -g {pkg}; [ -e {bin_q} ] && rm -f {bin_q} && rm -rf {pkg_dir_q}") + } + } + // system / unknown / scoop / pip 等不期望锚定 npm 的来源: + // **不要拼 `/npm`** (该处通常无 sibling npm,会拼出必失败的命令—— + // 与 anchored_command_from_paths 在同类来源返回 None 的语义对偶)。 + // 退化到无前缀的 `npm rm -g `,让用户终端 PATH 默认的 npm 处理; + // 卸载语义下这比"返回 None 让 UI 不显示命令"更友好,因为这条 hint 本来 + // 就是给用户复制粘贴的——明确意图 + 用户自决比沉默更好。 + _ => format!("npm rm -g {pkg}"), + } +} + +/// Windows 版卸载命令生成。等价类与 anchored upgrade 一致(volta/pnpm/npm 三分支), +/// 无 brew/bun/claude-native(平台不存在)、无 formula 判定路径——`real_target` 占位 +/// 保持与 POSIX 版签名对称;`source` 由调用方传入(见 POSIX 版 doc)。 +/// `quoted_sibling_or_bare_with_ext` 探不到时退化到 exe_basename 裸名。 +/// +/// **quoting 用 `win_quote_path_for_user_shell` 而非 `win_quote_path_for_batch`**: +/// 卸载命令在前端只展示给用户复制到自己的 shell,不经过 .bat + call 的两轮 % +/// expansion——后者把 `%` 转 `%%%%` 是为了让 call 看到字面 `%`,但用户的 cmd / +/// PowerShell / bash 都只一轮甚至零轮展开,直接复制 `%%%%` 会得到错的路径。 +/// 与升级路径(`anchored_command_from_paths` Windows 版用 `win_quote_path_for_batch`, +/// 因为它落 .bat + call)形成"目标终端决定 quoter"的对照,doc 在 helper 一侧 +/// 已经写明,这里不重复。 +/// +/// **目标语法是 cmd / PowerShell 限制**(P3, 2026-05-24):本函数生成的命令是 +/// cmd 兼容形式——含空格路径用双引号包裹(`"C:\Program Files\..."`),PowerShell +/// 对这种引号路径**当字符串字面值不执行**,需要 call operator `&` 前缀。两个 +/// shell 在引号路径上语法不兼容,无法用一份命令同时满足;详见 +/// `win_quote_path_for_user_shell` doc 的 PowerShell 限制段。 +/// +/// **前端 UI 通过 `ToolInstallation.uninstall_command_needs_cmd_hint` 结构化字段 +/// 条件渲染提示**(AboutSection.tsx 卸载行下方, i18n key `toolUninstallPwshHint`)。 +/// 该 bool 由 enumerate 用 `cfg!(target_os = "windows") && contains('"')` 算出, +/// `cfg!` 是编译时 ground truth——POSIX 编译时整体短路为 false,与 POSIX +/// `shell_single_quote` 的 `'"'"'` 转义形态含 `"` 的字面巧合**完全无关**。 +/// **前端不能 string match `uninstall_command`**:POSIX 路径带 `'`(如 +/// macOS `o'leary`)的命令也含 `"`,前端 includes('"') 会误判为 Windows cmd 形式。 +/// +/// **POSIX 兜底物理删除链在 Windows 版**有意不镜像**:实测未观察到 nvm-windows / +/// Scoop 上 `npm.cmd rm -g` 静默 no-op(POSIX 的 npm rm 静默是 nvm node v18.20.8 +/// 老 node 的特定 bug),而 Windows 的物理删除链需要 `del` / `rmdir /S /Q` + `if +/// exist` 三段语义,跨 cmd/PowerShell shell 不一致(`del` 在 PowerShell 是 Remove- +/// Item 别名、参数不同),收益不抵复杂度。若未来在 Windows 上观察到同款 bug,可 +/// 在本函数加 cmd-only 兜底链。 +/// +/// hermes 走 `py -m pip uninstall`,与 `tool_action_shell_command` 的 Windows 分支 +/// 用同一 py launcher 入口。 +#[cfg(target_os = "windows")] +fn uninstall_command_from_paths( + tool: &str, + bin_path: &str, + _real_target: &str, + source: &str, +) -> String { + if tool == "hermes" { + return "py -m pip uninstall hermes-agent".to_string(); + } + let Some(pkg) = npm_package_for(tool) else { + return String::new(); + }; + match source { + "volta" => { + let volta = quoted_sibling_or_bare_with_ext(bin_path, "volta", &["exe", "cmd"]); + format!("{volta} uninstall {pkg}") + } + "pnpm" => { + let pnpm = quoted_sibling_or_bare_with_ext(bin_path, "pnpm", &["cmd", "exe"]); + format!("{pnpm} rm -g {pkg}") + } + _ => { + let npm = quoted_sibling_or_bare_with_ext(bin_path, "npm", &["cmd", "exe"]); + format!("{npm} rm -g {pkg}") + } + } +} + /// Windows 版锚定命令生成。等价类压缩到 3 种(volta/pnpm/npm),不存在 brew/bun/ /// claude-native(Windows 没 Homebrew、Bun for Windows 仍 preview、claude.ai/install.sh /// 是 bash 脚本)。Scoop/Chocolatey/winget/nvm-windows/MS Store node 都归 npm 类—— @@ -2941,6 +3303,27 @@ mod tests { } } + /// `win_quote_path_for_user_shell` 的镜像——卸载命令是给用户复制到自己 shell + /// 跑的,**不做** `%` → `%%%%` 转义(那是 .bat + call 两轮 expansion 的需要, + /// 用户的 cmd 一轮、PowerShell/bash 零轮展开,4 倍转义会让三个 shell 都拿不到 + /// 原路径)。其他 token 边界字符的引号包裹规则与 `expect_quoted_path` 一致, + /// 因为引号在三个 shell 下都保持字面。 + /// + /// 与 `expect_quoted_path` 物理相邻并排放,提醒读者两个 helper **同步**反映 + /// 生产端 `win_quote_path_for_batch` / `win_quote_path_for_user_shell` 那对函数 + /// 的"目标终端决定 quoter"边界——任一边改了,另一边也得跟。 + fn expect_user_shell_quoted_path(p: &str) -> String { + // 不替换 `%`——这正是与 expect_quoted_path 的唯一差异。 + let needs_quote = p + .chars() + .any(|c| matches!(c, ' ' | '&' | '(' | ')' | '^' | ';' | '<' | '>' | '|' | ',')); + if needs_quote { + format!("\"{p}\"") + } else { + p.to_string() + } + } + #[test] fn volta_windows_uses_volta_install() { // tempdir 路径里不含 "volta" 子串,所以在 tempdir 下手建一个 `Volta` 子目录 @@ -3047,6 +3430,40 @@ mod tests { "batch 行不应含未转义的字面 `%foo%`(会被 call 二次解析展开): {batch_line}" ); } + + #[test] + fn windows_uninstall_for_percent_path_keeps_literal_percent() { + // **P3 回归用例**(2026-05-23):卸载命令是给用户复制到自己 shell 跑的, + // **不能复用 anchored upgrade 的 `win_quote_path_for_batch`**——后者把 + // `%` 转 `%%%%` 是因为命令落 .bat + call(两轮 expansion 后还原 `%foo%`), + // 而卸载命令直接渲染给用户复制,cmd 只一轮、PowerShell/bash 零轮展开, + // `%%%%foo%%%%` 在三个 shell 下都拿不到原路径。 + // + // 这条 integration 测试是上面那条 windows_full_batch_line_for_percent_path... + // 的精确对照:同样 `path%foo%` 子目录,升级路径生成 `%%%%foo%%%%`、 + // 卸载路径应保持字面 `%foo%`。两条用例并排锁住"目标终端决定 quoter"边界。 + let (_dir, sub, bin_path) = setup_sibling("path%foo%", "codex.cmd", &["npm.cmd"]); + // tempdir 路径不命中 nvm/homebrew/volta/... 任何前缀 → source = "system", + // 落到 Windows 版 uninstall 的 `_ => npm` 兜底分支(这正是本用例要验证的: + // npm.cmd 同目录被 sibling 探到、anchored 到那处)。 + let source = infer_install_source(std::path::Path::new(&bin_path)); + let uninstall = uninstall_command_from_paths("codex", &bin_path, &bin_path, source); + // 含字面 `%foo%`,**不含** `%%%%`(那是 batch quoter 的痕迹)。 + assert!( + uninstall.contains("path%foo%"), + "卸载命令应保持字面 `path%foo%`(cmd 用户字面识别;PowerShell/bash 也字面但 PowerShell 还需 `&` 前缀执行含引号命令——见 win_quote_path_for_user_shell doc): {uninstall}" + ); + assert!( + !uninstall.contains("%%%%"), + "卸载命令不应含 4 倍 `%` 转义(那是 .bat+call 链路的 quoting): {uninstall}" + ); + // 端到端形态:` rm -g @openai/codex`,npm 路径在 sub 下。 + // sub 可能含空格(取决于 temp 根目录),用 user-shell quoter 镜像算 expected—— + // 与 expect_quoted_path 对偶,锁住"卸载侧不做 % 转义、其他字符引号规则同款"。 + let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy()); + let expected_npm = expect_user_shell_quoted_path(&npm_full); + assert_eq!(uninstall, format!("{expected_npm} rm -g @openai/codex")); + } } /// Windows-only helpers 单测——在 macOS/Linux 上整块通过 cfg 排除,不参与 `cargo test`。 @@ -3133,6 +3550,57 @@ mod tests { assert!(!out.starts_with('"'), "纯 `%` 路径不应加外层引号: {out}"); } + // ─── win_quote_path_for_user_shell:对照 win_quote_path_for_batch ─── + // 关键差异:**不做 `%` 转义**(目标是用户复制到 cmd/PowerShell/bash, + // 不进 .bat + call 的两轮 expansion)。token 边界字符的引号包裹规则 + // 与 batch quoter 一致(三个 shell 下引号都保持字面)。 + + #[test] + fn user_shell_quote_clean_path_stays_bare() { + // 与 batch quoter 同样的"无特殊字符不加引号"语义。 + assert_eq!( + win_quote_path_for_user_shell("C:\\Users\\me\\npm.cmd"), + "C:\\Users\\me\\npm.cmd" + ); + } + + #[test] + fn user_shell_quote_spaced_path_gets_quoted() { + // 空格触发外层引号(防止 shell 拆 token),与 batch quoter 同款。 + assert_eq!( + win_quote_path_for_user_shell("C:\\Program Files\\nodejs\\npm.cmd"), + "\"C:\\Program Files\\nodejs\\npm.cmd\"" + ); + } + + #[test] + fn user_shell_quote_percent_stays_literal_unlike_batch() { + // **回归用例**(P3 反馈):卸载命令是用户复制到自己 shell 跑的, + // **不能用 batch 的 4 倍 % 转义**。对比: + // - win_quote_path_for_batch("C:\\path%foo%\\npm.cmd") + // → "C:\\path%%%%foo%%%%\\npm.cmd" (供 .bat+call 两轮展开后还原 `%foo%`) + // - win_quote_path_for_user_shell("C:\\path%foo%\\npm.cmd") + // → "C:\\path%foo%\\npm.cmd" (保持原 `%`,让用户自决) + // 若用 batch 那个 quoter,粘到 cmd 一轮展开变 `%%`、粘到 PowerShell/bash + // 直接字面 `%%%%`,三个 shell 都拿不到原路径。 + assert_eq!( + win_quote_path_for_user_shell("C:\\path%foo%\\npm.cmd"), + "C:\\path%foo%\\npm.cmd" + ); + } + + #[test] + fn user_shell_quote_percent_with_space_keeps_percent_literal() { + // 与 batch quoter 的 win_quote_percent_with_space_gets_both 形成精确对照: + // - batch: "C:\\my %dir%\\npm.cmd" → "\"C:\\my %%%%dir%%%%\\npm.cmd\"" + // - user_shell: same input → "\"C:\\my %dir%\\npm.cmd\"" + // 空格仍触发引号(token 边界、跨 shell 一致),`%` 保持字面(目标 shell 不确定)。 + assert_eq!( + win_quote_path_for_user_shell("C:\\my %dir%\\npm.cmd"), + "\"C:\\my %dir%\\npm.cmd\"" + ); + } + #[test] fn sibling_bin_picks_first_existing_extension() { // 同目录同时存在 `npm.cmd` 和 `npm.exe` 时,候选顺序 `[cmd, exe]` 应取 .cmd—— @@ -3273,6 +3741,13 @@ mod tests { use super::super::*; use std::path::Path; + /// 测试 helper:跟生产代码 `enumerate_tool_installations` 同款契约——从 bin 路径 + /// 推 source 标签,再传给 `uninstall_command_from_paths`。封一层让 uninstall_* + /// 用例的 4 参数调用形态简洁。 + fn src(bin: &str) -> &'static str { + infer_install_source(Path::new(bin)) + } + fn inst(path: &str, is_default: bool) -> ToolInstallation { ToolInstallation { path: path.to_string(), @@ -3281,6 +3756,10 @@ mod tests { error: None, source: infer_install_source(Path::new(path)).to_string(), is_path_default: is_default, + // 升级锚定测试不关心 uninstall_command(那条由 uninstall_* 用例覆盖); + // 填空串避免与本组用例的"升级命令应该是什么"互相干扰。 + uninstall_command: String::new(), + uninstall_command_needs_cmd_hint: false, // 测试场景下不需要走 fs canonicalize——POSIX 锚定测试关心的是 // path/real 都被传给 anchored_command_from_paths 的纯字符串判定, // 已有用例(brew_formula_extraction / claude_native_*)是直接 @@ -3518,6 +3997,307 @@ mod tests { ); } + // ─── uninstall command (bug fix: 卸载命令的 source-aware 锚定) ─── + // 这组测试与上面 anchored upgrade 是对偶关系——同一组真身判定要在卸载侧 + // 也产出正确命令。先前卸载命令在前端拼,前端只拿到 `source` 字符串,无法 + // 区分 brew formula vs homebrew npm 全局包,把所有 `/opt/homebrew/bin/*` + // 默认拼成 `npm rm -g`,对 brew formula 必然 `no such package` 失败。 + // 修复后卸载在后端拼,复用 brew_formula_from_path + sibling_bin。 + + #[test] + fn brew_formula_uninstall_uses_brew_uninstall() { + // 关键回归用例:Jason 2026-05-23 实测的 bug 现场—— + // /opt/homebrew/bin/gemini 真身在 /Cellar/gemini-cli/,前端给出 + // `npm rm -g @google/gemini-cli` 会失败。修复后应该是 brew uninstall。 + // formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli), + // 用 npm 包名做参数会卸错对象。 + let bin = "/opt/homebrew/bin/gemini"; + let cmd = uninstall_command_from_paths( + "gemini", + bin, + "/opt/homebrew/Cellar/gemini-cli/0.43.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js", + src(bin), + ); + assert_eq!(cmd, "/opt/homebrew/bin/brew uninstall gemini-cli"); + } + + #[test] + fn homebrew_npm_global_uninstall_uses_npm_rm() { + // openclaw 装在 Homebrew node 的全局目录(lib/node_modules,**非 Cellar**) + // 是 npm 全局包,应该走 npm rm -g 而不是 brew uninstall。 + // 这条 case 锁住"homebrew formula vs homebrew npm 全局"的边界—— + // 两者前端 source 都是 "homebrew",必须靠后端真身判定区分。 + // + // 命令尾巴附加物理删除兜底:`npm rm -g` 在某些环境(老 node 损坏 npm + // 元数据)静默 no-op,`[ -e bin ] && rm -f bin && rm -rf pkg_dir` 兜底 + // 让真正不生效时仍能清理。详见 uninstall_command_from_paths doc。 + let bin = "/opt/homebrew/bin/openclaw"; + let cmd = uninstall_command_from_paths( + "openclaw", + bin, + "/opt/homebrew/lib/node_modules/openclaw/dist/cli.js", + src(bin), + ); + assert_eq!( + cmd, + concat!( + "/opt/homebrew/bin/npm rm -g openclaw", + "; [ -e /opt/homebrew/bin/openclaw ]", + " && rm -f /opt/homebrew/bin/openclaw", + " && rm -rf /opt/homebrew/lib/node_modules/openclaw", + ) + ); + } + + #[test] + fn nvm_uninstall_anchors_to_that_npm() { + // nvm node18 v18.20.8 装的 @google/gemini-cli 0.1.7——Jason 实测的 + // 第二处安装。卸载必须锚定到 v18.20.8 下的 npm,避免误删 v22.14.0 的。 + // + // **这条用例是 npm rm 静默 no-op bug 的关键回归现场**:Jason 实测 + // 此机型上 `npm rm -g @google/gemini-cli` 返回 `up to date in 91ms` + // exit 0 不动包目录。命令尾巴的物理删除兜底专门防御此 case。 + let bin = "/Users/me/.nvm/versions/node/v18.20.8/bin/gemini"; + let cmd = uninstall_command_from_paths( + "gemini", + bin, + "/Users/me/.nvm/versions/node/v18.20.8/lib/node_modules/@google/gemini-cli/dist/index.js", + src(bin), + ); + assert_eq!( + cmd, + concat!( + "/Users/me/.nvm/versions/node/v18.20.8/bin/npm rm -g @google/gemini-cli", + "; [ -e /Users/me/.nvm/versions/node/v18.20.8/bin/gemini ]", + " && rm -f /Users/me/.nvm/versions/node/v18.20.8/bin/gemini", + " && rm -rf /Users/me/.nvm/versions/node/v18.20.8/lib/node_modules/@google/gemini-cli", + ) + ); + } + + #[test] + fn npm_uninstall_fallback_quotes_spaced_paths() { + // 兜底链里有 3 处路径(npm / bin / pkg_dir),含空格时 3 处都要单引号包裹, + // 否则 `[ -e /Users/my name/.../bin/gemini ]` 会被 shell 拆成多 token。 + // 锁住 quote_path_if_spaced 应用在兜底所有路径上,而非只 npm 头部。 + let bin = "/Users/my name/.nvm/versions/node/v22/bin/gemini"; + let cmd = uninstall_command_from_paths( + "gemini", + bin, + "/Users/my name/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli/dist/index.js", + src(bin), + ); + assert_eq!( + cmd, + concat!( + "'/Users/my name/.nvm/versions/node/v22/bin/npm' rm -g @google/gemini-cli", + "; [ -e '/Users/my name/.nvm/versions/node/v22/bin/gemini' ]", + " && rm -f '/Users/my name/.nvm/versions/node/v22/bin/gemini'", + " && rm -rf '/Users/my name/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli'", + ) + ); + } + + #[test] + fn npm_uninstall_fallback_strong_quotes_shell_metachars() { + // **P2 回归用例**:卸载命令是 destructive(`rm -rf `),路径含 + // `;` / `'` / `$` / `` ` `` 等 shell 元字符时必须强 quote——否则 + // `[ -e /Users/foo;bad/.../gemini ]` 会被 shell 拆成 `[ -e /Users/foo]` + // + `bad/.../gemini ] && rm -rf ...`,destructive 命令落到错的路径。 + // `quote_path_if_spaced` 只查空格,本用例确保升级到 + // `posix_quote_for_user_shell`(白名单)后,危险字符触发 `shell_single_quote`。 + // + // 用 `;` 作为代表性元字符——所有非 `[A-Za-z0-9._/+=:-]` 字符都走同一 + // shell_single_quote 路径,锁一个就够覆盖白名单切换语义。 + let bin = "/Users/foo;bar/.nvm/versions/node/v22/bin/gemini"; + let cmd = uninstall_command_from_paths( + "gemini", + bin, + "/Users/foo;bar/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli/dist/index.js", + src(bin), + ); + // npm/bin/pkg_dir 三处都被单引号包裹(shell_single_quote 的形态)。 + // 这条 assert_eq 锁住"含 `;` 路径**所有**位置都强 quote",任一漏掉 + // 都会让 shell 拆词、可能 destructive。 + assert_eq!( + cmd, + concat!( + "'/Users/foo;bar/.nvm/versions/node/v22/bin/npm' rm -g @google/gemini-cli", + "; [ -e '/Users/foo;bar/.nvm/versions/node/v22/bin/gemini' ]", + " && rm -f '/Users/foo;bar/.nvm/versions/node/v22/bin/gemini'", + " && rm -rf '/Users/foo;bar/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli'", + ) + ); + } + + #[test] + fn npm_uninstall_fallback_quotes_single_quote_in_path() { + // 路径含字面 `'`——`shell_single_quote` 的 `'"'"'` 转义形式必须能 round-trip。 + // 形态:`o'leary` → `'o'"'"'leary'`(关单引号 + 双引号包字面单引号 + 重开 + // 单引号),跨 bash/zsh/dash/sh 全部 POSIX 兼容。本用例在卸载兜底链上下文 + // 验证 npm/bin/pkg_dir 三处都正确 round-trip,防止未来"换 quoter"重构时 + // 漏掉单引号场景(单引号是最棘手的 shell quoting 边界)。 + // + // **关键 ToolInstallation.uninstall_command_needs_cmd_hint 边界**(reviewer + // 2026-05-24 抓的 false-positive):此命令含 `"`(escape 的一部分),但 + // 它是 **POSIX shell_single_quote 形态**,**不是** Windows cmd 引号路径。 + // 前端 `cmd.includes('"')` 在此场景会**误判**为 Windows cmd 形式, + // 给 macOS 用户名带 `'`(如 `o'leary`)的用户错误显示 PowerShell 提示。 + // 这就是为什么 `uninstall_command_needs_cmd_hint` 必须由后端用 + // `cfg!(target_os = "windows") && contains('"')` 算——`cfg!` 是编译时 + // ground truth, POSIX 编译时整体短路 false, 与含 `"` 的字面巧合无关。 + // 本测试的存在 = 字面 contains('"') 永远 true, cfg 短路是唯一防线。 + let bin = "/Users/o'leary/.nvm/versions/node/v22/bin/gemini"; + let cmd = uninstall_command_from_paths( + "gemini", + bin, + "/Users/o'leary/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli/dist/index.js", + src(bin), + ); + // 验证三处都得到同款 `'"'"'` 转义形态。 + assert!( + cmd.contains("'/Users/o'\"'\"'leary/.nvm/versions/node/v22/bin/npm'"), + "npm 路径单引号转义不对: {cmd}" + ); + assert!( + cmd.contains("'/Users/o'\"'\"'leary/.nvm/versions/node/v22/bin/gemini'"), + "bin 路径单引号转义不对: {cmd}" + ); + assert!( + cmd.contains("'/Users/o'\"'\"'leary/.nvm/versions/node/v22/lib/node_modules/@google/gemini-cli'"), + "pkg_dir 路径单引号转义不对: {cmd}" + ); + // 显式断言:命令字面**确实**含 `"`——这是上面 doc 关键边界的存在证明。 + // 此 assert 让"前端 string match 是 false-positive"的论据有了不可绕过的 + // 测试支撑(未来若 shell_single_quote 改用别的 escape 形式,本断言会失败、 + // 触发 reviewer 评估是否还需要 cfg 防御层)。 + assert!( + cmd.contains('"'), + "POSIX shell_single_quote 转义形态必须含 `\"`(本用例的存在前提): {cmd}" + ); + } + + #[test] + fn npm_uninstall_fallback_only_for_anchored_branches() { + // 兜底链**只在 nvm/fnm/mise/homebrew npm 全局四个分支出现**(那里 bin 与 + // pkg_dir 都能可靠推导)。其他分支不应该带兜底: + // - brew formula: brew uninstall 没观察到 no-op + // - volta: volta uninstall 没观察到 no-op + // - bun: bun rm 没观察到 no-op + // - system/unknown: 路径不可靠,**不要拼出可能误指根目录的 rm -rf** + // 这条用例反向锁住——任意分支若误带兜底,会被这里 catch。 + // + // **不包含 hermes** :hermes 在函数开头就 early return pip uninstall,根本 + // 不进 source match 分支,断言对 pip uninstall 是 vacuously true。hermes + // 的"不进 npm 分支"已由 hermes_uninstall_uses_pip 用例独立锁住,这里再放 + // 一行只会误导读者(让人以为本用例在验证"hermes 走 npm 分支也不带兜底")。 + for (tool, bin, real) in [ + ("gemini", "/opt/homebrew/bin/gemini", + "/opt/homebrew/Cellar/gemini-cli/0.43.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js"), + ("codex", "/Users/me/.volta/bin/codex", + "/Users/me/.volta/tools/image/packages/@openai/codex/lib/cli.js"), + ("opencode", "/Users/me/.bun/bin/opencode", + "/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode"), + ("opencode", "/Users/me/.opencode/bin/opencode", "/Users/me/.opencode/bin/opencode"), + ] { + let cmd = uninstall_command_from_paths(tool, bin, real, src(bin)); + assert!( + !cmd.contains("rm -rf"), + "tool={tool} bin={bin}: 该分支不应含 rm -rf 兜底,实际生成: {cmd}" + ); + assert!( + !cmd.contains("[ -e "), + "tool={tool} bin={bin}: 该分支不应含 `[ -e ]` 测试,实际生成: {cmd}" + ); + } + } + + #[test] + fn volta_uninstall_uses_volta_uninstall() { + let bin = "/Users/me/.volta/bin/codex"; + let cmd = uninstall_command_from_paths( + "codex", + bin, + "/Users/me/.volta/tools/image/packages/@openai/codex/lib/cli.js", + src(bin), + ); + assert_eq!(cmd, "/Users/me/.volta/bin/volta uninstall @openai/codex"); + } + + #[test] + fn bun_uninstall_uses_bun_rm() { + let bin = "/Users/me/.bun/bin/opencode"; + let cmd = uninstall_command_from_paths( + "opencode", + bin, + "/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode", + src(bin), + ); + assert_eq!(cmd, "/Users/me/.bun/bin/bun rm -g opencode-ai"); + } + + #[test] + fn pnpm_uninstall_uses_pnpm_rm() { + // **review M1 回归用例**:pnpm 全局包不在 npm prefix 下,落到 npm rm -g + // 会"not installed"必失败。Windows anchored upgrade 早已有 pnpm 分支, + // POSIX 卸载补齐对偶——`~/.local/share/pnpm/` 是 pnpm 在 macOS/Linux + // 上的标准全局 bin 形态,被 infer_install_source 归为 "pnpm" 类。 + let bin = "/Users/me/.local/share/pnpm/codex"; + let cmd = uninstall_command_from_paths( + "codex", + bin, + "/Users/me/.local/share/pnpm/global/5/node_modules/@openai/codex/dist/cli.js", + src(bin), + ); + assert_eq!(cmd, "/Users/me/.local/share/pnpm/pnpm rm -g @openai/codex"); + } + + #[test] + fn hermes_uninstall_uses_pip() { + // hermes 走 pip,与锚定无关——包名固定 hermes-agent。 + let bin = "/Users/me/.local/bin/hermes"; + let cmd = uninstall_command_from_paths( + "hermes", + bin, + "/Users/me/.local/bin/hermes", + src(bin), + ); + assert_eq!(cmd, "python3 -m pip uninstall hermes-agent"); + } + + #[test] + fn brew_uninstall_path_with_space_is_quoted() { + // brew 分支用 `/brew`,目录含空格时同样要引号包裹, + // 否则 `/opt/my brew/bin/brew uninstall gemini-cli` 会被 shell 拆词 + // 当成两个命令。镜像 anchored upgrade 的 brew_path_with_space_is_quoted。 + let bin = "/opt/my brew/bin/gemini"; + let cmd = uninstall_command_from_paths( + "gemini", + bin, + "/opt/my brew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js", + src(bin), + ); + assert_eq!(cmd, "'/opt/my brew/bin/brew' uninstall gemini-cli"); + } + + #[test] + fn system_install_uninstall_falls_back_to_bare_npm_rm() { + // ~/.opencode/bin / ~/go/bin / ~/.local/bin 等独立安装无 sibling npm + // (与 anchored upgrade 在同类来源返回 None 的语义对偶)。 + // **关键**:不要拼 `/npm rm -g `——该处无 npm 文件, + // 拼出来必失败。退化到**无前缀** `npm rm -g `,让用户终端 PATH + // 默认的 npm 处理;卸载语义下"不锚定但明确意图"比"返回 None 让 UI + // 不显示命令"更友好(这条 hint 本来就是给用户复制粘贴的)。 + let bin = "/Users/me/.opencode/bin/opencode"; + let cmd = uninstall_command_from_paths( + "opencode", + bin, + "/Users/me/.opencode/bin/opencode", + src(bin), + ); + assert_eq!(cmd, "npm rm -g opencode-ai"); + } + #[test] fn sibling_bin_returns_none_when_bin_path_has_no_directory() { // bin_path 不含 `/` → parent_dir 返回空 → sibling_bin 不能拼出绝对路径 @@ -3590,6 +4370,8 @@ mod tests { error: None, source: "nvm".to_string(), is_path_default: false, + uninstall_command: String::new(), + uninstall_command_needs_cmd_hint: false, real: std::path::PathBuf::from("/x"), }; // 单处 → 不冲突。 diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index cfdaa1e32..d66b8400e 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -142,57 +142,11 @@ const TOOL_APP_IDS: Record = { hermes: "hermes", }; -// 各工具的全局包名:npm 系用 npm 包名,hermes 例外(pip 包 hermes-agent)。 -const TOOL_NPM_PACKAGES: Record, string> = { - claude: "@anthropic-ai/claude-code", - codex: "@openai/codex", - gemini: "@google/gemini-cli", - opencode: "opencode-ai", - openclaw: "openclaw", -}; - -// 取路径的目录部分(兼容 / 与 \ 分隔符);无分隔符返回空串。 -function dirOfPath(p: string): string { - const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); - return i > 0 ? p.slice(0, i) : ""; -} - -// 路径是否为 Windows 风格(含反斜杠或盘符前缀)。 -function isWindowsPath(p: string): boolean { - return p.includes("\\") || /^[a-zA-Z]:/.test(p); -} - -// 含空格时加引号,避免命令被 shell 拆断。 -function shellQuote(s: string): string { - return s.includes(" ") ? `"${s}"` : s; -} - -// 为「某一处具体安装」生成卸载建议命令(只读、供复制,绝不代执行)。 -// 关键:用该处同目录的 npm 精确作用于这一处的 node 安装,避免裸 `npm rm -g` -// 误删了当前激活(可能是想保留的默认)那处。volta/bun/pip 走各自的卸载器。 -function buildUninstallCommand( - toolName: ToolName, - inst: ToolInstallation, -): string { - if (toolName === "hermes") { - return "python3 -m pip uninstall hermes-agent"; - } - const pkg = TOOL_NPM_PACKAGES[toolName]; - if (inst.source === "volta") { - return `volta uninstall ${pkg}`; - } - if (inst.source === "bun") { - return `bun rm -g ${pkg}`; - } - // nvm / fnm / mise / homebrew / system / scoop 上的 node 全局包:统一 npm rm -g, - // 但锚定到该处同目录的 npm 以删对版本。 - const dir = dirOfPath(inst.path); - const win = isWindowsPath(inst.path); - const npmBin = win ? "npm.cmd" : "npm"; - const npm = dir ? shellQuote(`${dir}${win ? "\\" : "/"}${npmBin}`) : "npm"; - return `${npm} rm -g ${pkg}`; -} - +// 卸载命令历史上在前端按 inst.source 拼,但 `source` 把 brew formula 和 homebrew npm +// 全局包都归为 "homebrew"——前端无法区分两者(formula 真身在 /Cellar/、npm 全局在 +// /opt/homebrew/lib/node_modules/),会给 brew formula 错拼 `npm rm -g` 必失败。 +// 现已迁回后端 uninstall_command_from_paths,复用 brew_formula_from_path 真身判定; +// 这里只读 inst.uninstall_command,不再前端拼。 export function AboutSection({ isPortable }: AboutSectionProps) { // ... (use hooks as before) ... const { t } = useTranslation(); @@ -1081,10 +1035,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {

    {conflicts.map((inst) => { - const uninstallCmd = buildUninstallCommand( - toolName, - inst, - ); + const uninstallCmd = inst.uninstall_command; return (
  • @@ -1104,6 +1055,17 @@ export function AboutSection({ isPortable }: AboutSectionProps) { + {/* PowerShell 限制提示:用后端结构化字段而非前端 string match。 + **不能用 uninstallCmd.includes('"')**:POSIX 的 + shell_single_quote 转义是 `'"'"'` 也含 `"`,含 `'` 的 POSIX + 路径会被误判为 Windows cmd 形式。后端用 + `cfg!(target_os = "windows") && contains('"')` 算这个 bit, + POSIX 编译时短路 false,前端只读 bool。 */} + {inst.uninstall_command_needs_cmd_hint && ( +

    + {t("settings.toolUninstallPwshHint")} +

    + )}
  • ); })} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 35801b508..2fb85c294 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -686,6 +686,7 @@ "toolDiagnoseFailed": "Diagnosis failed", "toolUninstallCopyHint": "Copy uninstall command (won't run it)", "toolUninstallCopied": "Uninstall command copied", + "toolUninstallPwshHint": "Built for Command Prompt (cmd). PowerShell users: prefix the command with `&` (call operator), otherwise the quoted path is treated as a string literal.", "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:", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0e5eda5c6..07ba69e89 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -686,6 +686,7 @@ "toolDiagnoseFailed": "診断に失敗しました", "toolUninstallCopyHint": "アンインストールコマンドをコピー(自動実行しません)", "toolUninstallCopied": "アンインストールコマンドをコピーしました", + "toolUninstallPwshHint": "コマンドプロンプト (cmd) 用です。PowerShell ではコマンド先頭に `&`(呼び出し演算子)を付ける必要があります。付けないと引用符付きパスが文字列リテラルとして扱われます。", "toolUpgradeConfirmTitle": "アップグレード先の確認", "toolUpgradeConfirmHint": "複数のインストールを検出しました。今回のアップグレードはすべてを更新するわけではありません。実際に作用する箇所は下記の各項目をご確認ください。", "toolUpgradeWillRun": "実行内容:", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ae9d8e223..ee54054da 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -686,6 +686,7 @@ "toolDiagnoseFailed": "诊断失败", "toolUninstallCopyHint": "复制卸载命令(不会自动执行)", "toolUninstallCopied": "卸载命令已复制", + "toolUninstallPwshHint": "适用于命令提示符 (cmd)。PowerShell 用户需在命令前加 `&`(call 运算符),否则带引号的路径会被当作字符串字面值。", "toolUpgradeConfirmTitle": "确认升级位置", "toolUpgradeConfirmHint": "检测到多处安装。本次升级不会全部更新,具体作用的位置以下方各项为准。", "toolUpgradeWillRun": "将执行:", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 45e77806a..fa6fd01e0 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -249,6 +249,20 @@ export interface ToolInstallation { error: string | null; source: string; is_path_default: boolean; + /** 后端拼好的卸载命令(仅展示给用户复制,UI 不代执行)。source-aware: + * brew formula → brew uninstall、volta → volta uninstall、bun → bun rm -g、 + * 其余 npm 全局 → 锚定该处同目录 npm 的 npm rm -g。 + * 此前前端按 source 字符串自己拼,无法区分 brew formula vs homebrew npm 全局包, + * 会给 brew formula 错拼 `npm rm -g`。现统一在后端用 brew_formula_from_path 的 + * 真身判定生成,与升级路径共享判定逻辑。 */ + uninstall_command: string; + /** 该卸载命令是否是 Windows cmd 兼容形式(含 quoted 路径,PowerShell 用户需 `&` + * call operator 前缀)。前端据此条件渲染一行 PowerShell 限制提示。 + * **必须由后端给出**:POSIX `shell_single_quote` 转义形式 `'"'"'` 含双引号, + * 前端 `cmd.includes('"')` 会把 POSIX 路径带 `'` 的用户(macOS `o'leary`) + * 误判为 Windows cmd 形式。后端用 `cfg!(target_os = "windows") && contains('"')` + * 算,POSIX 编译时整体短路为 false,精确锁定触发域。 */ + uninstall_command_needs_cmd_hint: boolean; } /** 一次"探测工具安装分布"的结果。字段对应后端 ToolInstallationReport。 */