diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index a59560955..3abb43e87 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1985,10 +1985,19 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option update || `)。 +/// +/// **codex 刻意不在此列**:`codex update` 在 npm 安装上只是裸 `npm install -g +/// @openai/codex`(无 `@latest` / `--include=optional` / 不先卸载),却只检查 exit code、 +/// 无条件打印 “Update ran successfully”。当 npm 把平台二进制 optional 依赖 +/// `@openai/codex-` 漏装时它仍 **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-`」的分发模式(同 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 { + // 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 { + 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 { 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"