mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(settings): self-update first chain, switch hermes to native installer
Promote anchored upgrades to "<bin> update || <package fallback>" for the
tools whose CLI knows how to self-update safely, and replace pip-based
hermes install/update with the official NousResearch installer scripts.
Frontend learns to detect "update reported success but version did not
change" so silent upstream no-ops surface as a soft warning.
Backend (src-tauri/src/commands/misc.rs):
- New prefers_official_update(tool, shell) per-platform allow-list:
POSIX {claude, codex, opencode, openclaw}. Windows drops opencode
pending anomalyco/opencode#17295 (its silent `upgrade` may prompt for
install-source detection and deadlock our lifecycle). gemini is absent
from both because `gemini update` is not implemented upstream
(google-gemini/gemini-cli#18618 / #16122 OPEN).
- New chain_update_commands joins primary/fallback per shell: POSIX
`||`, Windows `|| call` — batch transfers control to the `||` RHS
without `call`, skipping subsequent tools in multi-tool actions.
- New LifecycleCommandShell {Posix, WindowsBatch} threads the target
shell through tool_action_shell_command_for_shell, replacing the old
hermes-only WSL substitution hack with a uniform mechanism.
- anchored_command_from_paths refactored: hermes anchors to
`<bin> update`; tools in prefers_official_update chain self-update
with the package-manager command as fallback; brew formulae remain
brew-managed (never self-update what Homebrew owns); others use the
prior npm/volta/bun/pnpm anchoring unchanged. Mirror on Windows.
- Hermes commands switch from pip to the official scripts. POSIX/WSL
runs `bash -c 'tmp=\$(mktemp) && curl -fsSL .../install.sh -o \$tmp
&& bash \$tmp; status=\$?; rm -f \$tmp; exit \$status'`. The mktemp +
two-step download avoids `curl | bash` because WSL sub-shells inherit
neither outer `set -o pipefail` nor a guaranteed bash (default may be
dash/ash). Windows uses `powershell -NoProfile -ExecutionPolicy Bypass
-EncodedCommand <b64>` with a hand-rolled UTF-16 LE base64 encoder
for `irm .../install.ps1 | iex`, hiding the PowerShell pipe from
cmd.exe. Pip is abandoned because macOS system python3 is often 3.9
while hermes-agent requires >=3.11, and the pyenv `python` shim may
not exist — pip errors then misclassify as "command not found".
Frontend (src/components/settings/AboutSection.tsx):
- New versionUnchangedAfterUpdate four-AND short-circuit detects when
the upgrade command succeeded, the tool still reports a version, but
that version equals the pre-upgrade version while a different
latest_version is known. Guards against upstream no-op updaters that
return exit 0 without applying anything (openai/codex#21897).
- Soft-failure shape gains kind?: "notRunnable" | "versionUnchanged"
so the warning toast title can disambiguate the two cases.
- Hermes install command snippet swapped from `python3 -m pip ...` to
the official curl-bash one-liner shown to users.
i18n (zh / en / ja):
- New settings.toolActionVersionUnchangedTitle and
settings.toolActionVersionUnchanged, synchronized across all locales.
Tests (src-tauri/src/commands/misc.rs):
- Anchored upgrade assertions updated to `<bin> update || <pkg>` shape
across nvm / volta / bun / homebrew-npm-global / spaces / fnm
branches; brew formula explicitly does NOT chain self-update.
- New hermes anchor tests replace hermes_has_no_npm_anchor on both
POSIX and Windows. WSL hermes tests rewritten for the mktemp+bash
flow with `hermes update` fallback.
- Reverse-lock tests: gemini static fallback must NOT contain
`gemini update`; opencode Windows static fallback must NOT contain
`opencode upgrade`; hermes commands must NOT contain
`python`/`pip`/`powershell`/forbidden pipes.
Validated:
- cargo check --tests: clean
- pnpm typecheck: 0 errors
- cargo fmt: clean
This commit is contained in:
+545
-162
@@ -356,40 +356,152 @@ fn tool_display_name(tool: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// hermes pip 升级命令的 Unix shell 版本:`python3` 优先 + `python` 兜底。
|
||||
/// Non-Windows target 直接用;Windows target 上的 WSL 分支也用——WSL 内部跑的是
|
||||
/// Linux,**不存在 Windows 的 `py` launcher**(Microsoft PEP 397 是 Windows 独有),
|
||||
/// Ubuntu 24.04+ 默认连 `python` alias 都没有、只有 `python3`。
|
||||
const HERMES_PIP_UPGRADE_UNIX: &str =
|
||||
"python3 -m pip install --upgrade \"hermes-agent[web]\" || python -m pip install --upgrade \"hermes-agent[web]\"";
|
||||
/// Hermes 官方安装器会自带/选择合适的 Python 运行时。不要再用
|
||||
/// `python3 -m pip ... || python -m pip ...`:Hermes PyPI 包要求 Python >=3.11,
|
||||
/// 但 macOS 系统 `python3` 常是 3.9,而 pyenv 下 `python` shim 还可能不存在,会把
|
||||
/// 真正的 Python 版本问题盖成 "python command exists in these Python versions"。
|
||||
///
|
||||
/// 这里也刻意不用 `curl | bash` 这种 shell pipe 形式(仍然用 curl 下载,只是改成
|
||||
/// 先落到临时文件再交给 bash 执行):WSL 分支会在 `wsl.exe ... -- sh -c "<cmd>"`
|
||||
/// 子 shell 里执行命令,外层脚本的 `set -o pipefail` 不会继承进去;而 WSL 默认
|
||||
/// shell 可能是 dash/ash,也不能假设支持 `set -o pipefail`。先下载到 mktemp 文件再
|
||||
/// 交给 bash,能让 curl 失败稳定变成整条命令失败。
|
||||
const HERMES_INSTALL_UNIX: &str =
|
||||
"bash -c 'tmp=$(mktemp) && curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
|
||||
const HERMES_UPDATE_UNIX: &str =
|
||||
"hermes update || bash -c 'tmp=$(mktemp) && curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
|
||||
|
||||
fn tool_action_shell_command(tool: &str, _action: ToolLifecycleAction) -> Option<&'static str> {
|
||||
#[cfg(target_os = "windows")]
|
||||
const HERMES_INSTALL_WINDOWS_SCRIPT: &str =
|
||||
"irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn powershell_encoded_command(script: &str) -> String {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
|
||||
let mut bytes = Vec::with_capacity(script.len() * 2);
|
||||
for unit in script.encode_utf16() {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn hermes_install_windows_command() -> String {
|
||||
format!(
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {}",
|
||||
powershell_encoded_command(HERMES_INSTALL_WINDOWS_SCRIPT)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn hermes_update_windows_command() -> String {
|
||||
// fallback 是 powershell.exe,不是 .cmd/.bat;这里不需要 `call`。PowerShell 的
|
||||
// `irm | iex` 已被 EncodedCommand 收进单一参数,避免 `cmd.exe` 解析管道符。
|
||||
format!("hermes update || {}", hermes_install_windows_command())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum LifecycleCommandShell {
|
||||
Posix,
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
WindowsBatch,
|
||||
}
|
||||
|
||||
fn npm_install_command_for(tool: &str) -> Option<&'static str> {
|
||||
match tool {
|
||||
"claude" => Some("npm i -g @anthropic-ai/claude-code@latest"),
|
||||
"codex" => Some("npm i -g @openai/codex@latest"),
|
||||
"gemini" => Some("npm i -g @google/gemini-cli@latest"),
|
||||
"opencode" => Some("npm i -g opencode-ai@latest"),
|
||||
"openclaw" => Some("npm i -g openclaw@latest"),
|
||||
#[cfg(target_os = "windows")]
|
||||
"hermes" => Some(
|
||||
"py -m pip install --upgrade \"hermes-agent[web]\" || python -m pip install --upgrade \"hermes-agent[web]\"",
|
||||
),
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
"hermes" => Some(HERMES_PIP_UPGRADE_UNIX),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows host 上的 WSL 分支专用:`tool_action_shell_command` 在 Windows target 编译
|
||||
/// 出的版本对 hermes 返回的是 Windows 的 `py -m pip ...`,但跨 `wsl.exe` 边界后跑的
|
||||
/// 是 Linux——`py` launcher 是 Windows 独有,执行会 fail。这个 wrapper 把 hermes 替换
|
||||
/// 为 unix 版命令,其余工具(npm 类)跨平台命令字符串相同、直接透传。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_tool_action_shell_command(tool: &str, action: ToolLifecycleAction) -> Option<&'static str> {
|
||||
if tool == "hermes" {
|
||||
return Some(HERMES_PIP_UPGRADE_UNIX);
|
||||
fn official_update_args(tool: &str) -> Option<&'static str> {
|
||||
match tool {
|
||||
"claude" | "codex" | "hermes" => Some("update"),
|
||||
"openclaw" => Some("update --yes"),
|
||||
"opencode" => Some("upgrade"),
|
||||
_ => None,
|
||||
}
|
||||
tool_action_shell_command(tool, action)
|
||||
}
|
||||
|
||||
fn bare_official_update_command(tool: &str) -> Option<String> {
|
||||
official_update_args(tool).map(|args| format!("{tool} {args}"))
|
||||
}
|
||||
|
||||
fn chain_update_commands(
|
||||
primary: String,
|
||||
fallback: String,
|
||||
shell: LifecycleCommandShell,
|
||||
) -> String {
|
||||
if fallback.trim().is_empty() {
|
||||
return primary;
|
||||
}
|
||||
match shell {
|
||||
LifecycleCommandShell::Posix => format!("{primary} || {fallback}"),
|
||||
// 这段最终会被外层再包成 `call <command>`。fallback 若是 npm.cmd/pnpm.cmd,
|
||||
// `||` 右侧也必须显式 `call`,否则批处理会转移控制权并跳过后续工具。
|
||||
LifecycleCommandShell::WindowsBatch => format!("{primary} || call {fallback}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_action_shell_command_for_shell(
|
||||
tool: &str,
|
||||
action: ToolLifecycleAction,
|
||||
shell: LifecycleCommandShell,
|
||||
) -> Option<String> {
|
||||
if tool == "hermes" {
|
||||
return Some(
|
||||
match (action, shell) {
|
||||
(ToolLifecycleAction::Install, LifecycleCommandShell::Posix) => HERMES_INSTALL_UNIX,
|
||||
(ToolLifecycleAction::Update, LifecycleCommandShell::Posix) => HERMES_UPDATE_UNIX,
|
||||
#[cfg(target_os = "windows")]
|
||||
(ToolLifecycleAction::Install, LifecycleCommandShell::WindowsBatch) => {
|
||||
return Some(hermes_install_windows_command());
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
(ToolLifecycleAction::Update, LifecycleCommandShell::WindowsBatch) => {
|
||||
return Some(hermes_update_windows_command());
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
(_, LifecycleCommandShell::WindowsBatch) => return None,
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let install = npm_install_command_for(tool)?;
|
||||
match action {
|
||||
ToolLifecycleAction::Install => Some(install.to_string()),
|
||||
ToolLifecycleAction::Update => match prefers_official_update(tool, shell)
|
||||
.then(|| bare_official_update_command(tool))
|
||||
.flatten()
|
||||
{
|
||||
Some(update) => Some(chain_update_commands(update, install.to_string(), shell)),
|
||||
None => Some(install.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_action_shell_command(tool: &str, action: ToolLifecycleAction) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let shell = LifecycleCommandShell::WindowsBatch;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let shell = LifecycleCommandShell::Posix;
|
||||
|
||||
tool_action_shell_command_for_shell(tool, action, shell)
|
||||
}
|
||||
|
||||
/// Windows host 上的 WSL 分支专用:`tool_action_shell_command` 在 Windows target 编译
|
||||
/// 出的版本会包含 Windows batch 语义(例如 `|| call npm ...`)且 hermes 会返回
|
||||
/// Windows PowerShell installer,但跨 `wsl.exe` 边界后跑的是 Linux。这个 wrapper
|
||||
/// 强制生成 POSIX 版命令。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn wsl_tool_action_shell_command(tool: &str, action: ToolLifecycleAction) -> Option<String> {
|
||||
tool_action_shell_command_for_shell(tool, action, LifecycleCommandShell::Posix)
|
||||
}
|
||||
|
||||
fn build_tool_action_line(
|
||||
@@ -402,14 +514,14 @@ fn build_tool_action_line(
|
||||
{
|
||||
// ① WSL 工具(override 是 UNC `\\wsl$\<distro>\...`):锚定的绝对路径是 Windows
|
||||
// 主机路径,跨 wsl.exe 进入 distro 文件系统后无效;且 enumerate 不参与 WSL。
|
||||
// 始终走静态命令(npm i -g / pip)并通过 wsl.exe -d distro -- sh 包一层。
|
||||
// 始终走静态命令(npm i -g / 官方 installer)并通过 wsl.exe -d distro -- sh 包一层。
|
||||
// **必须用 wsl_tool_action_shell_command 而非 tool_action_shell_command**:
|
||||
// 后者在 Windows target 给 hermes 返回 Windows 的 `py -m pip ...`,跨 wsl.exe
|
||||
// 后跑 Linux、py 不存在,要替换为 unix 版的 python3/python 兜底链。
|
||||
// 后者在 Windows target 给 hermes 返回 PowerShell installer,跨 wsl.exe
|
||||
// 后跑 Linux,要替换为 Unix 版官方 installer/update 命令。
|
||||
if let Some(distro) = wsl_distro_for_tool(tool) {
|
||||
let command = wsl_tool_action_shell_command(tool, action)
|
||||
.ok_or_else(|| format!("Unsupported tool action target: {tool}"))?;
|
||||
return build_wsl_tool_action_line(&distro, command, wsl_shell, wsl_shell_flag);
|
||||
return build_wsl_tool_action_line(&distro, &command, wsl_shell, wsl_shell_flag);
|
||||
}
|
||||
// ② Windows 原生 update 锚定;install 走静态(install.sh 是 bash 脚本,Windows
|
||||
// 无意义)。**`enumerate_tool_installations` 在这里 per-tool 重做、与前端
|
||||
@@ -422,7 +534,9 @@ fn build_tool_action_line(
|
||||
installs_anchored_command(tool, &installs)
|
||||
.unwrap_or_else(|| static_fallback_command(tool))
|
||||
}
|
||||
ToolLifecycleAction::Install => static_fallback_command(tool),
|
||||
ToolLifecycleAction::Install => {
|
||||
static_fallback_command_for(tool, ToolLifecycleAction::Install)
|
||||
}
|
||||
};
|
||||
if command.is_empty() {
|
||||
return Err(format!("Unsupported tool action target: {tool}"));
|
||||
@@ -440,7 +554,7 @@ fn build_tool_action_line(
|
||||
let _ = (wsl_shell, wsl_shell_flag);
|
||||
// update 锚定到命令行实际命中的那处(写回同一个 node / brew / 原生安装器),
|
||||
// 而非裸 `npm` 落到 PATH 第一个 npm;install 走「上游推荐 || npm 兜底」短路链
|
||||
// (有 native installer 的工具如 claude/opencode),其余仍裸 npm/pip。
|
||||
// (有 native installer 的工具如 claude/opencode/hermes),其余仍裸 npm。
|
||||
let command = match action {
|
||||
ToolLifecycleAction::Update => {
|
||||
let installs = enumerate_tool_installations(tool);
|
||||
@@ -1459,7 +1573,7 @@ fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
|
||||
installs
|
||||
}
|
||||
|
||||
/// 工具对应的 npm 包名(hermes 走 pip,不在此表)。锚定升级据此拼 `npm i -g`。
|
||||
/// 工具对应的 npm 包名(hermes 走自己的 CLI/installer,不在此表)。锚定升级据此拼 `npm i -g`。
|
||||
/// 全平台共用一张表——Windows 锚定层(`anchored_command_from_paths` 的 windows 版)也读这里。
|
||||
fn npm_package_for(tool: &str) -> Option<&'static str> {
|
||||
match tool {
|
||||
@@ -1629,48 +1743,45 @@ fn sibling_bin(bin_path: &str, exe: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径,
|
||||
/// 推断"写回同一处"的锚定升级命令。**POSIX 版是纯函数(不碰 FS)**——真实 canonicalize
|
||||
/// 由调用方做(`installs_anchored_command` 复用 enumerate 时算出的 `inst.real`),
|
||||
/// 便于单测覆盖各包管理器分支。Windows 版同名函数因 sibling 扩展名歧义必须读 fs,
|
||||
/// 是刻意保留的平台差异(详见 Windows 版本 doc)。hermes(pip)不在此处:
|
||||
/// `npm_package_for` 返回 None → None。
|
||||
///
|
||||
/// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。
|
||||
/// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行——
|
||||
/// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含**
|
||||
/// `~/.local/bin` / `/opt/homebrew/bin` / `~/.volta/bin` 等用户级 bin 目录;而探测
|
||||
/// 阶段 `try_get_version` 用的是 `$SHELL -lic`(登录+交互式,会读 .zshrc/.zprofile),
|
||||
/// 两者 PATH 不对称。裸 `claude update` / `brew upgrade ...` 在 GUI 进程里大概率
|
||||
/// `command not found`(exit 127)→ `set -e` 中止 → 用户看到失败 toast,锚定决策却
|
||||
/// 已展示给用户"将写回原生那处"——欺骗性故障。
|
||||
///
|
||||
/// 判定顺序(命中即返回):
|
||||
/// ① Claude 原生安装器(`~/.local/share/claude/versions/`)→ `<bin_path 绝对> update`;
|
||||
/// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管,
|
||||
/// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。
|
||||
/// ② Homebrew formula(真身在 `Cellar/<formula>/`)→ `<bin_path 同目录>/brew upgrade <formula>`;
|
||||
/// formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli)。
|
||||
/// ③ volta → `<bin_path 同目录>/volta install <pkg>`;bun → `<bin_path 同目录>/bun add -g <pkg>@latest`。
|
||||
/// `~/.volta/bin` / `~/.bun/bin` 几乎不在 GUI 进程的 PATH 里,且用户可能 PATH
|
||||
/// 上有另一份 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();
|
||||
fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String> {
|
||||
official_update_args(tool).map(|args| format!("{} {args}", quote_path_if_spaced(bin_path)))
|
||||
}
|
||||
|
||||
if tool == "claude"
|
||||
&& (real_lower.contains("/.local/share/claude/")
|
||||
|| real_lower.contains("/claude/versions/"))
|
||||
{
|
||||
return Some(format!("{} update", quote_path_if_spaced(bin_path)));
|
||||
#[cfg(target_os = "windows")]
|
||||
fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String> {
|
||||
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
|
||||
}
|
||||
|
||||
fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
|
||||
match shell {
|
||||
LifecycleCommandShell::Posix => {
|
||||
matches!(tool, "claude" | "codex" | "opencode" | "openclaw")
|
||||
}
|
||||
LifecycleCommandShell::WindowsBatch => {
|
||||
matches!(
|
||||
tool,
|
||||
// OpenCode 的 Windows `upgrade` 在 anomalyco/opencode#17295 修复前可能因
|
||||
// 安装方式探测失败弹交互 prompt(spawn npm.cmd 没传 shell:true);静默
|
||||
// lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了
|
||||
// 再把 opencode 加回这里。
|
||||
"claude" | "codex" | "openclaw"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn package_manager_anchored_command_from_paths(
|
||||
tool: &str,
|
||||
bin_path: &str,
|
||||
real_target: &str,
|
||||
) -> Option<String> {
|
||||
if let Some(formula) = brew_formula_from_path(real_target) {
|
||||
let brew = sibling_bin(bin_path, "brew")?;
|
||||
return Some(format!("{} upgrade {formula}", quote_path_if_spaced(&brew)));
|
||||
}
|
||||
let pkg = npm_package_for(tool)?;
|
||||
match infer_install_source(Path::new(bin_path)) {
|
||||
"volta" => {
|
||||
let volta = sibling_bin(bin_path, "volta")?;
|
||||
@@ -1685,44 +1796,69 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
}
|
||||
// 自带同级 npm 的 node 管理器:落到下面锚定到那处的 npm。
|
||||
"nvm" | "fnm" | "mise" | "homebrew" => {}
|
||||
// system / 未知来源(如 opencode install.sh 装的 ~/.opencode/bin、~/go/bin)
|
||||
// 通常没有同级 npm,锚定会拼出 `<dir>/npm` 这种必失败命令;返回 None 让调用方
|
||||
// 回退静态裸命令(至少能跑),并由前端据 anchored=false 给出诚实文案。
|
||||
// system / 未知来源通常没有同级 npm,不能拼 `<dir>/npm`。若工具有官方
|
||||
// self-update,上层会直接锚到 CLI 自身;否则返回 None 走静态兜底。
|
||||
_ => return None,
|
||||
}
|
||||
let npm = sibling_bin(bin_path, "npm")?;
|
||||
Some(format!("{} i -g {pkg}@latest", quote_path_if_spaced(&npm)))
|
||||
}
|
||||
|
||||
/// 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 类——
|
||||
/// 它们都只是"如何装 node"的不同入口,全局包真正的 idiom 仍是 sibling `npm.cmd`。
|
||||
/// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径,
|
||||
/// 推断"写回同一处"的锚定升级命令。**POSIX 版是纯函数(不碰 FS)**——真实 canonicalize
|
||||
/// 由调用方做(`installs_anchored_command` 复用 enumerate 时算出的 `inst.real`),
|
||||
/// 便于单测覆盖各包管理器分支。Windows 版同名函数因 sibling 扩展名歧义必须读 fs,
|
||||
/// 是刻意保留的平台差异(详见 Windows 版本 doc)。
|
||||
///
|
||||
/// **与 POSIX 版的语义差异**:POSIX 版是纯函数(不碰 fs),Windows 版通过
|
||||
/// `sibling_bin_with_ext` 读 fs 来探明扩展名(`.cmd` vs `.exe`)——Node installer
|
||||
/// 装 `.cmd`、Volta 装 `.exe`,纯字符串拼接无法消歧。这一平台差异**被刻意保留**:
|
||||
/// 测试用 tempdir 隔离 fs,生产侧 TOCTOU 是 by design(见 `sibling_bin_with_ext` doc)。
|
||||
/// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。
|
||||
/// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行——
|
||||
/// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含**
|
||||
/// `~/.local/bin` / `/opt/homebrew/bin` / `~/.volta/bin` 等用户级 bin 目录;而探测
|
||||
/// 阶段 `try_get_version` 用的是 `$SHELL -lic`(登录+交互式,会读 .zshrc/.zprofile),
|
||||
/// 两者 PATH 不对称。裸 `claude update` / `brew upgrade ...` 在 GUI 进程里大概率
|
||||
/// `command not found`(exit 127)→ `set -e` 中止 → 用户看到失败 toast,锚定决策却
|
||||
/// 已展示给用户"将写回原生那处"——欺骗性故障。
|
||||
///
|
||||
/// `_real_target` 占位维持与 POSIX 版的签名对称——Windows 上未观测到需要真身路径
|
||||
/// 区分的等价类(无 Cellar、无 claude-native installer)。若未来加 Scoop persist 锚定
|
||||
/// (scoop 装的工具真身在 `<scoop_root>/persist/<app>/...`),从这里启用 `_real_target`。
|
||||
///
|
||||
/// **关键不变量同 POSIX 版:返回的命令必须用绝对路径,不依赖 PATH**。Windows GUI
|
||||
/// 进程 PATH 由 Service Control Manager / explorer.exe 给,通常不含用户 `%LOCALAPPDATA%`
|
||||
/// 下的 Volta/pnpm 路径;`$SHELL -lic` 的探测时 PATH 与执行时 PATH 不对称。
|
||||
///
|
||||
/// 判定顺序(命中即返回):
|
||||
/// ① volta(`infer_install_source == "volta"`)→ sibling `volta.exe`/`.cmd` install <pkg>
|
||||
/// ② pnpm(`infer_install_source == "pnpm"`)→ sibling `pnpm.cmd`/`.exe` add -g <pkg>@latest
|
||||
/// (用 add+@latest 而非 update:语义明确、且对"之前没在 pnpm 全局装过"也幂等)
|
||||
/// ③ 其余 → sibling `npm.cmd`/`.exe` i -g <pkg>@latest
|
||||
///
|
||||
/// hermes(pip)在 `npm_package_for` 返回 None → 整体返 None,落静态 pip 兜底。
|
||||
/// 所有 sibling 探测都通过 `sibling_bin_with_ext`(碰 fs):该处无候选扩展名存在 →
|
||||
/// 返 None,上游 `plan_command_for` 兜回静态命令、`anchored=false`。
|
||||
/// 判定顺序(命中即返回):
|
||||
/// ① Hermes → `<bin_path 绝对> update`;Hermes CLI 自己知道安装环境,避免 cc-switch
|
||||
/// 猜系统 `python3`/`python` 时撞上 Python 版本或 pyenv shim 问题。
|
||||
/// ② Claude 原生安装器(`~/.local/share/claude/versions/`)→ `<bin_path 绝对> update`;
|
||||
/// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管,
|
||||
/// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。
|
||||
/// ③ Homebrew formula(真身在 `Cellar/<formula>/`)→ `<bin_path 同目录>/brew upgrade <formula>`;
|
||||
/// formula 由 Homebrew 拥有,避免 self-update 尝试改动包管理器管理的安装。
|
||||
/// ④ 其余支持官方自升级的工具 → `<bin_path 绝对> update/upgrade || <原锚定包管理器命令>`;
|
||||
/// Codex 的 self-update 只在部分 release 可用,所以保留 npm/brew/bun/volta fallback。
|
||||
/// ⑤ 不支持官方自升级的 npm 全局包(例如 Gemini CLI) → 锚定到"那处 bin 目录的 npm"。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> Option<String> {
|
||||
let real_lower = real_target.to_ascii_lowercase();
|
||||
|
||||
if tool == "hermes" {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "claude"
|
||||
&& (real_lower.contains("/.local/share/claude/")
|
||||
|| real_lower.contains("/claude/versions/"))
|
||||
{
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
|
||||
if brew_formula_from_path(real_target).is_some() {
|
||||
return package_command;
|
||||
}
|
||||
if prefers_official_update(tool, LifecycleCommandShell::Posix) {
|
||||
let update = anchored_official_update_command(tool, bin_path)?;
|
||||
return Some(match package_command {
|
||||
Some(fallback) => chain_update_commands(update, fallback, LifecycleCommandShell::Posix),
|
||||
None => update,
|
||||
});
|
||||
}
|
||||
package_command
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, _real_target: &str) -> Option<String> {
|
||||
fn package_manager_anchored_command_from_paths(tool: &str, bin_path: &str) -> Option<String> {
|
||||
let pkg = npm_package_for(tool)?;
|
||||
|
||||
match infer_install_source(Path::new(bin_path)) {
|
||||
@@ -1752,6 +1888,51 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, _real_target: &str) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows 版锚定命令生成。对平台确认可静默运行的工具优先使用官方 CLI 自升级;
|
||||
/// 对 npm/Volta/pnpm 这类可确认写回位置的安装,再接一个包管理器 fallback。不存在 brew/bun/claude-native
|
||||
/// (Windows 没 Homebrew、Bun for Windows 仍 preview、claude.ai/install.sh 是 bash 脚本)。
|
||||
/// Scoop/Chocolatey/winget/nvm-windows/MS Store node 都归 npm 类——它们都只是"如何装
|
||||
/// node"的不同入口,全局包真正的 idiom 仍是 sibling `npm.cmd`。
|
||||
///
|
||||
/// **与 POSIX 版的语义差异**:POSIX 版是纯函数(不碰 fs),Windows 版通过
|
||||
/// `sibling_bin_with_ext` 读 fs 来探明扩展名(`.cmd` vs `.exe`)——Node installer
|
||||
/// 装 `.cmd`、Volta 装 `.exe`,纯字符串拼接无法消歧。这一平台差异**被刻意保留**:
|
||||
/// 测试用 tempdir 隔离 fs,生产侧 TOCTOU 是 by design(见 `sibling_bin_with_ext` doc)。
|
||||
///
|
||||
/// `_real_target` 占位维持与 POSIX 版的签名对称——Windows 上未观测到需要真身路径
|
||||
/// 区分的等价类(无 Cellar、无 claude-native installer)。若未来加 Scoop persist 锚定
|
||||
/// (scoop 装的工具真身在 `<scoop_root>/persist/<app>/...`),从这里启用 `_real_target`。
|
||||
///
|
||||
/// **关键不变量同 POSIX 版:返回的命令必须用绝对路径,不依赖 PATH**。Windows GUI
|
||||
/// 进程 PATH 由 Service Control Manager / explorer.exe 给,通常不含用户 `%LOCALAPPDATA%`
|
||||
/// 下的 Volta/pnpm 路径;`$SHELL -lic` 的探测时 PATH 与执行时 PATH 不对称。
|
||||
///
|
||||
/// 判定顺序(命中即返回):
|
||||
/// ① hermes → `<bin_path> update`;Hermes CLI 自己处理安装环境。
|
||||
/// ② 支持官方自升级且 Windows 可安全静默执行的工具 → `<bin_path> update/upgrade || call <包管理器 fallback>`。
|
||||
/// ③ 其余 npm 工具 → sibling `npm.cmd`/`.exe` i -g <pkg>@latest。
|
||||
///
|
||||
/// 包管理器 fallback 的 sibling 探测都通过 `sibling_bin_with_ext`(碰 fs):该处无候选
|
||||
/// 扩展名存在时,支持官方自升级的工具仍返回 `<bin_path> update/upgrade`,其余工具
|
||||
/// 才返 None 让上游兜回静态命令、`anchored=false`。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn anchored_command_from_paths(tool: &str, bin_path: &str, _real_target: &str) -> Option<String> {
|
||||
if tool == "hermes" {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
|
||||
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
|
||||
let update = anchored_official_update_command(tool, bin_path)?;
|
||||
return Some(match package_command {
|
||||
Some(fallback) => {
|
||||
chain_update_commands(update, fallback, LifecycleCommandShell::WindowsBatch)
|
||||
}
|
||||
None => update,
|
||||
});
|
||||
}
|
||||
package_command
|
||||
}
|
||||
|
||||
/// 从枚举结果里取"命令行实际命中的那处":优先 `is_path_default`;否则(解析不到
|
||||
/// PATH 默认、但只有一处)取唯一那处;多处且无默认标记 → None(无从锚定)。
|
||||
///
|
||||
@@ -1780,24 +1961,33 @@ fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Optio
|
||||
anchored_command_from_paths(tool, &inst.path, &real)
|
||||
}
|
||||
|
||||
/// 静态裸命令(= 现有的 `npm i -g <pkg>@latest` / pip 升级)。
|
||||
/// 锚定探不到默认安装时回退到它,等同于"装到 PATH 第一个 npm"的旧行为。
|
||||
/// 静态命令(= 平台可安全静默执行的官方 CLI 自升级 || `npm i -g <pkg>@latest` /
|
||||
/// 官方 installer)。锚定探不到默认安装时回退到它;npm fallback 仍等同于
|
||||
/// "装到 PATH 第一个 npm"的旧行为。
|
||||
fn static_fallback_command_for(tool: &str, action: ToolLifecycleAction) -> String {
|
||||
tool_action_shell_command(tool, action).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn static_fallback_command(tool: &str) -> String {
|
||||
tool_action_shell_command(tool, ToolLifecycleAction::Update)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
static_fallback_command_for(tool, ToolLifecycleAction::Update)
|
||||
}
|
||||
|
||||
/// 新装(install)的命令:对有官方 installer 的工具走「上游推荐 || npm 兜底」短路链,
|
||||
/// 其余工具透传到 `static_fallback_command`(= 与 update fallback 同一张 npm/pip 表)。
|
||||
/// 其余工具透传到 install 静态命令。update fallback 会在平台可安全静默执行时
|
||||
/// 优先跑官方 CLI 自升级,但 install 端不能先跑 `tool update`,
|
||||
/// 否则“未安装时安装”的路径会多一次无效失败。
|
||||
///
|
||||
/// 设计理由:
|
||||
/// - install 没有锚点可言(从无到有),但**有"上游推荐方式"这一事实** ——
|
||||
/// Anthropic 和 SST(OpenCode)都已将自家 native installer 列为首推、把 npm 列为传统方式。
|
||||
/// 把这层认知补进来,让 install 表与 update 端的锚定决策树共用同一份"上游事实"。
|
||||
/// - 短路链(POSIX `||`)保证 URL 不可达/防火墙拦截时仍能装上,降级到 npm。
|
||||
/// - Hermes 使用官方 installer,避免用系统 Python/pip 安装时踩 Python >=3.11 与 pyenv
|
||||
/// `python` shim 问题;更新路径若能锚定已安装 CLI,则走 `<hermes> update`。
|
||||
/// **Hermes 没有 npm 包,install 端不享受 `||` 降级**——上游 installer 不可达就只能等。
|
||||
/// - 对**有 npm 包**的工具(claude/opencode),短路链(POSIX `||`)保证 URL 不可达/
|
||||
/// 防火墙拦截时仍能装上,降级到裸 `npm i -g`。
|
||||
/// - 仅非 Windows 启用:claude.ai/install.sh、opencode.ai/install 都是 bash 脚本,
|
||||
/// Windows(及不带 bash 的环境)继续走原 `tool_action_shell_command` 的 npm 命令。
|
||||
/// Windows(及不带 bash 的环境)继续走 `tool_action_shell_command` 的 npm/PowerShell 命令。
|
||||
/// - 配套要求:`build_tool_lifecycle_command` 头部已开 `set -o pipefail`,确保
|
||||
/// `curl ... | bash` 中 curl 失败能让管道整体非零、`||` 兜底真正触发。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -1805,7 +1995,8 @@ fn install_command_for(tool: &str) -> String {
|
||||
match tool {
|
||||
"claude" => "curl -fsSL https://claude.ai/install.sh | bash || npm i -g @anthropic-ai/claude-code@latest".to_string(),
|
||||
"opencode" => "curl -fsSL https://opencode.ai/install | bash || npm i -g opencode-ai@latest".to_string(),
|
||||
_ => static_fallback_command(tool),
|
||||
"hermes" => HERMES_INSTALL_UNIX.to_string(),
|
||||
_ => static_fallback_command_for(tool, ToolLifecycleAction::Install),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1815,18 +2006,17 @@ fn install_command_for(tool: &str) -> String {
|
||||
/// 系统后完全无效;且 `enumerate_tool_installations` 不参与 WSL 文件系统、锚定
|
||||
/// 无锚点。这一类显式短路到 `(unix_static, false, false)`,前端不会弹确认。
|
||||
/// **必须用 `wsl_tool_action_shell_command`(unix 版)而非 `static_fallback_command`**
|
||||
/// ——后者读 `tool_action_shell_command`,Windows target 给 hermes 返回 `py -m pip ...`,
|
||||
/// 跨 wsl.exe 后 py launcher 不存在;`build_tool_action_line` 的 WSL 分支也用同一
|
||||
/// wrapper,保证 plan 展示给前端的命令与实际执行落 .bat 的命令一致。
|
||||
/// ——后者读 `tool_action_shell_command`,Windows target 给 hermes 返回 PowerShell
|
||||
/// installer,跨 wsl.exe 后不适用;`build_tool_action_line` 的 WSL 分支也用同一 wrapper,
|
||||
/// 保证 plan 展示给前端的命令与实际执行落 .bat 的命令一致。
|
||||
/// - 其他平台与 Windows 原生工具走 `installs_anchored_command`:命中 → 锚定;
|
||||
/// None(无默认 / sibling 不存在 / hermes pip 等)→ 静态兜底、`anchored=false`,
|
||||
/// None(无默认 / sibling 不存在等)→ 静态兜底、`anchored=false`,
|
||||
/// 前端据此给"默认入口无法确定"诚实文案。
|
||||
fn plan_command_for(tool: &str, installs: &[ToolInstallation]) -> (String, bool, bool) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if wsl_distro_for_tool(tool).is_some() {
|
||||
let cmd = wsl_tool_action_shell_command(tool, ToolLifecycleAction::Update)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
return (cmd, false, false);
|
||||
}
|
||||
@@ -2952,7 +3142,11 @@ mod tests {
|
||||
let (_dir, sub, bin_path) = setup_sibling("Volta", "codex.cmd", &["volta.exe"]);
|
||||
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
|
||||
let volta_full = format!("{}\\volta.exe", sub.to_string_lossy());
|
||||
let expected = format!("{} install @openai/codex", expect_quoted_path(&volta_full));
|
||||
let expected = format!(
|
||||
"{} update || call {} install @openai/codex",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&volta_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
@@ -2965,12 +3159,32 @@ mod tests {
|
||||
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
|
||||
let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"{} add -g @openai/codex@latest",
|
||||
"{} update || call {} add -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&pnpm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_windows_uses_package_fallback_without_official_upgrade() {
|
||||
let (_dir, sub, bin_path) = setup_sibling("pnpm", "opencode.cmd", &["pnpm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("opencode", &bin_path, &bin_path);
|
||||
let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"{} add -g opencode-ai@latest",
|
||||
expect_quoted_path(&pnpm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_windows_static_fallback_skips_official_upgrade() {
|
||||
let cmd = static_fallback_command("opencode");
|
||||
assert_eq!(cmd, "npm i -g opencode-ai@latest");
|
||||
assert!(!cmd.contains("opencode upgrade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_windows_default_branch() {
|
||||
// 任意 system 类路径(不命中 volta/pnpm)→ 兜底 sibling npm.cmd 锚定。
|
||||
@@ -2979,29 +3193,84 @@ mod tests {
|
||||
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
|
||||
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"{} i -g @openai/codex@latest",
|
||||
"{} update || call {} i -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_windows_no_sibling_returns_none() {
|
||||
// sibling npm.cmd 不存在(纯独立二进制,如 opencode install.sh 装的)→ 返 None,
|
||||
// 上游兜回静态 `call npm i -g`,前端据 anchored=false 给"默认入口无法确定"提示。
|
||||
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
|
||||
// sibling npm.cmd 不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
|
||||
// 只是没有包管理器 fallback。
|
||||
let (_dir, _sub, bin_path) = setup_sibling("", "codex.cmd", &[]);
|
||||
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
|
||||
assert!(cmd.is_none());
|
||||
let expected = format!("{} update", expect_quoted_path(&bin_path));
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_windows_returns_none_for_pip() {
|
||||
// hermes 不在 npm_package_for 表中 → 返 None,上游退回静态 pip 命令
|
||||
// (`py -m pip install --upgrade hermes-agent[web] || python -m pip ...`)。
|
||||
// 故意把 npm.cmd 也放进 sibling 验证"npm 在但 pkg 不在"也是 None,不靠运气。
|
||||
fn hermes_windows_uses_cli_update() {
|
||||
// Hermes 自带 `hermes update`,不要再回退到 py/python/pip。即便同目录有
|
||||
// npm.cmd,也不应走 npm 分支。
|
||||
let (_dir, _sub, bin_path) = setup_sibling("", "hermes.exe", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("hermes", &bin_path, &bin_path);
|
||||
assert!(cmd.is_none());
|
||||
let expected = format!("{} update", expect_quoted_path(&bin_path));
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_windows_static_fallback_uses_powershell_installer_without_pip() {
|
||||
let install = static_fallback_command_for("hermes", ToolLifecycleAction::Install);
|
||||
assert!(
|
||||
install
|
||||
.starts_with("powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand "),
|
||||
"should use PowerShell EncodedCommand installer: {install}"
|
||||
);
|
||||
let encoded = install
|
||||
.split_once("-EncodedCommand ")
|
||||
.map(|(_, encoded)| encoded)
|
||||
.expect("installer should include encoded command");
|
||||
assert_eq!(
|
||||
encoded,
|
||||
powershell_encoded_command(HERMES_INSTALL_WINDOWS_SCRIPT)
|
||||
);
|
||||
let install_prefix = install
|
||||
.split_once("-EncodedCommand ")
|
||||
.map(|(prefix, _)| prefix)
|
||||
.expect("installer should include encoded command");
|
||||
assert!(
|
||||
!install_prefix.contains("|")
|
||||
&& !install_prefix.contains("-Command")
|
||||
&& !install_prefix.contains("python")
|
||||
&& !install_prefix.contains("pip"),
|
||||
"should hide PowerShell pipe from cmd.exe and avoid system Python/pip: {install}"
|
||||
);
|
||||
|
||||
let update = static_fallback_command("hermes");
|
||||
assert!(
|
||||
update.starts_with(
|
||||
"hermes update || powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand "
|
||||
),
|
||||
"should try CLI update before PowerShell installer: {update}"
|
||||
);
|
||||
let fallback = update
|
||||
.split_once("||")
|
||||
.map(|(_, fallback)| fallback)
|
||||
.expect("update should include a fallback command");
|
||||
let fallback_prefix = fallback
|
||||
.split_once("-EncodedCommand ")
|
||||
.map(|(prefix, _)| prefix)
|
||||
.expect("fallback should include encoded command");
|
||||
assert!(
|
||||
!fallback_prefix.contains('|')
|
||||
&& !fallback_prefix.contains("-Command")
|
||||
&& !update.contains("call powershell")
|
||||
&& !fallback_prefix.contains("python")
|
||||
&& !fallback_prefix.contains("pip"),
|
||||
"PowerShell fallback should be encoded, not called like a batch file or use pip: {update}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3011,9 +3280,11 @@ mod tests {
|
||||
// 锁定引号位置**(starts_with+contains 会放过"双引号位置错了但仍能命中"的回归)。
|
||||
let (_dir, sub, bin_path) = setup_sibling("Program Files", "codex.cmd", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
|
||||
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"\"{}\\npm.cmd\" i -g @openai/codex@latest",
|
||||
sub.to_string_lossy()
|
||||
"{} update || call {} i -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
@@ -3035,7 +3306,8 @@ mod tests {
|
||||
// 会让 expected 漏引号、假失败)。
|
||||
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
|
||||
let expected = format!(
|
||||
"call {} i -g @openai/codex@latest",
|
||||
"call {} update || call {} i -g @openai/codex@latest",
|
||||
expect_quoted_path(&bin_path),
|
||||
expect_quoted_path(&npm_full)
|
||||
);
|
||||
assert_eq!(batch_line, expected);
|
||||
@@ -3178,29 +3450,59 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_hermes_command_uses_python3_not_py_launcher() {
|
||||
// 跨 wsl.exe 边界后跑的是 Linux,Windows 的 `py` launcher 不存在(PEP 397
|
||||
// 是 Windows 独有)、Ubuntu 24.04+ 连 `python` alias 也没有。
|
||||
// `tool_action_shell_command` 在 Windows target 给 hermes 返回 `py -m pip ...`
|
||||
// 是给 Windows 原生跑用的,WSL 分支必须改走 unix 版命令——这一切由
|
||||
// `wsl_tool_action_shell_command` 替换 hermes case 实现。
|
||||
let cmd = wsl_tool_action_shell_command("hermes", ToolLifecycleAction::Update).unwrap();
|
||||
fn wsl_hermes_command_uses_unix_installer_not_powershell_or_pip() {
|
||||
// 跨 wsl.exe 边界后跑的是 Linux,Windows PowerShell installer 不适用;
|
||||
// 也不要再走 python3/python pip 链,避免 Python 版本/pyenv shim 问题。
|
||||
let update_cmd =
|
||||
wsl_tool_action_shell_command("hermes", ToolLifecycleAction::Update).unwrap();
|
||||
assert!(
|
||||
cmd.starts_with("python3 -m pip"),
|
||||
"WSL hermes 必须用 python3 起手,得到: {cmd}"
|
||||
update_cmd.starts_with("hermes update || bash -c 'tmp=$(mktemp) && curl -fsSL "),
|
||||
"WSL hermes 更新应先尝试 CLI 自更新再回退官方 installer,得到: {update_cmd}"
|
||||
);
|
||||
let fallback = update_cmd
|
||||
.split_once("||")
|
||||
.map(|(_, fallback)| fallback)
|
||||
.expect("update should include installer fallback");
|
||||
assert!(
|
||||
!fallback.contains('|')
|
||||
&& fallback.contains(" -o $tmp && bash $tmp")
|
||||
&& !update_cmd.contains("powershell")
|
||||
&& !update_cmd.contains("pip"),
|
||||
"WSL hermes fallback 不能依赖 pipefail/Windows installer/pip,得到: {update_cmd}"
|
||||
);
|
||||
|
||||
let install_cmd =
|
||||
wsl_tool_action_shell_command("hermes", ToolLifecycleAction::Install).unwrap();
|
||||
assert!(
|
||||
install_cmd.starts_with("bash -c 'tmp=$(mktemp) && curl -fsSL "),
|
||||
"WSL hermes 安装应直接走官方 Unix installer,得到: {install_cmd}"
|
||||
);
|
||||
assert!(
|
||||
!cmd.contains("py -m pip"),
|
||||
"WSL hermes 不能含 Windows 独有的 py launcher 命令,得到: {cmd}"
|
||||
!install_cmd.contains('|') && install_cmd.contains(" -o $tmp && bash $tmp"),
|
||||
"WSL hermes 安装不应依赖 pipefail,得到: {install_cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_npm_tools_passthrough_to_windows_target_command() {
|
||||
// npm 类工具的命令在 Windows/Unix 上字符串相同 → wrapper 直接透传给
|
||||
// `tool_action_shell_command`,无需额外替换。
|
||||
fn wsl_hermes_install_line_does_not_depend_on_outer_pipefail() {
|
||||
let line = build_wsl_tool_action_line("Ubuntu", HERMES_INSTALL_UNIX, None, None)
|
||||
.expect("valid WSL command line");
|
||||
assert!(line.starts_with("wsl.exe -d Ubuntu -- sh -c "));
|
||||
assert!(
|
||||
!line.contains("| bash") && line.contains(" -o $tmp && bash $tmp"),
|
||||
"WSL 子 shell 内不能出现 curl 管道安装器: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_npm_tools_use_posix_update_chain_without_batch_call() {
|
||||
// WSL 内跑的是 POSIX shell,不能带 Windows batch 的 `call`。同时 update
|
||||
// fallback 仍应先尝试官方 CLI 自升级。
|
||||
let cmd = wsl_tool_action_shell_command("claude", ToolLifecycleAction::Update).unwrap();
|
||||
assert_eq!(cmd, "npm i -g @anthropic-ai/claude-code@latest");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
"claude update || npm i -g @anthropic-ai/claude-code@latest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3323,9 +3625,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_homebrew_formula_uses_brew_not_self_update() {
|
||||
// Homebrew formula 归 brew 管理;即使 Codex 有 self-update,也不先改动
|
||||
// Cellar 内的安装内容。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/opt/homebrew/bin/codex",
|
||||
"/opt/homebrew/Cellar/codex/1.2.3/bin/codex",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/opt/homebrew/bin/brew upgrade codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_nvm_anchors_to_npm_without_cli_update() {
|
||||
let cmd = anchored_command_from_paths(
|
||||
"gemini",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/gemini",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/lib/node_modules/@google/gemini-cli/dist/index.js",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @google/gemini-cli@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_nvm_anchors_to_that_npm() {
|
||||
// 命令行命中 nvm 那处 → 升级写回同一个 node 的 npm,而非 PATH 第一个 npm。
|
||||
// Codex 官方 self-update 只在支持的 release 上生效;失败时仍写回同一个
|
||||
// node 的 npm,而非 PATH 第一个 npm。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
|
||||
@@ -3333,14 +3663,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/codex update || /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。
|
||||
// 是 npm 全局包,官方 update 失败后走 npm 锚定而非 brew upgrade。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"openclaw",
|
||||
"/opt/homebrew/bin/openclaw",
|
||||
@@ -3348,7 +3678,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/opt/homebrew/bin/npm i -g openclaw@latest")
|
||||
Some("/opt/homebrew/bin/openclaw update --yes || /opt/homebrew/bin/npm i -g openclaw@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3363,13 +3693,13 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.volta/bin/volta install @openai/codex")
|
||||
Some("/Users/me/.volta/bin/codex update || /Users/me/.volta/bin/volta install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bun_uses_bun_add() {
|
||||
// bun 同 volta:`~/.bun/bin` 几乎不在 GUI PATH 里,绝对路径锚定。
|
||||
// OpenCode 先跑官方 upgrade;失败后 bun 同 volta:绝对路径写回原安装源。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"opencode",
|
||||
"/Users/me/.bun/bin/opencode",
|
||||
@@ -3377,7 +3707,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.bun/bin/bun add -g opencode-ai@latest")
|
||||
Some("/Users/me/.bun/bin/opencode upgrade || /Users/me/.bun/bin/bun add -g opencode-ai@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3391,7 +3721,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
|
||||
Some("'/Users/my name/.volta/bin/codex' update || '/Users/my name/.volta/bin/volta' install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3406,42 +3736,46 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.bun/bin/bun' add -g opencode-ai@latest")
|
||||
Some("'/Users/my name/.bun/bin/opencode' upgrade || '/Users/my name/.bun/bin/bun' add -g opencode-ai@latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_has_no_npm_anchor() {
|
||||
// hermes 走 pip,不在 npm 包表 → 锚定返回 None,调用方回退到静态 pip 升级命令。
|
||||
fn hermes_uses_cli_update_anchor() {
|
||||
// Hermes 自带 `hermes update`;锚定到命令行默认那处 CLI,避免 cc-switch 猜
|
||||
// 系统 Python/pip 时撞上 Python >=3.11 或 pyenv shim 问题。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"hermes",
|
||||
"/usr/local/bin/hermes",
|
||||
"/usr/local/bin/hermes",
|
||||
);
|
||||
assert_eq!(cmd, None);
|
||||
assert_eq!(cmd.as_deref(), Some("/usr/local/bin/hermes update"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_install_without_sibling_npm_is_not_anchored() {
|
||||
fn opencode_native_install_uses_cli_upgrade_without_package_fallback() {
|
||||
// opencode install.sh 装到 ~/.opencode/bin(独立二进制、无同级 npm):
|
||||
// 不能锚定到 `<dir>/npm`(必失败),返回 None 让调用方回退静态命令。
|
||||
// 不能锚定到 `<dir>/npm`(必失败),但可以锚定到 CLI 自身跑官方 upgrade。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"opencode",
|
||||
"/Users/me/.opencode/bin/opencode",
|
||||
"/Users/me/.opencode/bin/opencode",
|
||||
);
|
||||
assert_eq!(cmd, None);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.opencode/bin/opencode upgrade")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_bin_install_is_not_anchored() {
|
||||
// ~/go/bin 同理:无同级 npm。
|
||||
fn go_bin_opencode_uses_cli_upgrade_without_package_fallback() {
|
||||
// ~/go/bin 同理:无同级 npm,但 OpenCode 官方 upgrade 可由 CLI 自己处理。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"opencode",
|
||||
"/Users/me/go/bin/opencode",
|
||||
"/Users/me/go/bin/opencode",
|
||||
);
|
||||
assert_eq!(cmd, None);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/go/bin/opencode upgrade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3455,7 +3789,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex update || /Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3469,7 +3803,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
Some("'/Users/my name/.nvm/versions/node/v22/bin/codex' update || '/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3678,13 +4012,62 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_install_keeps_static_pip_chain() {
|
||||
// hermes 已经有 python3 || python 兜底,install_command_for 透传到 fallback。
|
||||
fn update_fallbacks_use_official_cli_only_when_supported() {
|
||||
assert_eq!(
|
||||
static_fallback_command("claude"),
|
||||
"claude update || npm i -g @anthropic-ai/claude-code@latest"
|
||||
);
|
||||
assert_eq!(
|
||||
static_fallback_command("codex"),
|
||||
"codex update || npm i -g @openai/codex@latest"
|
||||
);
|
||||
assert_eq!(
|
||||
static_fallback_command("gemini"),
|
||||
"npm i -g @google/gemini-cli@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("gemini").contains("gemini update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("opencode"),
|
||||
"opencode upgrade || npm i -g opencode-ai@latest"
|
||||
);
|
||||
assert_eq!(
|
||||
static_fallback_command("openclaw"),
|
||||
"openclaw update --yes || npm i -g openclaw@latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_install_uses_official_installer() {
|
||||
// Hermes 官方 installer 会处理 Python 3.11+/uv 等运行时;不要再从 cc-switch
|
||||
// 里走 `python3 || python` pip 链。
|
||||
let cmd = install_command_for("hermes");
|
||||
assert!(cmd.contains("hermes-agent[web]"), "pip package: {cmd}");
|
||||
assert!(
|
||||
cmd.contains("python3") && cmd.contains("python "),
|
||||
"should keep python3→python fallback chain: {cmd}"
|
||||
cmd.starts_with("bash -c 'tmp=$(mktemp) && curl -fsSL ")
|
||||
&& cmd.contains("install.sh -o $tmp && bash $tmp"),
|
||||
"should use official installer: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
!cmd.contains('|') && !cmd.contains("python") && !cmd.contains("pip"),
|
||||
"should not depend on pipefail or system Python/pip: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hermes_update_fallback_uses_cli_update_then_installer() {
|
||||
// 锚定失败时也不回退 pip:先让 PATH 上的 hermes 自更新,找不到/失败再跑官方
|
||||
// installer。这样 pyenv 的 `python` shim 不会参与错误路径。
|
||||
let cmd = static_fallback_command("hermes");
|
||||
assert!(
|
||||
cmd.starts_with("hermes update || bash -c 'tmp=$(mktemp) && curl -fsSL "),
|
||||
"should try CLI update before official installer: {cmd}"
|
||||
);
|
||||
let fallback = cmd
|
||||
.split_once("||")
|
||||
.map(|(_, fallback)| fallback)
|
||||
.expect("update should include installer fallback");
|
||||
assert!(
|
||||
!fallback.contains('|') && !cmd.contains("python") && !cmd.contains("pip"),
|
||||
"should not depend on pipefail or system Python/pip: {cmd}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ npm i -g opencode-ai@latest
|
||||
# OpenClaw
|
||||
npm i -g openclaw@latest
|
||||
# Hermes
|
||||
python3 -m pip install --upgrade "hermes-agent[web]"`;
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`;
|
||||
|
||||
const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
|
||||
claude: "Claude Code",
|
||||
@@ -474,15 +474,23 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
|
||||
// 逐工具串行执行:每个工具独立成败、独立刷新版本,一个失败不会连坐
|
||||
// 后续工具(后端把整批拼成单脚本 + set -e,会在首个失败处中止整批)。
|
||||
// soft=true 表示"命令成功执行但版本探不到"(装上却跑不起来),
|
||||
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/装上却跑不起来),
|
||||
// 与命令本身报错(soft=false)区别对待:前者不算硬失败,toast 降级为 warning。
|
||||
const failures: { toolName: ToolName; detail: string; soft: boolean }[] =
|
||||
[];
|
||||
const failures: {
|
||||
toolName: ToolName;
|
||||
detail: string;
|
||||
soft: boolean;
|
||||
kind?: "notRunnable" | "versionUnchanged";
|
||||
}[] = [];
|
||||
let succeeded = 0;
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
setToolActions((prev) => ({ ...prev, [toolName]: action }));
|
||||
try {
|
||||
const previousTool = toolVersionByName.get(toolName);
|
||||
const previousVersion = previousTool?.version ?? null;
|
||||
const previousLatestVersion = previousTool?.latest_version ?? null;
|
||||
|
||||
await settingsApi.runToolLifecycleAction(
|
||||
[toolName],
|
||||
action,
|
||||
@@ -495,18 +503,46 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
);
|
||||
const tool = refreshed.find((t) => t.name === toolName);
|
||||
if (tool?.version) {
|
||||
succeeded += 1;
|
||||
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
|
||||
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
|
||||
if (action === "update") {
|
||||
const latestVersion = tool.latest_version ?? previousLatestVersion;
|
||||
const versionUnchangedAfterUpdate =
|
||||
action === "update" &&
|
||||
Boolean(previousVersion) &&
|
||||
tool.version === previousVersion &&
|
||||
Boolean(latestVersion) &&
|
||||
tool.version !== latestVersion;
|
||||
|
||||
if (versionUnchangedAfterUpdate) {
|
||||
// 有些上游 updater 会在未实际改动版本时仍返回 0。这里用刷新后的
|
||||
// 当前版本 + latest_version 再确认一次,避免给用户误报升级成功。
|
||||
failures.push({
|
||||
toolName,
|
||||
detail: t("settings.toolActionVersionUnchanged", {
|
||||
version: tool.version,
|
||||
latest: latestVersion ?? t("common.unknown"),
|
||||
}),
|
||||
soft: true,
|
||||
kind: "versionUnchanged",
|
||||
});
|
||||
void diagnoseToolSilently(toolName);
|
||||
} else {
|
||||
succeeded += 1;
|
||||
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
|
||||
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
|
||||
if (action === "update") {
|
||||
void diagnoseToolSilently(toolName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
|
||||
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
|
||||
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
|
||||
const detail = tool?.error?.trim() || t("settings.toolNotRunnable");
|
||||
failures.push({ toolName, detail, soft: true });
|
||||
failures.push({
|
||||
toolName,
|
||||
detail,
|
||||
soft: true,
|
||||
kind: "notRunnable",
|
||||
});
|
||||
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
|
||||
void diagnoseToolSilently(toolName);
|
||||
}
|
||||
@@ -560,13 +596,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
: failures[0]?.detail;
|
||||
|
||||
const hardFailures = failures.filter((f) => !f.soft);
|
||||
const allSoftVersionUnchanged =
|
||||
failures.length > 0 &&
|
||||
failures.every((f) => f.soft && f.kind === "versionUnchanged");
|
||||
|
||||
if (succeeded === 0 && hardFailures.length === 0) {
|
||||
// 命令均成功执行、但工具都探不到版本(装上却跑不起来)→ 降级为 warning 并解释原因。
|
||||
toast.warning(t("settings.toolActionInstalledNotRunnable"), {
|
||||
description: failureDescription || undefined,
|
||||
closeButton: true,
|
||||
});
|
||||
// 命令均成功执行、但结果需要用户介入(版本没变 / 装上却跑不起来)
|
||||
// → 降级为 warning 并解释原因。
|
||||
toast.warning(
|
||||
allSoftVersionUnchanged
|
||||
? t("settings.toolActionVersionUnchangedTitle")
|
||||
: t("settings.toolActionInstalledNotRunnable"),
|
||||
{
|
||||
description: failureDescription || undefined,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else if (succeeded === 0) {
|
||||
toast.error(t("settings.toolActionFailed"), {
|
||||
description: failureDescription || undefined,
|
||||
@@ -584,7 +629,13 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
);
|
||||
}
|
||||
},
|
||||
[t, wslShellByTool, refreshToolVersions, diagnoseToolSilently],
|
||||
[
|
||||
t,
|
||||
wslShellByTool,
|
||||
toolVersionByName,
|
||||
refreshToolVersions,
|
||||
diagnoseToolSilently,
|
||||
],
|
||||
);
|
||||
|
||||
// 升级/安装的统一入口锁。所有动作在入口处先登记 preflight、出口处解锁;
|
||||
|
||||
@@ -673,6 +673,8 @@
|
||||
"toolActionPartial": "{{action}} succeeded for {{succeeded}}, failed for {{failed}}",
|
||||
"toolActionFailed": "Install/update command failed",
|
||||
"toolNotRunnable": "Installed but not runnable (no version detected)",
|
||||
"toolActionVersionUnchangedTitle": "Version did not change",
|
||||
"toolActionVersionUnchanged": "Still on {{version}} (latest: {{latest}}). The updater may have reported success without applying the update.",
|
||||
"toolActionInstalledNotRunnable": "Installed, but it can't run in the current environment — please check",
|
||||
"installedNotRunnable": "Installed · can't run",
|
||||
"toolCheckEnv": "Check environment",
|
||||
|
||||
@@ -673,6 +673,8 @@
|
||||
"toolActionPartial": "{{succeeded}} 個成功、{{failed}} 個失敗({{action}})",
|
||||
"toolActionFailed": "インストール/更新コマンドの実行に失敗しました",
|
||||
"toolNotRunnable": "インストール済みですが実行できません(バージョン未検出)",
|
||||
"toolActionVersionUnchangedTitle": "バージョンは変わりませんでした",
|
||||
"toolActionVersionUnchanged": "現在も {{version}}(最新: {{latest}})です。アップデーターが成功を報告しても実際には更新されていない可能性があります。",
|
||||
"toolActionInstalledNotRunnable": "インストールされましたが、現在の環境では実行できません。確認してください",
|
||||
"installedNotRunnable": "インストール済み・実行不可",
|
||||
"toolCheckEnv": "実行環境を確認",
|
||||
|
||||
@@ -673,6 +673,8 @@
|
||||
"toolActionPartial": "{{succeeded}} 个{{action}}成功,{{failed}} 个失败",
|
||||
"toolActionFailed": "安装/升级命令执行失败",
|
||||
"toolNotRunnable": "已安装但无法运行(未探测到版本)",
|
||||
"toolActionVersionUnchangedTitle": "版本没有变化",
|
||||
"toolActionVersionUnchanged": "仍是 {{version}}(最新:{{latest}})。上游升级器可能报告成功但没有实际更新。",
|
||||
"toolActionInstalledNotRunnable": "已安装,但当前环境无法运行,请检查",
|
||||
"installedNotRunnable": "已安装·无法运行",
|
||||
"toolCheckEnv": "请检查运行环境",
|
||||
|
||||
Reference in New Issue
Block a user