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:
Jason
2026-05-25 08:52:14 +08:00
parent 7cad61be27
commit 5de0a0dc8d
5 changed files with 617 additions and 177 deletions
+66 -15
View File
@@ -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、出口处解锁;