mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -686,6 +686,7 @@
|
||||
"toolDiagnoseFailed": "診断に失敗しました",
|
||||
"toolUninstallCopyHint": "アンインストールコマンドをコピー(自動実行しません)",
|
||||
"toolUninstallCopied": "アンインストールコマンドをコピーしました",
|
||||
"toolUninstallPwshHint": "コマンドプロンプト (cmd) 用です。PowerShell ではコマンド先頭に `&`(呼び出し演算子)を付ける必要があります。付けないと引用符付きパスが文字列リテラルとして扱われます。",
|
||||
"toolUpgradeConfirmTitle": "アップグレード先の確認",
|
||||
"toolUpgradeConfirmHint": "複数のインストールを検出しました。今回のアップグレードはすべてを更新するわけではありません。実際に作用する箇所は下記の各項目をご確認ください。",
|
||||
"toolUpgradeWillRun": "実行内容:",
|
||||
|
||||
@@ -686,6 +686,7 @@
|
||||
"toolDiagnoseFailed": "诊断失败",
|
||||
"toolUninstallCopyHint": "复制卸载命令(不会自动执行)",
|
||||
"toolUninstallCopied": "卸载命令已复制",
|
||||
"toolUninstallPwshHint": "适用于命令提示符 (cmd)。PowerShell 用户需在命令前加 `&`(call 运算符),否则带引号的路径会被当作字符串字面值。",
|
||||
"toolUpgradeConfirmTitle": "确认升级位置",
|
||||
"toolUpgradeConfirmHint": "检测到多处安装。本次升级不会全部更新,具体作用的位置以下方各项为准。",
|
||||
"toolUpgradeWillRun": "将执行:",
|
||||
|
||||
@@ -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。 */
|
||||
|
||||
Reference in New Issue
Block a user