mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 04:22:58 +08:00
fix(updater): prevent codex self-update from breaking npm platform-dispatch installs
Codex ships as an npm platform-dispatch package (JS launcher @openai/codex + per-platform binary optional deps like @openai/codex-darwin-arm64). The upgrade chain ran `<bin>/codex update || <bin>/npm i -g @openai/codex@latest`, which can leave codex throwing "Missing optional dependency @openai/codex-darwin-arm64": - `codex update` on an npm install is a bare `npm install -g @openai/codex` that prints success and exits 0 even when the platform binary fails to land, short-circuiting the `||` npm fallback. - The npm fallback is a no-op when version==latest and only targets the main package, so it can never re-land the missing platform binary. Fixes: (1) remove codex from prefers_official_update (Posix+Windows) so npm-managed codex no longer runs the false-success `codex update`; (2) add a runnable=false gate in installs_anchored_command emitting an uninstall+install self-heal — the only repair that re-lands the platform binary; (3) narrow by source/real to npm-managed sources (nvm/fnm/mise/homebrew, non-brew-formula) so broken brew-formula/volta/bun installs fall back to their own anchored commands instead of being mis-repaired with npm. Reuses the existing enumerate runnable signal; no new FS probing.
This commit is contained in:
+175
-11
@@ -1985,10 +1985,19 @@ 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)))
|
||||
}
|
||||
|
||||
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
|
||||
///
|
||||
/// **codex 刻意不在此列**:`codex update` 在 npm 安装上只是裸 `npm install -g
|
||||
/// @openai/codex`(无 `@latest` / `--include=optional` / 不先卸载),却只检查 exit code、
|
||||
/// 无条件打印 “Update ran successfully”。当 npm 把平台二进制 optional 依赖
|
||||
/// `@openai/codex-<triple>` 漏装时它仍 **exit 0 假成功**,使外层 `||` 兜底被短路、损坏被
|
||||
/// 成功 toast 掩盖(用户报告的 “Missing optional dependency” 即源于此)。因此 codex 一律走
|
||||
/// npm 锚定升级;真正损坏(`runnable=false`)时由 `installs_anchored_command` 的门控改用
|
||||
/// `codex_repair_command` 的 uninstall+install 自愈,而非交给 codex 自身的 self-update。
|
||||
fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
|
||||
match shell {
|
||||
LifecycleCommandShell::Posix => {
|
||||
matches!(tool, "claude" | "codex" | "opencode" | "openclaw")
|
||||
matches!(tool, "claude" | "opencode" | "openclaw")
|
||||
}
|
||||
LifecycleCommandShell::WindowsBatch => {
|
||||
matches!(
|
||||
@@ -1997,12 +2006,66 @@ fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
|
||||
// 安装方式探测失败弹交互 prompt(spawn npm.cmd 没传 shell:true);静默
|
||||
// lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了
|
||||
// 再把 opencode 加回这里。
|
||||
"claude" | "codex" | "openclaw"
|
||||
"claude" | "openclaw"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 平台分发包损坏的自愈命令。Codex 的 npm 包是「主包 `@openai/codex`(纯 JS
|
||||
/// launcher)+ 平台二进制 optional 依赖 `@openai/codex-<triple>`」的分发模式(同 esbuild/swc)。
|
||||
/// 当平台二进制缺失时 codex 跑不起来——`enumerate_tool_installations` 跑 `--version` 会拿到
|
||||
/// “Missing optional dependency” 的非 0 退出,标记 `runnable=false`。此状态下普通
|
||||
/// `npm i -g @pkg@latest` 是 **no-op**:npm 视 optional 依赖缺失为非致命,reify 又认为主包已是
|
||||
/// 最新(外加半损坏留下的空 nested `node_modules` 残骸强化「tree 已满足」判断),不会补回平台
|
||||
/// 二进制。唯一实测可靠的修复是先 `uninstall` 清掉残骸、再 `install` 装回完整的主包 + 平台二进制
|
||||
/// (实测输出 `added 2 packages`)。
|
||||
///
|
||||
/// 锚定到与 codex 入口同目录的 npm(与升级路径一致,不依赖 GUI 非登录进程的 PATH)。`|| true`
|
||||
/// 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e` 中止,但随后的
|
||||
/// install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
|
||||
///
|
||||
/// **仅对会锚定到 sibling npm 的 node 管理器来源(nvm/fnm/mise/homebrew npm)生效**:
|
||||
/// `runnable=false` 是宽信号(权限 / node 版本 / 任意 `--version` 失败皆可触发),非 npm
|
||||
/// 全局安装各有自己的二进制分发与修复方式,无脑套 npm uninstall+install 会出错——Homebrew
|
||||
/// formula(real 在 `Cellar/`)本应 `brew upgrade codex`,npm 够不到它反而旁路装第二份 npm
|
||||
/// 全局 codex;Volta/Bun 本应 `volta install`/`bun add`,且 `~/.bun/bin` 下没有 npm、
|
||||
/// `sibling_bin` 会拼出不存在的路径;system/未知来源无可靠 sibling npm。这些来源一律返回
|
||||
/// None,让上游继续走 source-specific 的 `anchored_command_from_paths`。白名单与
|
||||
/// `package_manager_anchored_command_from_paths` 的 sibling-npm 分支对齐。
|
||||
/// 刻意**不**额外用 `inst.error` 文本确认「确系缺二进制」:enumerate 只保留 stderr 末尾 4 行,
|
||||
/// 而 codex.js 抛错的 "Missing optional dependency" 行会被尾部 node stack `at ...` 行挤出窗口
|
||||
/// (实测用户原始错误即如此),强加该条件反而漏修真实缺包;对 npm 全局安装,uninstall+install
|
||||
/// 对各类损坏都是合理且不会更糟的修复。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
|
||||
// brew formula(real 在 Cellar)→ 不归 npm 管,交回 anchored 走 brew upgrade。
|
||||
if brew_formula_from_path(real).is_some() {
|
||||
return None;
|
||||
}
|
||||
// 只认会落到 sibling npm 的 node 管理器来源;volta/bun/system/未知交回 anchored。
|
||||
if !matches!(
|
||||
infer_install_source(Path::new(bin_path)),
|
||||
"nvm" | "fnm" | "mise" | "homebrew"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let npm = sibling_bin(bin_path, "npm")?;
|
||||
let npm = quote_path_if_spaced(&npm);
|
||||
let pkg = "@openai/codex";
|
||||
Some(format!(
|
||||
"{npm} uninstall -g {pkg} || true; {npm} i -g {pkg}@latest"
|
||||
))
|
||||
}
|
||||
|
||||
/// Windows 暂不做平台分发自愈:Windows 上 codex 的破坏模式不同(EPERM 文件锁 / 版本 bump
|
||||
/// 残留,见 openai/codex#21872、#19824),且 `.bat` 链的错误处理与 POSIX `set -e` 语义不同,
|
||||
/// 需要单独设计;先在本问题实际发生的 POSIX 平台落地。返回 None → 上游走正常锚定命令。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn codex_repair_command(_bin_path: &str, _real: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn package_manager_anchored_command_from_paths(
|
||||
tool: &str,
|
||||
@@ -2190,6 +2253,17 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
|
||||
fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> {
|
||||
let inst = default_install(installs)?;
|
||||
let real = inst.real.to_string_lossy();
|
||||
// Codex 平台分发包损坏自愈:主包在但平台二进制缺失时 codex 跑不起来
|
||||
// (runnable=false),此时正常锚定的 `npm i -g @latest` 是 no-op 修不好——改用
|
||||
// uninstall+install 重装补回平台二进制。**但仅限会锚定到 sibling npm 的 node 管理器
|
||||
// 来源**(codex_repair_command 内按 source/real 收窄,brew/volta/bun/system 交回下方
|
||||
// source-specific 锚定,避免误用 npm 重装)。runnable=true 的正常升级也走下方普通锚定
|
||||
// 路径(且因 codex 不在 prefers_official_update,不会再跑会假成功掩盖损坏的 `codex update`)。
|
||||
if tool == "codex" && !inst.runnable {
|
||||
if let Some(cmd) = codex_repair_command(&inst.path, &real) {
|
||||
return Some(cmd);
|
||||
}
|
||||
}
|
||||
anchored_command_from_paths(tool, &inst.path, &real)
|
||||
}
|
||||
|
||||
@@ -4046,8 +4120,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn codex_nvm_anchors_to_that_npm() {
|
||||
// Codex 官方 self-update 只在支持的 release 上生效;失败时仍写回同一个
|
||||
// node 的 npm,而非 PATH 第一个 npm。
|
||||
// Codex 不走 self-update(`codex update` 在 npm 安装上只是裸 `npm install -g`,
|
||||
// 却会假成功掩盖平台二进制漏装)——直接锚定到同一个 node 的 npm,而非 PATH
|
||||
// 第一个 npm。损坏时的 uninstall+install 自愈见 codex_missing_platform_binary_*。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
|
||||
@@ -4055,7 +4130,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/codex update || /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4075,9 +4150,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volta_uses_volta_install() {
|
||||
fn volta_self_update_chain_anchors_to_volta() {
|
||||
// `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能
|
||||
// PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。
|
||||
// 用 openclaw(仍在 prefers_official_update)覆盖 volta 分支的 self-update 链;
|
||||
// codex 已改为不 self-update(见 codex_volta_anchors_to_volta_install)。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"openclaw",
|
||||
"/Users/me/.volta/bin/openclaw",
|
||||
"/Users/me/.volta/tools/image/packages/openclaw/lib/node_modules/openclaw",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.volta/bin/openclaw update --yes || /Users/me/.volta/bin/volta install openclaw")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_volta_anchors_to_volta_install() {
|
||||
// codex 锚定到命令行命中的那份 volta,但不 self-update:纯 `volta install`。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/Users/me/.volta/bin/codex",
|
||||
@@ -4085,7 +4176,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.volta/bin/codex update || /Users/me/.volta/bin/volta install @openai/codex")
|
||||
Some("/Users/me/.volta/bin/volta install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4113,7 +4204,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.volta/bin/codex' update || '/Users/my name/.volta/bin/volta' install @openai/codex")
|
||||
Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4181,7 +4272,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex update || /Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4195,7 +4286,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.nvm/versions/node/v22/bin/codex' update || '/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4292,6 +4383,78 @@ mod tests {
|
||||
assert!(default_install(&installs).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_missing_platform_binary_self_heals_via_uninstall_install() {
|
||||
// 平台二进制缺失 → `codex --version` 报 "Missing optional dependency" 退出非 0
|
||||
// → enumerate 标记 runnable=false。此状态下普通 `npm i -g @latest` 是 no-op 修不好,
|
||||
// 升级路径改用 uninstall+install 重装补回平台二进制(`|| true` 让 uninstall 在
|
||||
// set -e 下对半损坏包返回非 0 时仍继续 install)。
|
||||
let mut broken = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
|
||||
broken.runnable = false;
|
||||
assert_eq!(
|
||||
installs_anchored_command("codex", &[broken]).as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_runnable_uses_plain_npm_not_self_heal() {
|
||||
// 正常(runnable=true)的 codex 升级:锚定 npm,既不重装、也不跑会假成功
|
||||
// 掩盖损坏的 `codex update`。
|
||||
let healthy = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
|
||||
let cmd = installs_anchored_command("codex", &[healthy]);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
assert!(!cmd.unwrap().contains("uninstall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_homebrew_formula_uses_brew_not_npm_repair() {
|
||||
// brew formula 装的坏 codex(real 在 Cellar):自愈门控必须收窄放行,回落到
|
||||
// `brew upgrade codex`——若误走 npm 重装,npm 够不到 Cellar 那份、反而旁路
|
||||
// 装第二份 npm 全局 codex 制造双安装。
|
||||
let broken = ToolInstallation {
|
||||
path: "/opt/homebrew/bin/codex".to_string(),
|
||||
version: None,
|
||||
runnable: false,
|
||||
error: None,
|
||||
source: "homebrew".to_string(),
|
||||
is_path_default: true,
|
||||
real: std::path::PathBuf::from("/opt/homebrew/Cellar/codex/1.2.3/bin/codex"),
|
||||
};
|
||||
assert_eq!(
|
||||
installs_anchored_command("codex", &[broken]).as_deref(),
|
||||
Some("/opt/homebrew/bin/brew upgrade codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_volta_uses_volta_install_not_npm_repair() {
|
||||
// volta 装的坏 codex:回落到 `volta install`,不走 npm 重装。
|
||||
let mut broken = inst("/Users/me/.volta/bin/codex", true);
|
||||
broken.runnable = false;
|
||||
assert_eq!(
|
||||
installs_anchored_command("codex", &[broken]).as_deref(),
|
||||
Some("/Users/me/.volta/bin/volta install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_bun_uses_bun_add_not_phantom_npm() {
|
||||
// bun 装的坏 codex:回落到 `bun add`,且**绝不**拼出 `~/.bun/bin/npm`
|
||||
// (bun 目录下没有 npm,那条路径不存在、执行会直接失败)。
|
||||
let mut broken = inst("/Users/me/.bun/bin/codex", true);
|
||||
broken.runnable = false;
|
||||
let cmd = installs_anchored_command("codex", &[broken]);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.bun/bin/bun add -g @openai/codex@latest")
|
||||
);
|
||||
assert!(!cmd.unwrap().contains("npm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_abs_path_line_skips_shell_noise() {
|
||||
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
|
||||
@@ -4419,8 +4582,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
static_fallback_command("codex"),
|
||||
"codex update || npm i -g @openai/codex@latest"
|
||||
"npm i -g @openai/codex@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("codex").contains("codex update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("gemini"),
|
||||
"npm i -g @google/gemini-cli@latest"
|
||||
|
||||
Reference in New Issue
Block a user