mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 04:02:02 +08:00
fix(settings): make Grok Build upgrade work from the GUI
`grok update` discovers and installs releases by spawning `npm view` and `npm i -g`, even for xAI's native install — 0.2.112 moved the self-update path onto npm distribution, so the binary now needs node on PATH. Lifecycle scripts run under a non-login `bash -c` inheriting launchd's narrow PATH, where npm and node are invisible, so upgrading grok failed with a bare `Error: No such file or directory (os error 2)`. Inject the login shell's real PATH into run_tool_lifecycle_silently, closing the asymmetry between probing (`$SHELL -lic`, which reads .zshrc) and execution (non-login bash). Read it through `/usr/bin/env` rather than `echo $PATH`: fish stores PATH as a list and would emit space-separated segments, while env always prints the child's real environment. This also revives the install chain's bare `npm i -g` fallback, which could only ever exit 127 under the narrow PATH. Chain the official installer after native Grok's self-update. An npm fallback would share both of the primary's failure modes — no node, or a registry mirror missing the tarball — and fail alongside it; the installer is the only node-free path and lands in the same ~/.grok/bin. It also rewrites `[cli] installer` back to `internal`, healing users whom the install-time npm fallback had switched onto npm distribution.
This commit is contained in:
+216
-14
@@ -214,11 +214,16 @@ fn run_tool_lifecycle_silently(command_line: &str, _label: &str) -> Result<(), S
|
||||
use std::process::Command;
|
||||
// command_line 是 bash 风格脚本(含 `set -e` 与多行命令);强制用 bash 执行,
|
||||
// 避免用户默认 shell 为 fish/zsh 时 `set -e` 等语义不一致。
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(command_line)
|
||||
.output()
|
||||
.map_err(|e| format!("启动安装进程失败: {e}"))?;
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg("-c").arg(command_line);
|
||||
// GUI App 继承的是 launchd 的窄 PATH,而锚定探测用的是登录 shell —— 见
|
||||
// `login_shell_path` doc。命令自身用绝对路径不受影响,但工具**内部** spawn 的
|
||||
// npm/node 等子进程、以及 install 链里裸的 npm fallback 都需要真实 PATH。
|
||||
if let Some(login_path) = login_shell_path() {
|
||||
let inherited = std::env::var("PATH").unwrap_or_default();
|
||||
cmd.env("PATH", merge_path_segments(&login_path, &inherited));
|
||||
}
|
||||
let output = cmd.output().map_err(|e| format!("启动安装进程失败: {e}"))?;
|
||||
finish_lifecycle_output(&output)
|
||||
}
|
||||
|
||||
@@ -1855,6 +1860,73 @@ fn first_abs_path_line(raw: &str) -> Option<&str> {
|
||||
raw.lines().map(str::trim).find(|l| l.starts_with('/'))
|
||||
}
|
||||
|
||||
/// 从 `env` 输出里取 `PATH=` 那行的值。要求值以 `/` 开头——PATH 首段必是绝对路径,
|
||||
/// 这条约束顺带跳过"某个多行值的环境变量恰好有一行以 `PATH=` 开头"的污染,
|
||||
/// 与 `first_abs_path_line` 对交互式 shell 噪音的容错同理。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn path_line_from_env_output(raw: &str) -> Option<&str> {
|
||||
raw.lines()
|
||||
.filter_map(|line| line.strip_prefix("PATH="))
|
||||
.find(|value| value.starts_with('/'))
|
||||
}
|
||||
|
||||
/// 合并两段 PATH:`primary` 全部保留在前,`extra` 中未出现过的段按序追加。
|
||||
/// 空段直接丢弃(`a::b` 里的空段在 POSIX 下语义是"当前目录",注入时不该带上)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn merge_path_segments(primary: &str, extra: &str) -> String {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut merged: Vec<&str> = Vec::new();
|
||||
for segment in primary.split(':').chain(extra.split(':')) {
|
||||
if segment.is_empty() || !seen.insert(segment) {
|
||||
continue;
|
||||
}
|
||||
merged.push(segment);
|
||||
}
|
||||
merged.join(":")
|
||||
}
|
||||
|
||||
/// 用与 `resolve_path_default` 相同的登录 shell 解析用户的真实 PATH,
|
||||
/// 供 `run_tool_lifecycle_silently` 注入给安装/升级脚本。
|
||||
///
|
||||
/// **要解决的不对称**:探测阶段(`try_get_version` / `resolve_path_default`)跑的是
|
||||
/// `$SHELL -lic`,会读 `.zshrc`/`.zprofile`,看得到 nvm / homebrew / volta;而执行阶段
|
||||
/// 是非登录 `bash -c`,继承的是 launchd 给 GUI App 的 PATH,通常只有
|
||||
/// `/usr/bin:/bin:/usr/sbin:/sbin`。锚定命令自己用绝对路径调用执行体,本不受这条影响
|
||||
/// ——但有两类漏网:
|
||||
/// 1. **执行体在内部再 spawn 第三方 CLI**:`grok update` 靠 `npm view` 查最新版本
|
||||
/// (见 `grok_native_update_command`),npm 又是 `#!/usr/bin/env node` 脚本;
|
||||
/// 未来任何 self-update 内部调 node/git/python 同理。
|
||||
/// 2. **install 分支的 `<官方 installer> || npm i -g <pkg>@latest`**:`||` 右侧是裸命令,
|
||||
/// 窄 PATH 下必然 exit 127,等于没有兜底。
|
||||
///
|
||||
/// 把执行阶段的 PATH 拉平到探测阶段的水平,一次消除这两类。
|
||||
///
|
||||
/// **用 `/usr/bin/env` 而不是 `echo $PATH`**:`$PATH` 在 fish 里是 list 类型,
|
||||
/// `"$PATH"` 展开成空格分隔而非冒号分隔;`env` 打印的则是子进程的真实环境,
|
||||
/// 任何 shell 下格式都正确。写绝对路径又绕过了 alias / function / PATH 三重不确定性
|
||||
/// (交互式 shell 会加载用户 alias)。
|
||||
///
|
||||
/// 解析不到时返回 `None`,调用方保持原有行为(不注入),不引入新的失败模式。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn login_shell_path() -> Option<String> {
|
||||
use std::process::Command;
|
||||
let shell = std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|s| is_valid_shell(s))
|
||||
.unwrap_or_else(|| "sh".to_string());
|
||||
let flag = default_flag_for_shell(&shell);
|
||||
let out = Command::new(shell)
|
||||
.arg(flag)
|
||||
.arg("/usr/bin/env")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let raw = decode_command_output(&out.stdout);
|
||||
Some(path_line_from_env_output(&raw)?.to_string())
|
||||
}
|
||||
|
||||
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
|
||||
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -2205,6 +2277,64 @@ 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)))
|
||||
}
|
||||
|
||||
/// Grok Build 原生安装的升级命令 = `<bin 绝对> update || <官方 installer>`。
|
||||
///
|
||||
/// **为什么唯独 native Grok 的 self-update 需要 fallback**(claude native / hermes 都没有):
|
||||
/// `grok update` 虽然是自包含 Rust 二进制的子命令,**却把 npm 当成自己的分发管道**——
|
||||
/// 先 spawn `npm view @xai-official/grok version --json` 查最新版,再 spawn
|
||||
/// `npm i -g @xai-official/grok@<version>` 安装,由该包的 `postinstall.js` 从平台 optional
|
||||
/// 依赖里解出二进制、安置成 `~/.grok/bin/grok-<version>` 并 relink `grok`。而 `npm` 自身是
|
||||
/// `#!/usr/bin/env node` 脚本 → **native 安装也隐式硬依赖 PATH 里同时有 `npm` + `node`**。
|
||||
/// GUI 进程 PATH 由 launchd 给、`run_tool_lifecycle_silently` 又是非登录 `bash -c`,
|
||||
/// nvm/homebrew 下的 node+npm 均不可见 → grok 内部 spawn 得到 ENOENT,只向用户抛出
|
||||
/// 费解的 `Error: No such file or directory (os error 2)`(实测复现)。
|
||||
///
|
||||
/// **这是上游换了机制**:0.2.111 及更早版本自更新是直接下载二进制到 `~/.grok/downloads/`
|
||||
/// (彼时 `is_grok_native_install` 假设的 "native = 不碰 npm" 成立),0.2.112 起改走上述 npm
|
||||
/// 管道(落点随之从 `downloads/` 变为 `bin/`)。**副作用**:npm 全局包那一处安装是
|
||||
/// `grok update` 自己装出来的,非用户手动所为,故 native 用户也会被 enumerate 到两处;
|
||||
/// 两处由同一次 postinstall 同步,版本恒等。
|
||||
///
|
||||
/// 这正是 `anchored_command_from_paths` 那条"绝对路径 + 必要时把解释器目录放 PATH 首位"
|
||||
/// 不变量没覆盖的第三类:**执行体自身既不需要解释器、也已用绝对路径,却在内部再 spawn
|
||||
/// 第三方 CLI**。`login_shell_path` 的 PATH 注入已让绝大多数机器上的 primary 直接成功;
|
||||
/// 这条 fallback 覆盖的是"这台机器根本没装 node"——用官方 installer 装的用户完全可能如此。
|
||||
///
|
||||
/// **fallback 必须是官方 installer,不能是 `npm i -g`**:后者与 primary **同源**——primary
|
||||
/// 失败的两种现实原因(本机无 node;npm registry 指向未同步该 tarball 的镜像,见
|
||||
/// npmmirror dist-tag 事故)都会让 npm fallback 一并失败,`||` 形同虚设。官方 installer
|
||||
/// 是唯一 node-free 的独立路径(只需 `curl`,在 `/usr/bin`,窄 PATH 下可用),且落点同为
|
||||
/// `~/.grok/bin`,锚定语义分毫不动 —— 真正的降级冗余要求 fallback 与 primary **失败模式不相关**。
|
||||
///
|
||||
/// **这条 fallback 还兼具第三重作用:修复上游锚点(勿在重构时丢掉)**。
|
||||
/// grok 的更新路径由 `~/.grok/config.toml` 的 `[cli] installer` 决定(`npm` / `internal` /
|
||||
/// `gh-release`),而官方 install.sh 会**无条件把该字段覆写为 `internal`**(其 awk 段落先插入
|
||||
/// `installer = "internal"`、再跳过 `[cli]` 段里已有的 `installer`/`channel` 行)。于是:
|
||||
/// 用户一旦因 install 端的 npm fallback 被切进 npm 模式(postinstall.js 会写 `installer = "npm"`
|
||||
/// 并在每次 npm 更新时重写,自我巩固),只要 npm 路径出任何问题,这里的 `||` 就会把他拉回
|
||||
/// node-free 的 internal 模式,并顺带修好 npm 模式漏更新的 `~/.grok/bin/agent` launcher。
|
||||
/// **实测验证**(2026-07-30,窄 PATH + `installer = "npm"`):primary 抛 `os error 2` → fallback
|
||||
/// 接管 → 装上最新版 → config 写回 `internal` → agent 对齐 → `.zshrc` 幂等更新不重复。
|
||||
/// ⇒ install 端保留 npm fallback 是安全的(它是 x.ai 不可达时的唯一退路,防火墙场景需要),
|
||||
/// 其副作用由本链自愈;**把这里换成 npm fallback 会同时废掉降级冗余和这条自愈路径**。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn grok_native_update_command(update: String) -> String {
|
||||
chain_update_commands(
|
||||
update,
|
||||
GROK_INSTALL_UNIX.to_string(),
|
||||
LifecycleCommandShell::Posix,
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows 版同上,fallback 换成官方 PowerShell installer。
|
||||
/// **不走 `chain_update_commands`**:它会给 `||` 右侧加 `call`,而这里的 fallback 是
|
||||
/// `powershell.exe`(不是 `.cmd`/`.bat`),不需要 `call`——与 `hermes_update_windows_command`
|
||||
/// 同一理由。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn grok_native_update_command(update: String) -> String {
|
||||
format!("{update} || {}", grok_install_windows_command())
|
||||
}
|
||||
|
||||
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
|
||||
///
|
||||
/// **codex 刻意不在此列**:`codex update` 在 npm 安装上只是裸 `npm install -g
|
||||
@@ -2359,7 +2489,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
return Some(grok_native_update_command(
|
||||
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() {
|
||||
@@ -2438,7 +2570,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
}
|
||||
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
|
||||
return anchored_official_update_command(tool, bin_path);
|
||||
return Some(grok_native_update_command(
|
||||
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) {
|
||||
@@ -2535,6 +2669,11 @@ fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
|
||||
fn posix_install_command_for(tool: &str) -> String {
|
||||
match tool {
|
||||
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
|
||||
// Grok 的 npm fallback **会切换用户的分发模式**(该包 postinstall 把
|
||||
// `~/.grok/config.toml` 的 `[cli] installer` 写成 `npm`,此后 `grok update` 一律走
|
||||
// npm、隐式依赖 node)。仍然保留它:官方 installer 不可达(防火墙 / x.ai 被拦)时
|
||||
// 这是唯一退路,而副作用可自愈——`grok_native_update_command` 的 `||` 官方 installer
|
||||
// 会在 npm 路径出问题时把 `installer` 覆写回 `internal`(见该函数 doc 的实测记录)。
|
||||
"grok" => installer_with_npm_fallback(GROK_INSTALL_UNIX, tool),
|
||||
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
|
||||
"hermes" => HERMES_INSTALL_UNIX.to_string(),
|
||||
@@ -4043,11 +4182,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_windows_uses_self_update() {
|
||||
fn grok_native_windows_uses_self_update_with_installer_fallback() {
|
||||
// sibling 有 npm.cmd 也**不能**拿它当 fallback:`grok update` 本身就是靠 npm
|
||||
// 分发的(见 grok_native_update_command doc),npm fallback 与 primary 同源、
|
||||
// 会一起失败。fallback 必须是官方 PowerShell installer —— 唯一不经 npm 的路径。
|
||||
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]);
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
|
||||
let expected = format!("{} update", expect_quoted_path(&bin_path));
|
||||
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
|
||||
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path).unwrap();
|
||||
let expected = format!(
|
||||
"{} update || {}",
|
||||
expect_quoted_path(&bin_path),
|
||||
grok_install_windows_command()
|
||||
);
|
||||
assert_eq!(cmd, expected);
|
||||
// fallback 是 powershell.exe 不是 .cmd/.bat —— `||` 右侧不该有 `call`。
|
||||
assert!(
|
||||
!cmd.contains("|| call"),
|
||||
"powershell needs no `call`: {cmd}"
|
||||
);
|
||||
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4517,7 +4669,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_installer_uses_self_update() {
|
||||
fn grok_native_installer_uses_self_update_with_installer_fallback() {
|
||||
// ~/.grok/bin/grok is a launcher symlink into ~/.grok/downloads.
|
||||
// Updating it through npm would create or mutate a different install.
|
||||
let cmd = anchored_command_from_paths(
|
||||
@@ -4525,7 +4677,25 @@ mod tests {
|
||||
"/Users/me/.grok/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(format!("/Users/me/.grok/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_native_update_falls_back_to_installer_not_npm() {
|
||||
// 反向锁定:`grok update` 内部靠 `npm view` + `npm i -g` 完成升级,GUI 的窄
|
||||
// PATH 下会 ENOENT。fallback 必须是官方 installer —— 换成 `npm i -g` 就与
|
||||
// primary 同源(无 node / 镜像缺 tarball 时一起失败),`||` 形同虚设。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"grok",
|
||||
"/Users/me/.grok/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
)
|
||||
.expect("native grok should anchor");
|
||||
assert!(cmd.contains("x.ai/cli/install.sh"), "{cmd}");
|
||||
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4535,7 +4705,10 @@ mod tests {
|
||||
"/Users/me/bin/grok",
|
||||
"/Users/me/.grok/downloads/grok-macos-aarch64",
|
||||
);
|
||||
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(format!("/Users/me/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5006,6 +5179,35 @@ mod tests {
|
||||
assert_eq!(first_abs_path_line("welcome\nbye\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_line_from_env_output_survives_shell_noise() {
|
||||
// `$SHELL -lic /usr/bin/env` 的 stdout 前面可能有交互式 rc 的欢迎语。
|
||||
let raw = "🚀 Welcome back, Jason!\nSHELL=/bin/zsh\nPATH=/opt/homebrew/bin:/usr/bin\nHOME=/Users/me\n";
|
||||
assert_eq!(
|
||||
path_line_from_env_output(raw),
|
||||
Some("/opt/homebrew/bin:/usr/bin")
|
||||
);
|
||||
// 多行值的环境变量里恰好有一行以 `PATH=` 开头时,「值须以 / 开头」把它筛掉。
|
||||
let poisoned = "SOME_SCRIPT=line1\nPATH=not-a-path\nPATH=/usr/bin:/bin\n";
|
||||
assert_eq!(path_line_from_env_output(poisoned), Some("/usr/bin:/bin"));
|
||||
// 完全没有 PATH 行 → None,调用方保持不注入。
|
||||
assert_eq!(path_line_from_env_output("HOME=/Users/me\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_path_segments_dedupes_preserving_login_order() {
|
||||
// 登录 shell 的段全部在前且保序;继承 PATH 里的新段追加在后。
|
||||
assert_eq!(
|
||||
merge_path_segments(
|
||||
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin",
|
||||
"/usr/bin:/bin:/usr/sbin"
|
||||
),
|
||||
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin:/usr/sbin"
|
||||
);
|
||||
// 空段(`a::b` 在 POSIX 下意为当前目录)不该被注入。
|
||||
assert_eq!(merge_path_segments("/usr/bin::/bin", ""), "/usr/bin:/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_conflicting_thresholds() {
|
||||
let make = |version: Option<&str>, runnable: bool| ToolInstallation {
|
||||
|
||||
Reference in New Issue
Block a user