feat(settings): unify unix installers on mktemp+bash, fix WSL install missing native installer

Extend the mktemp+bash installer pattern from hermes (commit 20d943be) to
claude and opencode, and remove the cfg gate that locked install_command_for
to non-Windows targets — Windows host + WSL tools now correctly route
through the POSIX install priority instead of falling back to bare npm.
Frontend "one-click install commands" displayed in the About section now
diverge per platform to match what the backend actually runs.

Backend (src-tauri/src/commands/misc.rs):
- New CLAUDE_INSTALL_UNIX and OPENCODE_INSTALL_UNIX constants use the same
  `bash -c 'tmp=$(mktemp) && curl -fsSL .../install.sh -o $tmp && bash
  $tmp; status=$?; rm -f $tmp; exit $status'` shape as HERMES_INSTALL_UNIX.
  The shared comment about why we avoid `curl | bash` is now hoisted to the
  top of all three constants — the constraint (WSL `sh -c` sub-shells
  don't inherit outer pipefail; default shell may be dash/ash) applies to
  every shell installer, not just Hermes.
- New installer_with_npm_fallback helper deduplicates the
  "official installer || npm fallback" chain construction; reuses
  chain_update_commands(LifecycleCommandShell::Posix) so the wiring matches
  the update-chain logic.
- install_command_for split into cross-platform posix_install_command_for
  + thin #[cfg(not(target_os = "windows"))] wrapper. The cfg gate had
  been hiding a real bug: on Windows hosts, the WSL branch called
  wsl_tool_action_shell_command which forwarded install actions to the
  Windows-target tool_action_shell_command, yielding bare `npm i -g` for
  claude/opencode even though they have working native POSIX installers.
- wsl_tool_action_shell_command now branches on action: Install dispatches
  to posix_install_command_for (native installer || npm chain); Update
  keeps the existing behavior. Empty install commands collapse to None so
  callers error on unsupported tools instead of running an empty command.
- build_tool_lifecycle_command pipefail comment updated: the original
  justification (covering install's `curl | bash` path) no longer applies
  to any current command, but the directive stays as defense-in-depth for
  future pipe additions.
- build_tool_action_line WSL branch comment updated to reflect the
  install/update split.

Frontend (src/components/settings/AboutSection.tsx):
- New posixScriptInstallCommand(url) helper builds the same mktemp+bash
  string template the backend uses.
- New powershellEncodedCommand(script) mirrors the backend's UTF-16 LE +
  base64 encoder (charCodeAt + String.fromCharCode(lo, hi) + btoa) so the
  PowerShell EncodedCommand shown to users matches what the backend
  executes.
- ONE_CLICK_INSTALL_COMMANDS split into POSIX_ONE_CLICK_INSTALL_COMMANDS
  and WINDOWS_ONE_CLICK_INSTALL_COMMANDS, selected by isWindows():
  POSIX shows claude/opencode/hermes with the mktemp+bash installer;
  Windows shows claude/codex/gemini/opencode/openclaw with npm (matching
  backend static_fallback_command on Windows) and hermes with the
  PowerShell EncodedCommand. Display now mirrors backend execution
  per platform, removing the show-vs-run mismatch.
- Stale comment in the probe-concurrency note mentioning "curl | bash"
  updated to "官方 installer".

Tests (src-tauri/src/commands/misc.rs):
- New wsl_install_uses_posix_install_priority covers three
  representatives: claude (positive: native installer with npm fallback),
  opencode (positive), codex (negative: no native installer so falls back
  to bare `npm i -g`). Reverse-asserts !contains("| bash") to lock out
  the old pipe form.
- claude_install_chains_native_then_npm and
  opencode_install_chains_native_then_npm strengthened with
  !parts[0].contains('|') so the native installer portion stays
  pipe-free even if future edits to the template sneak a pipe back in.

Validated:
- cargo check --tests: clean
- cargo test --lib commands::misc:: : 59 passed, 0 failed
- pnpm typecheck: 0 errors
- cargo fmt: clean
- pnpm format: clean (no files changed)
This commit is contained in:
Jason
2026-05-25 09:07:34 +08:00
parent 5de0a0dc8d
commit 88ba908bc4
2 changed files with 134 additions and 30 deletions
+94 -27
View File
@@ -307,9 +307,9 @@ fn build_tool_lifecycle_command(
#[cfg(not(target_os = "windows"))]
{
// set -e 让任一步失败即中止;set -o pipefail 让管道前段失败也参与判定——
// install `curl ... | bash` 路径下,curl 失败时 bash 收空 stdin 仍 exit 0,
// 没有 pipefail 会"假成功"而绕过 `||` 兜底链,工具其实没装上
// set -e 让任一步失败即中止;set -o pipefail 保留为管道命令的兜底防线。
// 当前官方 installer 路径已避免 `curl | bash`,但未来若新增管道命令,
// 仍应让管道前段失败参与整条脚本判定
lines.push("set -e".to_string());
lines.push("set -o pipefail".to_string());
}
@@ -356,16 +356,20 @@ fn tool_display_name(tool: &str) -> &'static str {
}
}
/// 官方 shell installer 都不用 `curl | bash` 这种 pipe 形式(仍然用 curl 下载,
/// 只是先落到临时文件再交给 bash 执行):WSL 分支会在
/// `wsl.exe ... -- sh -c "<cmd>"` 子 shell 里执行命令,外层脚本的 `set -o pipefail`
/// 不会继承进去;而 WSL 默认 shell 可能是 dash/ash,也不能假设支持 `set -o pipefail`。
/// 先下载到 mktemp 文件再交给 bash,能让 curl 失败稳定变成整条命令失败。
const CLAUDE_INSTALL_UNIX: &str =
"bash -c 'tmp=$(mktemp) && curl -fsSL https://claude.ai/install.sh -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
const OPENCODE_INSTALL_UNIX: &str =
"bash -c 'tmp=$(mktemp) && curl -fsSL https://opencode.ai/install -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'";
/// 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 =
@@ -501,7 +505,19 @@ fn tool_action_shell_command(tool: &str, action: ToolLifecycleAction) -> Option<
/// 强制生成 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)
match action {
ToolLifecycleAction::Install => {
let command = posix_install_command_for(tool);
if command.is_empty() {
None
} else {
Some(command)
}
}
ToolLifecycleAction::Update => {
tool_action_shell_command_for_shell(tool, action, LifecycleCommandShell::Posix)
}
}
}
fn build_tool_action_line(
@@ -514,10 +530,11 @@ fn build_tool_action_line(
{
// ① WSL 工具(override 是 UNC `\\wsl$\<distro>\...`):锚定的绝对路径是 Windows
// 主机路径,跨 wsl.exe 进入 distro 文件系统后无效;且 enumerate 不参与 WSL。
// 始终走静态命令(npm i -g / 官方 installer)并通过 wsl.exe -d distro -- sh 包一层。
// install 走 POSIX 安装优先级,update 走 POSIX 静态/官方 update 命令,
// 再通过 wsl.exe -d distro -- sh 包一层。
// **必须用 wsl_tool_action_shell_command 而非 tool_action_shell_command**:
// 后者在 Windows target 给 hermes 返回 PowerShell installer,跨 wsl.exe
// 后跑 Linux,要替换为 Unix 版官方 installer/update 命令。
// 后者在 Windows target 给 hermes 返回 PowerShell installer,且 Windows batch
// 语义也不适合跨 wsl.exe;这里统一替换为 POSIX 版安装/更新命令。
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}"))?;
@@ -1984,27 +2001,43 @@ fn static_fallback_command(tool: &str) -> String {
/// - 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/PowerShell 命令。
/// - 配套要求:`build_tool_lifecycle_command` 头部已开 `set -o pipefail`,确保
/// `curl ... | bash` 中 curl 失败能让管道整体非零、`||` 兜底真正触发
#[cfg(not(target_os = "windows"))]
fn install_command_for(tool: &str) -> String {
/// - 对**有 npm 包**的工具(claude/opencode),短路链(POSIX `||`)保证官方脚本不可达/
/// 防火墙拦截时仍能装上,降级到裸 `npm i -g`。官方脚本本身不用 pipe,
/// 所以这条路径在 WSL 的 `sh -c` 子 shell 中也不依赖外层 `pipefail`。
/// - Windows 原生不启用:claude.ai/install.sh、opencode.ai/install 都是 bash 脚本,
/// Windows 原生继续走 `tool_action_shell_command` 的 npm/PowerShell 命令;WSL 作为
/// Linux 环境复用这套 POSIX 安装优先级
fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
match npm_install_command_for(tool) {
Some(npm) => chain_update_commands(
installer.to_string(),
npm.to_string(),
LifecycleCommandShell::Posix,
),
None => installer.to_string(),
}
}
fn posix_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(),
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
"hermes" => HERMES_INSTALL_UNIX.to_string(),
_ => static_fallback_command_for(tool, ToolLifecycleAction::Install),
}
}
#[cfg(not(target_os = "windows"))]
fn install_command_for(tool: &str) -> String {
posix_install_command_for(tool)
}
/// 计算某工具的升级命令与"是否需确认"。全平台共用一份:
/// - **Windows + WSL 工具**(override 是 `\\wsl$\<distro>\...` UNC 路径)始终走静态、
/// 不锚定:锚定命令是 Windows 主机绝对路径,跨 `wsl.exe` 边界进入 distro 文件
/// 系统后完全无效;且 `enumerate_tool_installations` 不参与 WSL 文件系统、锚定
/// 无锚点。这一类显式短路到 `(unix_static, false, false)`,前端不会弹确认。
/// - **Windows + WSL 工具**(override 是 `\\wsl$\<distro>\...` UNC 路径)的升级规划
/// 始终走 POSIX 静态命令、不锚定:锚定命令是 Windows 主机绝对路径,跨 `wsl.exe`
/// 边界进入 distro 文件系统后完全无效;且 `enumerate_tool_installations` 不参与
/// WSL 文件系统、锚定无锚点。这一类显式短路到 `(unix_static, false, false)`,
/// 前端不会弹确认。
/// **必须用 `wsl_tool_action_shell_command`(unix 版)而非 `static_fallback_command`**
/// ——后者读 `tool_action_shell_command`,Windows target 给 hermes 返回 PowerShell
/// installer,跨 wsl.exe 后不适用;`build_tool_action_line` 的 WSL 分支也用同一 wrapper,
@@ -3494,6 +3527,32 @@ mod tests {
);
}
#[test]
fn wsl_install_uses_posix_install_priority() {
let claude =
wsl_tool_action_shell_command("claude", ToolLifecycleAction::Install).unwrap();
assert!(
claude.starts_with("bash -c 'tmp=$(mktemp) && curl -fsSL https://claude.ai/install.sh ")
&& claude.contains(" || npm i -g @anthropic-ai/claude-code@latest"),
"WSL claude install should prefer native POSIX installer with npm fallback: {claude}"
);
assert!(!claude.contains("| bash"));
let opencode =
wsl_tool_action_shell_command("opencode", ToolLifecycleAction::Install).unwrap();
assert!(
opencode.starts_with(
"bash -c 'tmp=$(mktemp) && curl -fsSL https://opencode.ai/install "
) && opencode.contains(" || npm i -g opencode-ai@latest"),
"WSL opencode install should prefer native POSIX installer with npm fallback: {opencode}"
);
assert!(!opencode.contains("| bash"));
let codex =
wsl_tool_action_shell_command("codex", ToolLifecycleAction::Install).unwrap();
assert_eq!(codex, "npm i -g @openai/codex@latest");
}
#[test]
fn wsl_npm_tools_use_posix_update_chain_without_batch_call() {
// WSL 内跑的是 POSIX shell,不能带 Windows batch 的 `call`。同时 update
@@ -3970,6 +4029,10 @@ mod tests {
let parts: Vec<&str> = cmd.split("||").collect();
assert_eq!(parts.len(), 2, "should be a two-step short-circuit chain");
assert!(parts[0].contains("install.sh"), "native first: {cmd}");
assert!(
!parts[0].contains('|'),
"native installer should avoid pipe: {cmd}"
);
assert!(parts[1].contains("npm i -g"), "npm second: {cmd}");
}
@@ -3986,6 +4049,10 @@ mod tests {
"should keep npm package as fallback: {cmd}"
);
assert!(cmd.contains("||"), "should chain fallback: {cmd}");
assert!(
!cmd.split("||").next().unwrap_or_default().contains('|'),
"native installer should avoid pipe: {cmd}"
);
}
#[test]
+40 -3
View File
@@ -39,6 +39,7 @@ import appIcon from "@/assets/icons/app-icon.png";
import { APP_ICON_MAP } from "@/config/appConfig";
import type { AppId } from "@/lib/api/types";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isWindows } from "@/lib/platform";
import { ToolUpgradeConfirmDialog } from "./ToolUpgradeConfirmDialog";
import { ToolInstallRow } from "./ToolInstallRow";
@@ -104,7 +105,39 @@ const ENV_BADGE_CONFIG: Record<
},
};
const ONE_CLICK_INSTALL_COMMANDS = `# Claude Code
const posixScriptInstallCommand = (url: string) =>
`bash -c 'tmp=$(mktemp) && curl -fsSL ${url} -o $tmp && bash $tmp; status=$?; rm -f $tmp; exit $status'`;
const HERMES_WINDOWS_INSTALL_SCRIPT =
"irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex";
const powershellEncodedCommand = (script: string): string => {
let binary = "";
for (let i = 0; i < script.length; i += 1) {
const code = script.charCodeAt(i);
binary += String.fromCharCode(code & 0xff, code >> 8);
}
return btoa(binary);
};
const HERMES_WINDOWS_INSTALL_COMMAND = `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${powershellEncodedCommand(
HERMES_WINDOWS_INSTALL_SCRIPT,
)}`;
const POSIX_ONE_CLICK_INSTALL_COMMANDS = `# Claude Code
${posixScriptInstallCommand("https://claude.ai/install.sh")} || npm i -g @anthropic-ai/claude-code@latest
# Codex
npm i -g @openai/codex@latest
# Gemini CLI
npm i -g @google/gemini-cli@latest
# OpenCode
${posixScriptInstallCommand("https://opencode.ai/install")} || npm i -g opencode-ai@latest
# OpenClaw
npm i -g openclaw@latest
# Hermes
${posixScriptInstallCommand("https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh")}`;
const WINDOWS_ONE_CLICK_INSTALL_COMMANDS = `# Claude Code
npm i -g @anthropic-ai/claude-code@latest
# Codex
npm i -g @openai/codex@latest
@@ -115,7 +148,11 @@ npm i -g opencode-ai@latest
# OpenClaw
npm i -g openclaw@latest
# Hermes
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`;
${HERMES_WINDOWS_INSTALL_COMMAND}`;
const ONE_CLICK_INSTALL_COMMANDS = isWindows()
? WINDOWS_ONE_CLICK_INSTALL_COMMANDS
: POSIX_ONE_CLICK_INSTALL_COMMANDS;
const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
claude: "Claude Code",
@@ -185,7 +222,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
// 升级 preflight(probe 阶段)的 in-flight 工具集合。
// probeToolInstallations 是个 1-3 秒级别的跨进程探测(对每个工具跑 --version + canonicalize),
// 在它返回之前 toolActions / batchAction 都还没被置位 → 按钮不会 disabled → 用户快速双击
// 会并发开两轮 probe,各自再触发 executeRun(并发的 `npm i -g` / `curl | bash`,写冲突)。
// 会并发开两轮 probe,各自再触发 executeRun(并发的 `npm i -g` / 官方 installer,写冲突)。
// 把 probe 期间的工具登记在这里、纳入 isAnyBusy 派生,关掉这个并发窗口。
// 用 Set 而非 boolean:单卡片升级 & 批量升级可能在不同工具上独立 preflight,
// 精确反映到各自卡片按钮的 disabled。