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:
Jason
2026-07-30 16:00:03 +08:00
parent a354f08a3d
commit f07edc7680
+216 -14
View File
@@ -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 {