mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
@@ -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]
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user