feat(settings): backend-generated source-aware uninstall command hints

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 <pkg_dir>`), 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 <bin> ] && rm -f <bin> && rm -rf <pkg_dir>` 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 `<node_root>/{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.
This commit is contained in:
Jason
2026-05-24 18:55:35 +08:00
Unverified
parent 671859748d
commit 3a77861dd9
6 changed files with 819 additions and 58 deletions
+17 -55
View File
@@ -142,57 +142,11 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
hermes: "hermes",
};
// 各工具的全局包名:npm 系用 npm 包名,hermes 例外(pip 包 hermes-agent)。
const TOOL_NPM_PACKAGES: Record<Exclude<ToolName, "hermes">, 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) {
</p>
<ul className="space-y-1.5">
{conflicts.map((inst) => {
const uninstallCmd = buildUninstallCommand(
toolName,
inst,
);
const uninstallCmd = inst.uninstall_command;
return (
<li key={inst.path} className="space-y-1">
<ToolInstallRow inst={inst} />
@@ -1104,6 +1055,17 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<Copy className="h-3 w-3" />
</button>
</div>
{/* 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 && (
<p className="text-[10px] leading-snug text-muted-foreground/80">
{t("settings.toolUninstallPwshHint")}
</p>
)}
</li>
);
})}
+1
View File
@@ -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:",
+1
View File
@@ -686,6 +686,7 @@
"toolDiagnoseFailed": "診断に失敗しました",
"toolUninstallCopyHint": "アンインストールコマンドをコピー(自動実行しません)",
"toolUninstallCopied": "アンインストールコマンドをコピーしました",
"toolUninstallPwshHint": "コマンドプロンプト (cmd) 用です。PowerShell ではコマンド先頭に `&`(呼び出し演算子)を付ける必要があります。付けないと引用符付きパスが文字列リテラルとして扱われます。",
"toolUpgradeConfirmTitle": "アップグレード先の確認",
"toolUpgradeConfirmHint": "複数のインストールを検出しました。今回のアップグレードはすべてを更新するわけではありません。実際に作用する箇所は下記の各項目をご確認ください。",
"toolUpgradeWillRun": "実行内容:",
+1
View File
@@ -686,6 +686,7 @@
"toolDiagnoseFailed": "诊断失败",
"toolUninstallCopyHint": "复制卸载命令(不会自动执行)",
"toolUninstallCopied": "卸载命令已复制",
"toolUninstallPwshHint": "适用于命令提示符 (cmd)。PowerShell 用户需在命令前加 `&`(call 运算符),否则带引号的路径会被当作字符串字面值。",
"toolUpgradeConfirmTitle": "确认升级位置",
"toolUpgradeConfirmHint": "检测到多处安装。本次升级不会全部更新,具体作用的位置以下方各项为准。",
"toolUpgradeWillRun": "将执行:",
+14
View File
@@ -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。 */